Compare commits

...
Sign in to create a new pull request.

52 commits

Author SHA1 Message Date
d0bfb39863 Release: merge develop into main for v2.2.0
feat: Enhance dataset management and submission features

- Added Activity import to main store and initialized activities array.
- Updated app.ts to use Inertia router for navigation events.
- Introduced useDatasetChangeDetection composable for tracking dataset changes.
- Created useDatasetFormSubmission composable for handling dataset form submissions.
- Modified app.edge to dynamically load page-specific JS/CSS with Inertia v3.
- Added OPENSEARCH_HOST to environment configuration.
- Adjusted middleware registration in kernel.ts for Inertia.
- Implemented activities API route in api.ts.
- Refined array_contains_types validation rule for titles and descriptions.
- Updated Vite configuration for improved asset management and build process.
2026-06-30 09:43:01 +02:00
f0a45a929f feat: implement activity logging and related features
Merge branch 'fix/version-chain-api' into develop

- Add ActivityLogger service for logging user activities.
- Create activities table migration for storing activity logs.
- Update Dataset model to log dataset uploads and publications.
- Enhance User model to include activities relationship.
- Introduce command to fix version-related IDs in dataset references.
- Modify Inertia configuration to include OpenSearch host.
- Update Dashboard page to display recent activities.
- Extend main store to manage activities state.
- Add API route for fetching activities.
- updatet Api/DatasetController.ts for correct version chaining of related datasets
2026-06-30 09:31:16 +02:00
1b1c83e022 fix: refactor OaiController to improve error handling and enforce request validation 2026-06-30 09:22:50 +02:00
7e2f320b4f feat: implement activity logging for user actions and create activities table 2026-06-24 15:03:17 +02:00
6c75efbc28 fix: update OpenSearch host configuration and improve code consistency across controllers and components 2026-06-24 11:38:24 +02:00
9c0221ce27 fix: repair related_document_id for version references
Add `fix:version-related-ids` ace command to backfill and correct
related_document_id on IsNewVersionOf / IsPreviousVersionOf references,
resolving the target dataset via its DOI. Handles both NULL and
self-referential (wrong) values that the existing detect command could
not repair.

Make the dataset version-chain API DOI-based: resolve previous/newer
versions through the DOI in the reference value instead of the
unreliable related_document_id, so the chain is correct regardless of
the stored FK.
2026-06-09 14:23:06 +02:00
9368a0dd8d Squashed commit of the following:
commit 579f0878e5240dc17db69be1e0b0c0f5af7ef9fe
Author: Arno Kaimbacher <arno.kaimbacher@geosphere.at>
Date:   Tue Jun 9 09:25:44 2026 +0200

    feat: Refactor error handling in Dataset Edit form and improve validation messages

    - Updated error handling in the Dataset Edit form to use a centralized formatError function for displaying validation messages.
    - Enhanced user feedback by ensuring that error messages are displayed consistently across various fields.
    - Modified the validation rule for arrayContainsTypes to provide clearer error messages for missing main and translated titles/abstracts.
    - Introduced a new ValidationService to manage manual construction of validation errors.
    - Updated Vite configuration to streamline asset loading and improve performance.
    - Adjusted Inertia setup to utilize dynamic imports for page-specific assets.
    - Cleaned up unnecessary comments and code in various files for better readability.

commit 5efddc2a58c0e164fef585cc7344c06155dbc2c1
Author: Arno Kaimbacher <arno.kaimbacher@geosphere.at>
Date:   Mon Jan 12 17:02:47 2026 +0100

    feat: add dataset change detection and form submission composables

    - Implemented `useDatasetChangeDetection` for tracking unsaved changes in dataset forms, including comparisons for licenses, basic properties, files, coverage, and more.
    - Added `useDatasetFormSubmission` for handling dataset form submissions with validation, success/error handling, and auto-save functionality.
2026-06-09 09:35:15 +02:00
0680879e2f Merge bugfix branch 'bugfix/doi-registration' into develop 2026-01-12 11:25:24 +01:00
118836d489 fix: Update OpenSearch client initialization to use template literals for consistency 2025-12-17 12:06:37 +01:00
b16a1ba3f0 bugfix: doi-registration: Refactor XSLT and Vue components for improved data validation and conditional rendering
- Updated doi_datacite.xslt to include conditional checks for empty values in creators, titles, subjects, contributors, dates, rights, sizes, formats, descriptions, and geoLocations to ensure only non-empty elements are rendered.
- Enhanced the handling of TitleAbstract and TitleAbstractAdditional templates to only output descriptions if the Value attribute is not empty.
- Modified the Doi.vue component to clean up the import statements and commented out unused warning display code for clarity.
2025-12-04 17:01:13 +01:00
6b04ad9910 Release: merge develop for v2.1.0 2025-11-13 11:42:59 +01:00
6457d233e7 Merge branch 'fix/table-persons' into develop
fix: Enhance dataset editing by ensuring user authentication and ownership checks across multiple controllers
2025-11-13 11:30:58 +01:00
d44d08abcd fix: Enhance dataset controllers with user authentication checks and improve mail configuration 2025-11-13 11:02:00 +01:00
38c05f6714 fix: Update TablePersons components for improved event handling and layout consistency 2025-11-05 15:38:28 +01:00
e8a34379f3 Merge branch 'fix/datset-listing-ux' into develop 2025-11-05 13:22:07 +01:00
4229001572 Enhance Map Zoom Control and Improve Map Page Layout
- Refactored zoom control component for better accessibility and styling.
- Added hover effects and improved button states for zoom in/out buttons.
- Updated map page layout with enhanced dataset card design and responsive styles.
- Introduced empty state for no datasets found and improved results header.
- Added icons for dataset cards and improved author display.
2025-11-05 13:15:23 +01:00
88e37bfee8 feat: Enhance Dataset Index with Dynamic Legend and Improved State Management for submitter, editor and reviewer
- Added a collapsible legend to display dataset states and available actions.
- Implemented localStorage persistence for legend visibility.
- Refactored dataset state handling with dynamic classes and labels.
- Improved table layout and styling for better user experience.
- Updated Tailwind CSS configuration to define new background colors for dataset states.
2025-10-30 14:42:36 +01:00
a4e6f88e07 fix: Update TablePersons component with improved layout and name type handling also for organizations 2025-10-29 14:36:05 +01:00
39f1bcee46 Merge branch 'feat/create-projects' into develop 2025-10-29 11:25:12 +01:00
3d8f2354cb feat: Enhance Dataset Edit Page with Unsaved Changes Indicator and Improved Structure
- Added a progress indicator for unsaved changes at the top of the dataset edit page.
- Enhanced the title section with a dataset status badge and improved layout.
- Introduced collapsible sections for better organization of form fields.
- Improved notifications for success/error messages.
- Refactored form fields into distinct sections: Basic Information, Licenses, Titles, Descriptions, Creators & Contributors, Additional Metadata, Geographic Coverage, and Files.
- Enhanced loading spinner with a more visually appealing overlay.
- Added new project validation logic in the backend with create and update validators.
2025-10-29 11:20:27 +01:00
f39fe75340 feat: Implement project management functionality with CRUD operations and UI integration
feat: Implement project management functionality with CRUD operations and UI integration
- added projects_controller.ts for crud operations-
added views Edit-vue , Index.vue and Create.vue
- small adaptions in menu.ts
additional routes is start/routes.ts for projects
2025-10-16 15:37:55 +02:00
04269ce9cf - fix: Update TablePersons component for improved UI and functionality;
- refactor file scan options
2025-10-16 12:04:46 +02:00
5e424803ed Merge branch 'feat/admin-diagnose-overview' into develop 2025-10-14 12:30:15 +02:00
b5bbe26ec2 feat: Enhance background job settings UI and functionality
- Updated BackgroundJob.vue to improve the display of background job statuses, including missing cross-references and current job mode.
- Added auto-refresh functionality for background job status.
- Introduced success toast notifications for successful status refreshes.
- Modified the XML serialization process in DatasetXmlSerializer for better caching and performance.
- Implemented a new RuleProvider for managing custom validation rules.
- Improved error handling in routes for loading background job settings.
- Enhanced ClamScan configuration with socket support for virus scanning.
- Refactored dayjs utility to streamline locale management.
2025-10-14 12:19:09 +02:00
6757bdb77c feat: Enhance ClamAV Docker entrypoint and configuration
- Updated docker-entrypoint.sh to improve ClamAV service initialization and logging.
- Added checks for ClamAV and freshclam daemon status.
- Optimized freshclam configuration for container usage, including logging to stdout and setting database directory.
- Introduced caching mechanism for enabled file extensions in vinejs_provider.ts to reduce database queries.
- Implemented a new command to list datasets needing DataCite DOI updates, with options for verbose output, count only, and IDs only.
- Updated package dependencies to include p-limit and pino-pretty.
- finalized ace command 'detect:missing-cross-references'
2025-09-26 12:19:35 +02:00
4c8cce27da feat: Update form field labels from "Main Title Language*" to "Main Description Language*" for clarity 2025-09-19 17:21:21 +02:00
2f079e6fdd feat: Add optional ORCID identifier to dataset validation also for the subitter
npm updates
2025-09-19 16:26:01 +02:00
c049b22723 - feat: Enhance README with setup instructions, usage, and command documentation
- fix: Update API routes to include DOI URL handling and improve route organization

- chore: Add ORCID preload rule file and ensure proper registration

- docs: Add MIT License to the project for open-source compliance

- feat: Implement command to detect and fix missing dataset cross-references

- feat: Create command for updating DataCite DOI records with detailed logging and error handling

- docs: Add comprehensive documentation for dataset indexing command

- docs: Create detailed documentation for DataCite update command with usage examples and error handling
2025-09-19 14:35:23 +02:00
8f67839f93 hot-fix: Add ORCID validation and improve dataset editing UX
### Major Features
- Add comprehensive ORCID validation with checksum verification
- Implement unsaved changes detection and auto-save functionality
- Enhanced form component reactivity and state management

### ORCID Implementation
- Create custom VineJS ORCID validation rule with MOD-11-2 algorithm
- Add ORCID fields to Person model and TablePersons component
- Update dataset validators to include ORCID validation
- Add descriptive placeholder text for ORCID input fields

### UI/UX Improvements
- Add UnsavedChangesWarning component with detailed change tracking
- Improve FormCheckRadio and FormCheckRadioGroup reactivity
- Enhanced BaseButton with proper disabled state handling
- Better error handling and user feedback in file validation

### Data Management
- Implement sophisticated change detection for all dataset fields
- Add proper handling of array ordering for authors/contributors
- Improve license selection with better state management
- Enhanced subject/keyword processing with duplicate detection

### Technical Improvements
- Optimize search indexing with conditional updates based on modification dates
- Update person model column mapping for ORCID
- Improve validation error messages and user guidance
- Better handling of file uploads and deletion tracking

### Dependencies
- Update various npm packages (AWS SDK, Babel, Vite, etc.)
- Add baseline-browser-mapping for better browser compatibility

### Bug Fixes
- Fix form reactivity issues with checkbox/radio groups
- Improve error handling in file validation rules
- Better handling of edge cases in change detection
2025-09-15 14:07:59 +02:00
06ed2f3625 feat: Enhance Person data structure and improve TablePersons component
- Updated Person interface to include first_name and last_name fields for better clarity and organization handling.
- Modified TablePersons.vue to support new fields, including improved pagination and drag-and-drop functionality.
- Added loading states and error handling for form controls within the table.
- Enhanced the visual layout of the table with responsive design adjustments.
- Updated solr.xslt to correctly reference ServerDateModified and EmbargoDate attributes.
- updated AvatarController
- improved download method for editor, and reviewer
- improved security for officlial download file file API: filterd by server_state
2025-09-08 12:28:26 +02:00
e1ccf0ddc8 hotfix(dataset): enhance file download with embargo validation and improve API data handling
- Add embargo date validation to file download process with date-only comparison
- Require first_name for authors/contributors only when name_type is 'Personal'
- Remove sensitive personal data from dataset API responses
- Improve dataset validation logic for better data integrity
2025-09-03 12:48:44 +02:00
fbc34a7456 Merge branch 'develop'
nerge latest hotfix updates from develop branch into master branch
2025-07-03 10:44:39 +02:00
89d91d5e12 hotfix: improve mimetype creation and dashboard data loading
- Added a NotificationBar component to display flash messages on the Mimetype creation page.
- Modified FormCheckRadioGroup to handle both numeric and string keys for input values.
- Removed unused code and API calls from Dashboard.vue and moved API calls to the component level where they are used.
- Added authentication middleware to the 'clients' and 'authors' API routes in `start/routes/api.ts`.
- Updated the file download route in `start/routes/api.ts` to include "file" in the path.
- Corrected the validation message key for file extension minLength in MimetypeController.ts.
- Updated favicon path in `resources/views/app.edge`.
- Added argon2 dependency in `package.json`.
2025-07-03 10:17:19 +02:00
0bf442be96 hotfix(dataset): improve dataset classification UI and data handling
- Updated the Dataset and Submitter Category Vue components to enhance the UI for library classification. The collection names and numbers are now displayed with distinct styling using `span` elements with specific classes for better readability.
- Modified the DatasetController and Editor/DatasetController to filter collection roles by 'ddc' and 'ccs' names when preloading collections, improving data retrieval efficiency.
- Added `left_id` and `right_id` columns to the `collections` table in the `dataset_7_collections.ts` migration file.
- Added a migration to reorder the collection_roles table.
2025-05-06 17:43:37 +02:00
be6b38d0a3 hotfix (dataset): implement reject to reviewer functionality for editors
- Added "rejected_to_reviewer" state to the `ServerStates` enum.
- Implemented routes and controller actions (`rejectToReviewer`, `rejectToReviewerUpdate`) for editors to reject datasets back to reviewers with a rejection note.
- Added UI elements (button) in the editor dataset index and publish views to trigger the "reject to reviewer" action.
- Updated the reviewer dataset index view to display datasets in the "rejected_to_reviewer" state and show the editor's rejection note.
- Modified the reviewer dataset review page to allow reviewers to view and accept datasets that have been rejected back to them by editors.
- Updated the database migration to include the "rejected_to_reviewer" state in the `documents_server_state_check` constraint.
- Updated dependencies (pinia, redis).
2025-05-02 14:35:58 +02:00
c245c8e97d hotfix (dataset): improve dataset classification and review workflow
- Renamed "Collections" to "Classify" in dataset category views for submitters and editors to better reflect the page's purpose.
- Modified the `SectionTitleLineWithButton` component to conditionally render the cog button based on the `showCogButton` prop.
- Updated the Dataset Edit and Create views to use `textarea` instead of `text` for title and description input fields, allowing for multi-line text.
- Added authorization checks for dataset review and reject actions in the `Reviewer/DatasetController`, and passed the `can` object to the `Review` view.
- Added a "Reject" button to the dataset review page, visible only to users with the `dataset-review-reject` permission and when the dataset is in the 'approved' state.
- Improved the display of dataset information in index views by adding dark mode styling to table headers.
- Removed unused code and comments from the Dashboard.vue file.
- Removed the `show-header-icon` property from the CardBox component in the Create.vue file.
- Updated dependencies
2025-04-24 18:25:07 +02:00
c3ae4327b7 hotfix (dataset): enhance dataset editing and validation
- Modified the TableKeywords component to remove the external_key reset when the type is updated, only resetting the value.
- Updated the DatasetController to pass authorization checks (`can.edit`, `can.delete`) to the edit view.
- Updated the arrayContainsTypes validation rule to improve the error messages for titles and descriptions, clarifying the requirements for main and translated entries.
- Updated the Dataset Edit view to:
  - Remove unused code and comments.
  - Add authorization checks to the save button.
  - Add a release button.
  - Add icons to the save and release buttons.
  - Add a computed property `hasUnsavedChanges` to determine if there are unsaved changes in the form.
2025-04-18 11:39:19 +02:00
2cb33a779c hotfix(dataset): improve dataset management and UI enhancements
- Added tooltips to display reject notes from editors and reviewers on dataset index pages for submitters and editors.
- Implemented custom ordering for datasets in submitter and editor index views, prioritizing datasets rejected by editors or reviewers.
- Changed "Review" button label to "View" on the reviewer dataset index page.
- Changed "Review" button label to "Accept" on the reviewer dataset review page.
- Added project_id to the dataset model.
- Updated dependencies (vite, @pkgr/core, caniuse-lite, electron-to-chromium, http-proxy-middleware).
- Replaced the static doctypes array with the DatasetTypes enum.
- Updated favicon.
2025-04-16 17:07:45 +02:00
dbd2bf2e9d hotfix(dataset): correct embargo date validation message
- Corrected the embargo date validation message in both the DatasetController and dataset validator to ensure consistency.
- Updated the `embargo_date.date.afterOrEqual` message to dynamically display the correct date (10 days from now) in the desired format.
2025-04-09 15:59:22 +02:00
106f8d5f27 hotfix(dataset): enhance dataset creation and editing forms
- Added functionality to add new authors and contributors directly within the dataset creation and editing forms.
- Implemented `addNewAuthor` and `addNewContributor` methods to dynamically add new person objects to the authors and contributors arrays in the form data.
- Added header icons with click events to the `CardBox` component for authors and contributors sections to trigger the addition of new entries.
- Updated the dataset index views for reviewers and editors to improve the display of dataset titles, including adding a CSS class to truncate long titles.
- Ensured authors and contributors are ordered by `pivot_sort_order` when preloading in the Dataset and Editor controllers.
- Fixed an issue where pressing enter in the `SearchAutocomplete` component would submit the form.
- Updated validation messages to be available in the `updateEditorDatasetValidator`.
2025-04-09 13:00:37 +02:00
f04c1f6327 hotfix: enhance editor dataset management and UI improvements
- Implemented dataset editing functionality for editor roles, including fetching, updating, and categorizing datasets.
- Added routes and controller actions for editing, updating, and categorizing datasets within the editor interface.
- Integrated UI components for managing dataset metadata, subjects, references, and files.
- Enhanced keyword management with features for adding, editing, and deleting keywords, including handling keywords used by multiple datasets.
- Improved reference management with features for adding, editing, and deleting dataset references.
- Added validation for dataset updates using the `updateEditorDatasetValidator`.
- Updated the dataset edit form to include components for managing titles, descriptions, authors, contributors, licenses, coverage, subjects, references, and files.
- Implemented transaction management for dataset updates to ensure data consistency.
- Added a download route for files associated with datasets.
- Improved the UI for displaying and interacting with datasets in the editor index view, including adding edit and categorize buttons.
- Fixed an issue where the file size was not correctly calculated.
- Added a tooltip to the keyword value column in the TableKeywords component to explain the editability of keywords.
- Added a section to display keywords that are marked for deletion.
- Added a section to display references that are marked for deletion.
- Added a restore button to the references to delete section to restore references.
- Updated the SearchCategoryAutocomplete component to support read-only mode.
- Updated the FormControl component to support read-only mode.
- Added icons and styling improvements to various components.
- Added a default value for subjectsToDelete and referencesToDelete in the dataset model.
- Updated the FooterBar component to use the JustboilLogo component.
- Updated the app.ts file to fetch chart data without a year parameter.
- Updated the Login.vue file to invert the logo in dark mode.
- Updated the AccountInfo.vue file to add a Head component.
2025-04-08 14:16:35 +02:00
10d159a57a hotfix(dataset): enhance file upload and update functionality
- Added file upload functionality to the dataset update form.
- Implemented file size validation and aggregated upload limit.
- Added temporary file storage and cleanup to handle large file uploads.
- Added a clear button to the file upload component.
- Added the ability to sort files in the file upload component.
- Fixed an issue where the file upload component was not correctly updating the model value.
- Updated the dataset edit form to use the new file upload component.
- Added the ability to sort files in the file upload component.
- Added a global declaration for the `sort_order` property on the `File` interface.
- Added helper functions for byte size parsing, configuration retrieval, and temporary file path generation.
2025-04-01 13:39:02 +02:00
8fbda9fc64 hotfixfix: enhance FormControl styling for read-only state
- Improved the styling of the `FormControl` component when in a read-only state.
- Added specific styles for read-only fields, including a grayed-out background, a disabled cursor, and removal of the focus ring.
- Updated the border color to match the read-only state.
- Ensured the text color is grayed out in read-only mode.
2025-03-31 17:42:59 +02:00
7bb4bd06cf hotfix: disable username and email fields in profile form
- Disabled the username and email fields in the profile update form.
- Set the `is-read-only` property to `true` for the username and email `FormControl` components.
2025-03-31 15:49:25 +02:00
f89b119b18 hotfix (dashboard): display allow email contact in card box client
- Added the `allowEmailContact` property to the `CardBoxClient` component to display the email contact status.
- Added the `allowEmailContact` computed property to the `Person` model to determine if email contact is allowed based on the related datasets.
- Preloaded the datasets relation in the `AuthorsController` to access the pivot attributes.
- Updated the `Dashboard.vue` to pass the `allowEmailContact` prop to the `CardBoxClient` component.
- Updated the `array_contains_types` validation rule to correct the error message for descriptions.
- Updated the `FormCheckRadio.vue` to correctly handle the radio button and checkbox components.
2025-03-31 15:14:34 +02:00
09f65359f9 hotfix(dataset): enhance radio button and checkbox components and add arrayContainsTypes validation
- Added checkbox support to the `FormCheckRadio` component.
- Updated the styling of the radio button and checkbox components.
- Added the `arrayContainsTypes` validation rule to ensure that arrays contain specific types.
- Updated the `dataset` validators and controllers to use the new validation rule.
- Updated the `FormCheckRadioGroup` component to correctly handle the `input-value` as a number.
- Removed the default value from the `id` column in the `collections` migration.
- Added the `array_contains_types` rule to the `adonisrc.ts` file.
2025-03-28 17:34:46 +01:00
9823364670 hotfix: enhance radio button and file upload components
- Improved the styling and functionality of the radio button component, including a new radio button style.
- Added a loading spinner to the file upload component to indicate when large files are being processed.
- Added the ability to sort files in the file upload component.
- Fixed an issue where the radio button component was not correctly updating the model value.
- Updated the dataset creation and edit forms to use the new radio button component.
- Added a global declaration for the `sort_order` property on the `File` interface.
- Updated the API to filter authors by first and last name.
- Removed the import of `_checkbox-radio-switch.css` as the radio button styling is now handled within the component.
2025-03-27 16:04:23 +01:00
b93e46207f hotfix-feat(dataset): implement file upload with validation and error handling
- Implemented file upload functionality for datasets using multipart requests.
- Added file size and type validation using VineJS.
- Added file name length validation.
- Added file scan to remove infected files.
- Implemented aggregated upload limit to prevent exceeding the server's capacity.
- Added error handling for file upload failures, including temporary file cleanup.
- Updated the `DatasetController` to handle file uploads, validation, and database transactions.
- Updated the `bodyparser.ts` config to process the file upload manually.
- Updated the `api.ts` routes to fetch the statistic data.
- Updated the `main.ts` store to fetch the statistic data.
- Updated the `Dashboard.vue` to display the submitters only for administrator role.
- Updated the `CardBoxWidget.vue` to display the submitters.
- Updated the `ServerError.vue` to use the LayoutGuest.vue.
- Updated the `AuthController.ts` and `start/routes.ts` to handle the database connection errors.
- Updated the `app/exceptions/handler.ts` to handle the database connection errors.
- Updated the `package.json` to use the correct version of the `@adonisjs/bodyparser`.
2025-03-26 14:19:06 +01:00
a25f8bf6f7 hotfix: update dependencies and UI elements
- Updated various npm packages in `package-lock.json` including `@adonisjs/drive`, `@adonisjs/env`, `axios`, `electron-to-chromium`, `nanoid`, `pg`, and `quansync`.
- Removed the GitHub link from the navbar and dashboard.
- Added an OAI interface link to the navbar and menu.
- Removed the "Star on GitHub" button from the dashboard.
- Updated the chart data fetching logic in `HomeController.ts` to calculate the last 4 years dynamically.
- Removed unused imports and commented-out code.
2025-03-20 10:29:34 +01:00
70f016422c hotfix(admin/user): implement password reset and update user password
- Implemented password reset functionality for admin users.
- Updated the user edit and create forms to use a password meter component for password strength validation.
- Modified the `AdminuserController` to handle the new password field and update user passwords.
- Updated the `createUserValidator` and `updateUserValidator` to validate the new password field.
- Updated the password field to `new_password` in the `Edit.vue` and `Create.vue` components.
- Added `showRequiredMessage` prop to `SimplePasswordMeter` component.
- Added conditional rendering for password strength bar in `SimplePasswordMeter` component.
- Added `fieldLabel` prop to `SimplePasswordMeter` component.
- Updated form submission to handle errors and reset password field.
2025-03-19 15:52:37 +01:00
9f5d35f7ba hotfix: enhance dataset creation and modal styling
- Added `@adonisjs/bodyparser` as a dependency.
- Improved the layout and styling of the consent modal in `Create.vue`.
- Enhanced the placeholder text for reference values in `Create.vue`.
- Added a default empty string for the subject in `Create.vue`.
- Updated the styling of the `CardBoxModal` component.
2025-03-19 13:28:02 +01:00
a934626721 hotfix: ensure selected collection is draggable
- Update draggable attribute and class logic so that the selected collection remains draggable
- Preserve proper styling while allowing user interaction with the selected collection
2025-03-18 12:51:19 +01:00
166 changed files with 23223 additions and 7946 deletions

View file

@ -1,7 +1,7 @@
PORT=3333
HOST=0.0.0.0
NODE_ENV=development
APP_KEY=pvmU1vuAZDkSwarb7yh9pgZ-RxaX4zS7
APP_KEY=pvmU1vuAZDkSwarb7yh9pgZ-xxxxxx007
DRIVE_DISK=local
SESSION_DRIVER=cookie
CACHE_VIEWS=false

View file

@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v3
- run: echo "The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "The workflow is now ready to test your code on the runner."
- name: List files in the repository:
- name: List files in the repository
run: |
ls ${{ github.workspace }}
- run: echo "This job's status is ${{ job.status }}."

View file

@ -1,57 +1,63 @@
################## First Stage - Creating base #########################
# Created a variable to hold our node base image
ARG NODE_IMAGE=node:22-bookworm-slim
ARG NODE_IMAGE=node:22-trixie-slim
FROM $NODE_IMAGE AS base
# Install dumb-init and ClamAV, and perform ClamAV database update
RUN apt update \
&& apt-get install -y dumb-init clamav clamav-daemon nano \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
dumb-init \
clamav \
clamav-daemon \
clamdscan \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
# Creating folders and changing ownerships
&& mkdir -p /home/node/app && chown node:node /home/node/app \
&& mkdir -p /var/lib/clamav \
&& mkdir -p /home/node/app \
&& mkdir -p /var/lib/clamav \
&& mkdir /usr/local/share/clamav \
&& chown -R node:clamav /var/lib/clamav /usr/local/share/clamav /etc/clamav \
# permissions
&& mkdir /var/run/clamav \
&& chown node:clamav /var/run/clamav \
&& chmod 750 /var/run/clamav
# -----------------------------------------------
# --- ClamAV & FeshClam -------------------------
# -----------------------------------------------
# RUN \
# chmod 644 /etc/clamav/freshclam.conf && \
# freshclam && \
# mkdir /var/run/clamav && \
# chown -R clamav:root /var/run/clamav
&& mkdir -p /var/log/clamav \
&& mkdir -p /tmp/clamav-logs \
# Set ownership and permissions
&& chown node:node /home/node/app \
# && chown -R node:clamav /var/lib/clamav /usr/local/share/clamav /etc/clamav /var/run/clamav \
&& chown -R node:clamav /var/lib/clamav /usr/local/share/clamav /etc/clamav /var/run/clamav /var/log/clamav \
&& chown -R node:clamav /etc/clamav \
&& chmod 755 /tmp/clamav-logs \
&& chmod 750 /var/run/clamav \
&& chmod 755 /var/lib/clamav \
&& chmod 755 /var/log/clamav \
# Add node user to clamav group and allow sudo for clamav commands
&& usermod -a -G clamav node
# && chmod 666 /var/run/clamav/clamd.socket
# Make directories group-writable so node (as member of clamav group) can access them
# && chmod 750 /var/run/clamav /var/lib/clamav /var/log/clamav /tmp/clamav-logs
# # initial update of av databases
# RUN freshclam
# Configure Clam AV...
# Configure ClamAV - copy config files before switching user
# COPY --chown=node:clamav ./*.conf /etc/clamav/
COPY --chown=node:clamav ./*.conf /etc/clamav/
# # permissions
# RUN mkdir /var/run/clamav && \
# chown node:clamav /var/run/clamav && \
# chmod 750 /var/run/clamav
# Setting the working directory
WORKDIR /home/node/app
# Changing the current active user to "node"
# Download initial ClamAV database as root before switching users
USER node
RUN freshclam --quiet || echo "Initial database download failed - will retry at runtime"
# initial update of av databases
RUN freshclam
# VOLUME /var/lib/clamav
# Copy entrypoint script
COPY --chown=node:clamav docker-entrypoint.sh /home/node/app/docker-entrypoint.sh
RUN chmod +x /home/node/app/docker-entrypoint.sh
ENV TZ="Europe/Vienna"
################## Second Stage - Installing dependencies ##########
# In this stage, we will start installing dependencies
FROM base AS dependencies
@ -70,7 +76,6 @@ ENV NODE_ENV=production
# We run "node ace build" to build the app (dist folder) for production
RUN node ace build --ignore-ts-errors
# RUN node ace build --production
# RUN node ace build --ignore-ts-errors
################## Final Stage - Production #########################
@ -88,6 +93,7 @@ RUN npm ci --omit=dev
# Copy files to the working directory from the build folder the user
COPY --chown=node:node --from=build /home/node/app/build .
# Expose port
# EXPOSE 3310
EXPOSE 3333
ENTRYPOINT ["/home/node/app/docker-entrypoint.sh"]
# Run the command to start the server using "dumb-init"

22
LICENSE Normal file
View file

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 Tethys Research Repository
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE

View file

@ -11,9 +11,10 @@ export default defineConfig({
*/
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/mail/commands')],
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/mail/commands')
],
/*
|--------------------------------------------------------------------------
| Preloads
@ -26,15 +27,17 @@ export default defineConfig({
() => import('./start/routes.js'),
() => import('./start/kernel.js'),
() => import('#start/validator'),
() => import('#start/rules/unique'),
() => import('#start/rules/translated_language'),
() => import('#start/rules/unique_person'),
() => import('#start/rules/file_length'),
() => import('#start/rules/file_scan'),
() => import('#start/rules/allowed_extensions_mimetypes'),
() => import('#start/rules/dependent_array_min_length'),
() => import('#start/rules/referenceValidation'),
() => import('#start/rules/valid_mimetype'),
// () => import('#start/rules/unique'),
// () => import('#start/rules/translated_language'),
// () => import('#start/rules/unique_person'),
// // () => import('#start/rules/file_length'),
// // () => import('#start/rules/file_scan'),
// // () => import('#start/rules/allowed_extensions_mimetypes'),
// () => import('#start/rules/dependent_array_min_length'),
// () => import('#start/rules/referenceValidation'),
// () => import('#start/rules/valid_mimetype'),
// () => import('#start/rules/array_contains_types'),
// () => import('#start/rules/orcid'),
],
/*
|--------------------------------------------------------------------------
@ -59,7 +62,8 @@ export default defineConfig({
// () => import('@eidellev/inertia-adonisjs'),
// () => import('@adonisjs/inertia/inertia_provider'),
() => import('#providers/app_provider'),
() => import('#providers/inertia_provider'),
// () => import('#providers/inertia_provider'),
() => import('@adonisjs/inertia/inertia_provider'),
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/auth/auth_provider'),
// () => import('@eidellev/adonis-stardust'),
@ -69,7 +73,7 @@ export default defineConfig({
() => import('#providers/stardust_provider'),
() => import('#providers/query_builder_provider'),
() => import('#providers/token_worker_provider'),
// () => import('#providers/validator_provider'),
() => import('#providers/rule_provider'),
// () => import('#providers/drive/provider/drive_provider'),
() => import('@adonisjs/drive/drive_provider'),
// () => import('@adonisjs/core/providers/vinejs_provider'),

View file

@ -85,7 +85,9 @@ export default class AdminuserController {
// return response.badRequest(error.messages);
throw error;
}
const input = request.only(['login', 'email', 'password', 'first_name', 'last_name']);
const input: Record<string, any> = request.only(['login', 'email', 'first_name', 'last_name']);
input.password = request.input('new_password');
const user = await User.create(input);
if (request.input('roles')) {
const roles: Array<number> = request.input('roles');
@ -95,7 +97,6 @@ export default class AdminuserController {
session.flash('message', 'User has been created successfully');
return response.redirect().toRoute('settings.user.index');
}
public async show({ request, inertia }: HttpContext) {
const id = request.param('id');
const user = await User.query().where('id', id).firstOrFail();
@ -139,9 +140,11 @@ export default class AdminuserController {
});
// password is optional
let input;
if (request.input('password')) {
input = request.only(['login', 'email', 'password', 'first_name', 'last_name']);
let input: Record<string, any>;
if (request.input('new_password')) {
input = request.only(['login', 'email', 'first_name', 'last_name']);
input.password = request.input('new_password');
} else {
input = request.only(['login', 'email', 'first_name', 'last_name']);
}
@ -156,7 +159,6 @@ export default class AdminuserController {
session.flash('message', 'User has been updated successfully');
return response.redirect().toRoute('settings.user.index');
}
public async destroy({ request, response, session }: HttpContext) {
const id = request.param('id');
const user = await User.findOrFail(id);

View file

@ -33,7 +33,7 @@ export default class MimetypeController {
// Step 2 - Validate request body against the schema
// await request.validate({ schema: newDatasetSchema, messages: this.messages });
const validator = vine.compile(newDatasetSchema);
validator.messagesProvider = new SimpleMessagesProvider(this.messages);
validator.messagesProvider = new SimpleMessagesProvider(this.messages);
await request.validateUsing(validator, { messagesProvider: new SimpleMessagesProvider(this.messages) });
} catch (error) {
// Step 3 - Handle errors
@ -64,7 +64,7 @@ export default class MimetypeController {
'maxLength': '{{ field }} must be less then {{ max }} characters long',
'isUnique': '{{ field }} must be unique, and this value is already taken',
'required': '{{ field }} is required',
'file_extension.minLength': 'at least {{ min }} mimetypes must be defined',
'file_extension.array.minLength': 'at least {{ min }} mimetypes must be defined',
'file_extension.*.string': 'Each file extension must be a valid string', // Adjusted to match the type
};
@ -168,7 +168,7 @@ export default class MimetypeController {
mimetype,
});
} catch (error) {
if (error.code == 'E_ROW_NOT_FOUND') {
if (error.code === 'E_ROW_NOT_FOUND') {
session.flash({ warning: 'Mimetype is not found in database' });
} else {
session.flash({ warning: 'general error occured, you cannot delete the mimetype' });

View file

@ -42,7 +42,7 @@ export default class RoleController {
can: {
create: await auth.user?.can(['user-create']),
edit: await auth.user?.can(['user-edit']),
delete: await auth.user?.can(['user-delete']),
delete: false, //await auth.user?.can(['user-delete']),
},
});
}
@ -124,7 +124,7 @@ export default class RoleController {
// password is optional
const input = request.only(['name', 'description']);
const input = request.only(['name', 'display_name', 'description']);
await role.merge(input).save();
// await user.save();

View file

@ -76,9 +76,9 @@ export default class MailSettingsController {
public async sendTestMail({ response, auth }: HttpContext) {
const user = auth.user!;
const userEmail = user.email;
// let mailManager = await app.container.make('mail.manager');
// let iwas = mailManager.use();
// let iwas = mailManager.use();
// let test = mail.config.mailers.smtp();
if (!userEmail) {
return response.badRequest({ message: 'User email is not set. Please update your profile.' });

View file

@ -4,19 +4,29 @@ import Person from '#models/person';
// node ace make:controller Author
export default class AuthorsController {
public async index({}: HttpContext) {
// select * from gba.persons
// where exists (select * from gba.documents inner join gba.link_documents_persons on "documents"."id" = "link_documents_persons"."document_id"
// where ("link_documents_persons"."role" = 'author') and ("persons"."id" = "link_documents_persons"."person_id"));
public async index({}: HttpContext) {
const authors = await Person.query()
.where('name_type', 'Personal')
.whereHas('datasets', (dQuery) => {
dQuery.wherePivot('role', 'author');
})
.withCount('datasets', (query) => {
query.as('datasets_count');
})
.orderBy('datasets_count', 'desc');
.select([
'id',
'academic_title',
'first_name',
'last_name',
'identifier_orcid',
'status',
'name_type',
'created_at'
// Note: 'email' is omitted
])
.preload('datasets')
.where('name_type', 'Personal')
.whereHas('datasets', (dQuery) => {
dQuery.wherePivot('role', 'author');
})
.withCount('datasets', (query) => {
query.as('datasets_count');
})
.orderBy('datasets_count', 'desc');
return authors;
}
@ -27,7 +37,10 @@ export default class AuthorsController {
if (request.input('filter')) {
// users = users.whereRaw('name like %?%', [request.input('search')])
const searchTerm = request.input('filter');
authors.whereILike('first_name', `%${searchTerm}%`).orWhereILike('last_name', `%${searchTerm}%`);
authors.andWhere((query) => {
query.whereILike('first_name', `%${searchTerm}%`)
.orWhereILike('last_name', `%${searchTerm}%`);
});
// .orWhere('email', 'like', `%${searchTerm}%`);
}

View file

@ -2,26 +2,46 @@ import type { HttpContext } from '@adonisjs/core/http';
import { StatusCodes } from 'http-status-codes';
import redis from '@adonisjs/redis/services/main';
const PREFIXES = ['von', 'van'];
const PREFIXES = ['von', 'van', 'de', 'del', 'della', 'di', 'da', 'dos', 'du', 'le', 'la'];
const DEFAULT_SIZE = 50;
const MIN_SIZE = 16;
const MAX_SIZE = 512;
const FONT_SIZE_RATIO = 0.4;
const COLOR_LIGHTENING_PERCENT = 60;
const COLOR_DARKENING_FACTOR = 0.6;
const CACHE_TTL = 24 * 60 * 60; // 24 hours instead of 1 hour
export default class AvatarController {
public async generateAvatar({ request, response }: HttpContext) {
try {
const { name, size = DEFAULT_SIZE } = request.only(['name', 'size']);
if (!name) {
return response.status(StatusCodes.BAD_REQUEST).json({ error: 'Name is required' });
// Enhanced validation
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return response.status(StatusCodes.BAD_REQUEST).json({
error: 'Name is required and must be a non-empty string',
});
}
const parsedSize = this.validateSize(size);
if (!parsedSize.isValid) {
return response.status(StatusCodes.BAD_REQUEST).json({
error: parsedSize.error,
});
}
// Build a unique cache key for the given name and size
const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`;
const cachedSvg = await redis.get(cacheKey);
if (cachedSvg) {
this.setResponseHeaders(response);
return response.send(cachedSvg);
const cacheKey = `avatar:${this.sanitizeName(name)}-${parsedSize.value}`;
// const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`;
try {
const cachedSvg = await redis.get(cacheKey);
if (cachedSvg) {
this.setResponseHeaders(response);
return response.send(cachedSvg);
}
} catch (redisError) {
// Log redis error but continue without cache
console.warn('Redis cache read failed:', redisError);
}
const initials = this.getInitials(name);
@ -29,41 +49,85 @@ export default class AvatarController {
const svgContent = this.createSvg(size, colors, initials);
// // Cache the generated avatar for future use, e.g. 1 hour expiry
await redis.setex(cacheKey, 3600, svgContent);
try {
await redis.setex(cacheKey, CACHE_TTL, svgContent);
} catch (redisError) {
// Log but don't fail the request
console.warn('Redis cache write failed:', redisError);
}
this.setResponseHeaders(response);
return response.send(svgContent);
} catch (error) {
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: error.message });
console.error('Avatar generation error:', error);
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
error: 'Failed to generate avatar',
});
}
}
private getInitials(name: string): string {
const parts = name
private validateSize(size: any): { isValid: boolean; value?: number; error?: string } {
const numSize = Number(size);
if (isNaN(numSize)) {
return { isValid: false, error: 'Size must be a valid number' };
}
if (numSize < MIN_SIZE || numSize > MAX_SIZE) {
return {
isValid: false,
error: `Size must be between ${MIN_SIZE} and ${MAX_SIZE}`,
};
}
return { isValid: true, value: Math.floor(numSize) };
}
private sanitizeName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s-]/gi, '');
}
private getInitials(name: string): string {
const sanitized = name.trim().replace(/\s+/g, ' '); // normalize whitespace
const parts = sanitized
.split(' ')
.filter((part) => part.length > 0);
.filter((part) => part.length > 0)
.map((part) => part.trim());
if (parts.length === 0) {
return 'NA';
}
if (parts.length >= 2) {
return this.getMultiWordInitials(parts);
if (parts.length === 1) {
// For single word, take first 2 characters or first char if only 1 char
return parts[0].substring(0, Math.min(2, parts[0].length)).toUpperCase();
}
return parts[0].substring(0, 2).toUpperCase();
return this.getMultiWordInitials(parts);
}
private getMultiWordInitials(parts: string[]): string {
const firstName = parts[0];
const lastName = parts[parts.length - 1];
const firstInitial = firstName.charAt(0).toUpperCase();
const lastInitial = lastName.charAt(0).toUpperCase();
// Filter out prefixes and short words
const significantParts = parts.filter((part) => !PREFIXES.includes(part.toLowerCase()) && part.length > 1);
if (PREFIXES.includes(lastName.toLowerCase()) && lastName === lastName.toUpperCase()) {
return firstInitial + lastName.charAt(1).toUpperCase();
if (significantParts.length === 0) {
// Fallback to first and last regardless of prefixes
const firstName = parts[0];
const lastName = parts[parts.length - 1];
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase();
}
return firstInitial + lastInitial;
if (significantParts.length === 1) {
return significantParts[0].substring(0, 2).toUpperCase();
}
// Take first and last significant parts
const firstName = significantParts[0];
const lastName = significantParts[significantParts.length - 1];
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase();
}
private generateColors(name: string): { background: string; text: string } {
@ -75,31 +139,44 @@ export default class AvatarController {
}
private createSvg(size: number, colors: { background: string; text: string }, initials: string): string {
const fontSize = size * FONT_SIZE_RATIO;
return `
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#${colors.background}"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-weight="bold" font-family="Arial, sans-serif" font-size="${fontSize}" fill="#${colors.text}">${initials}</text>
</svg>
`;
const fontSize = Math.max(12, Math.floor(size * FONT_SIZE_RATIO)); // Ensure readable font size
// Escape any potential HTML/XML characters in initials
const escapedInitials = this.escapeXml(initials);
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}">
<rect width="100%" height="100%" fill="#${colors.background}" rx="${size * 0.1}"/>
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle"
font-weight="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
font-size="${fontSize}" fill="#${colors.text}">${escapedInitials}</text>
</svg>`;
}
private escapeXml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
private setResponseHeaders(response: HttpContext['response']): void {
response.header('Content-type', 'image/svg+xml');
response.header('Cache-Control', 'no-cache');
response.header('Pragma', 'no-cache');
response.header('Expires', '0');
response.header('Content-Type', 'image/svg+xml');
response.header('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
response.header('ETag', `"${Date.now()}"`); // Simple ETag
}
private getColorFromName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
const normalizedName = name.toLowerCase().trim();
for (let i = 0; i < normalizedName.length; i++) {
hash = normalizedName.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash; // Convert to 32-bit integer
}
// Ensure we get vibrant colors by constraining the color space
const colorParts = [];
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
let value = (hash >> (i * 8)) & 0xff;
// Ensure minimum color intensity for better contrast
value = Math.max(50, value);
colorParts.push(value.toString(16).padStart(2, '0'));
}
return colorParts.join('');
@ -110,7 +187,7 @@ export default class AvatarController {
const g = parseInt(hexColor.substring(2, 4), 16);
const b = parseInt(hexColor.substring(4, 6), 16);
const lightenValue = (value: number) => Math.min(255, Math.floor((value * (100 + percent)) / 100));
const lightenValue = (value: number) => Math.min(255, Math.floor(value + (255 - value) * (percent / 100)));
const newR = lightenValue(r);
const newG = lightenValue(g);
@ -124,7 +201,7 @@ export default class AvatarController {
const g = parseInt(hexColor.slice(2, 4), 16);
const b = parseInt(hexColor.slice(4, 6), 16);
const darkenValue = (value: number) => Math.round(value * COLOR_DARKENING_FACTOR);
const darkenValue = (value: number) => Math.max(0, Math.floor(value * COLOR_DARKENING_FACTOR));
const darkerR = darkenValue(r);
const darkerG = darkenValue(g);

View file

@ -1,24 +1,36 @@
import type { HttpContext } from '@adonisjs/core/http';
// import Person from 'App/Models/Person';
import Dataset from '#models/dataset';
import { StatusCodes } from 'http-status-codes';
import DatasetReference from '#models/dataset_reference';
// node ace make:controller Author
export default class DatasetController {
public async index({}: HttpContext) {
// Select datasets with server_state 'published' or 'deleted' and sort by the last published date
const datasets = await Dataset.query()
.where(function (query) {
query.where('server_state', 'published')
.orWhere('server_state', 'deleted');
})
.preload('titles')
.preload('identifier')
.orderBy('server_date_published', 'desc');
/**
* GET /api/datasets
* Find all published datasets
*/
public async index({ response }: HttpContext) {
try {
const datasets = await Dataset.query()
.where(function (query) {
query.where('server_state', 'published').orWhere('server_state', 'deleted');
})
.preload('titles')
.preload('identifier')
.orderBy('server_date_published', 'desc');
return datasets;
return response.status(StatusCodes.OK).json(datasets);
} catch (error) {
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
message: error.message || 'Some error occurred while retrieving datasets.',
});
}
}
/**
* GET /api/dataset
* Find all published datasets
*/
public async findAll({ response }: HttpContext) {
try {
const datasets = await Dataset.query()
@ -34,34 +46,368 @@ export default class DatasetController {
}
}
public async findOne({ params }: HttpContext) {
const datasets = await Dataset.query()
.where('publish_id', params.publish_id)
.preload('titles')
.preload('descriptions')
.preload('user')
.preload('authors', (builder) => {
builder.orderBy('pivot_sort_order', 'asc');
})
.preload('contributors', (builder) => {
builder.orderBy('pivot_sort_order', 'asc');
})
.preload('subjects')
.preload('coverage')
.preload('licenses')
.preload('references')
.preload('project')
.preload('referenced_by', (builder) => {
builder.preload('dataset', (builder) => {
builder.preload('identifier');
});
})
.preload('files', (builder) => {
builder.preload('hashvalues');
})
.preload('identifier')
.firstOrFail();
/**
* GET /api/dataset/:publish_id
* Find one dataset by publish_id
*/
public async findOne({ response, params }: HttpContext) {
try {
const dataset = await Dataset.query()
.where('publish_id', params.publish_id)
.preload('titles')
.preload('descriptions') // Using 'descriptions' instead of 'abstracts'
.preload('user', (builder) => {
builder.select(['id', 'firstName', 'lastName', 'avatar', 'login']);
})
.preload('authors', (builder) => {
builder
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
.withCount('datasets', (query) => {
query.as('datasets_count');
})
.pivotColumns(['role', 'sort_order'])
.orderBy('pivot_sort_order', 'asc');
})
.preload('contributors', (builder) => {
builder
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
.withCount('datasets', (query) => {
query.as('datasets_count');
})
.pivotColumns(['role', 'sort_order', 'contributor_type'])
.orderBy('pivot_sort_order', 'asc');
})
.preload('subjects')
.preload('coverage')
.preload('licenses')
.preload('references')
.preload('project')
// .preload('referenced_by', (builder) => {
// builder.preload('dataset', (builder) => {
// builder.preload('identifier');
// });
// })
.preload('files', (builder) => {
builder.preload('hashvalues');
})
.preload('identifier')
.first(); // Use first() instead of firstOrFail() to handle not found gracefully
return datasets;
if (!dataset) {
return response.status(StatusCodes.NOT_FOUND).json({
message: `Cannot find Dataset with publish_id=${params.publish_id}.`,
});
}
// Build the version chain
const versionChain = await this.buildVersionChain(dataset);
// Add version chain to response
const responseData = {
...dataset.toJSON(),
versionChain: versionChain,
};
// return response.status(StatusCodes.OK).json(dataset);
return response.status(StatusCodes.OK).json(responseData);
} catch (error) {
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
message: error.message || `Error retrieving Dataset with publish_id=${params.publish_id}.`,
});
}
}
/**
* GET /:prefix/:value
* Find dataset by identifier (e.g., https://doi.tethys.at/10.24341/tethys.99.2)
*/
public async findByIdentifier({ response, params }: HttpContext) {
const identifierValue = `${params.prefix}/${params.value}`;
// Optional: Validate DOI format
if (!identifierValue.match(/^10\.\d+\/[a-zA-Z0-9._-]+\.[0-9]+(?:\.[0-9]+)*$/)) {
return response.status(StatusCodes.BAD_REQUEST).json({
message: `Invalid DOI format: ${identifierValue}`,
});
}
try {
// Method 1: Using subquery with whereIn (most similar to your original)
const dataset = await Dataset.query()
// .whereIn('id', (subQuery) => {
// subQuery.select('dataset_id').from('dataset_identifiers').where('value', identifierValue);
// })
.whereHas('identifier', (builder) => {
builder.where('value', identifierValue);
})
.preload('titles')
.preload('descriptions') // Using 'descriptions' instead of 'abstracts'
.preload('user', (builder) => {
builder.select(['id', 'firstName', 'lastName', 'avatar', 'login']);
})
.preload('authors', (builder) => {
builder
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
.withCount('datasets', (query) => {
query.as('datasets_count');
})
.pivotColumns(['role', 'sort_order'])
.wherePivot('role', 'author')
.orderBy('pivot_sort_order', 'asc');
})
.preload('contributors', (builder) => {
builder
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
.withCount('datasets', (query) => {
query.as('datasets_count');
})
.pivotColumns(['role', 'sort_order', 'contributor_type'])
.wherePivot('role', 'contributor')
.orderBy('pivot_sort_order', 'asc');
})
.preload('subjects')
.preload('coverage')
.preload('licenses')
.preload('references')
.preload('project')
// .preload('referenced_by', (builder) => {
// builder.preload('dataset', (builder) => {
// builder.preload('identifier');
// });
// })
.preload('files', (builder) => {
builder.preload('hashvalues');
})
.preload('identifier')
.first();
if (!dataset) {
return response.status(StatusCodes.NOT_FOUND).json({
message: `Cannot find Dataset with identifier=${identifierValue}.`,
});
}
// Build the version chain
const versionChain = await this.buildVersionChain(dataset);
// Add version chain to response
const responseData = {
...dataset.toJSON(),
versionChain: versionChain,
};
// return response.status(StatusCodes.OK).json(dataset);
return response.status(StatusCodes.OK).json(responseData);
} catch (error) {
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
message: error.message || `Error retrieving Dataset with identifier=${identifierValue}.`,
});
}
}
/**
* Build the complete version chain for a dataset
* Traverses both backwards (previous versions) and forwards (newer versions)
*/
private async buildVersionChain(dataset: Dataset) {
const versionChain = {
// current: {
// id: dataset.id,
// publish_id: dataset.publish_id,
// doi: dataset.identifier?.value || null,
// main_title: dataset.mainTitle || null,
// server_date_published: dataset.server_date_published,
// },
previousVersions: [] as any[],
newerVersions: [] as any[],
};
// Get all previous versions (going backwards in time)
versionChain.previousVersions = await this.getPreviousVersions(dataset.id);
// Get all newer versions (going forwards in time)
versionChain.newerVersions = await this.getNewerVersions(dataset.id);
return versionChain;
}
/**
* Recursively get all previous versions
*/
// private async getPreviousVersions(datasetId: number, visited: Set<number> = new Set()): Promise<any[]> {
// // Prevent infinite loops
// if (visited.has(datasetId)) {
// return [];
// }
// visited.add(datasetId);
// const previousVersions: any[] = [];
// // Find references where this dataset "IsNewVersionOf" another dataset
// const previousRefs = await DatasetReference.query()
// .where('document_id', datasetId)
// .where('relation', 'IsNewVersionOf')
// .whereNotNull('related_document_id');
// for (const ref of previousRefs) {
// if (!ref.related_document_id) continue;
// const previousDataset = await Dataset.query()
// .where('id', ref.related_document_id)
// .preload('identifier')
// .preload('titles')
// .first();
// if (previousDataset) {
// const versionInfo = {
// id: previousDataset.id,
// publish_id: previousDataset.publish_id,
// doi: previousDataset.identifier?.value || null,
// main_title: previousDataset.mainTitle || null,
// server_date_published: previousDataset.server_date_published,
// relation: 'IsPreviousVersionOf', // From perspective of current dataset
// };
// previousVersions.push(versionInfo);
// // Recursively get even older versions
// const olderVersions = await this.getPreviousVersions(previousDataset.id, visited);
// previousVersions.push(...olderVersions);
// }
// }
// return previousVersions;
// }
private async getPreviousVersions(datasetId: number, visited: Set<number> = new Set()): Promise<any[]> {
if (visited.has(datasetId)) return [];
visited.add(datasetId);
const result: any[] = [];
// A dataset points to its OLDER version via relation 'IsNewVersionOf'
const refs = await DatasetReference.query()
.where('document_id', datasetId)
.where('relation', 'IsNewVersionOf'); // ← removed .whereNotNull('related_document_id')
for (const ref of refs) {
const related = await this.resolveReferencedDataset(ref, datasetId);
if (!related) continue;
result.push({
id: related.id,
publish_id: related.publish_id,
doi: related.identifier?.value || null,
main_title: related.mainTitle || null,
server_date_published: related.server_date_published,
relation: 'IsPreviousVersionOf',
});
result.push(...(await this.getPreviousVersions(related.id, visited)));
}
return result;
}
/**
* Recursively get all newer versions
*/
// private async getNewerVersions(datasetId: number, visited: Set<number> = new Set()): Promise<any[]> {
// // Prevent infinite loops
// if (visited.has(datasetId)) {
// return [];
// }
// visited.add(datasetId);
// const newerVersions: any[] = [];
// // Find references where this dataset "IsPreviousVersionOf" another dataset
// const newerRefs = await DatasetReference.query()
// .where('document_id', datasetId)
// .where('relation', 'IsPreviousVersionOf')
// .whereNotNull('related_document_id');
// for (const ref of newerRefs) {
// if (!ref.related_document_id) continue;
// const newerDataset = await Dataset.query().where('id', ref.related_document_id).preload('identifier').preload('titles').first();
// if (newerDataset) {
// const versionInfo = {
// id: newerDataset.id,
// publish_id: newerDataset.publish_id,
// doi: newerDataset.identifier?.value || null,
// main_title: newerDataset.mainTitle || null,
// server_date_published: newerDataset.server_date_published,
// relation: 'IsNewVersionOf', // From perspective of current dataset
// };
// newerVersions.push(versionInfo);
// // Recursively get even newer versions
// const evenNewerVersions = await this.getNewerVersions(newerDataset.id, visited);
// newerVersions.push(...evenNewerVersions);
// }
// }
// return newerVersions;
// }
private async getNewerVersions(datasetId: number, visited: Set<number> = new Set()): Promise<any[]> {
if (visited.has(datasetId)) return [];
visited.add(datasetId);
const result: any[] = [];
// A dataset points to its NEWER version via relation 'IsPreviousVersionOf'
const refs = await DatasetReference.query()
.where('document_id', datasetId)
.where('relation', 'IsPreviousVersionOf'); // ← removed .whereNotNull(...)
for (const ref of refs) {
const related = await this.resolveReferencedDataset(ref, datasetId);
if (!related) continue;
result.push({
id: related.id,
publish_id: related.publish_id,
doi: related.identifier?.value || null,
main_title: related.mainTitle || null,
server_date_published: related.server_date_published,
relation: 'IsNewVersionOf',
});
result.push(...(await this.getNewerVersions(related.id, visited)));
}
return result;
}
private async resolveReferencedDataset(ref: DatasetReference, currentDatasetId: number) {
const doi = this.normalizeDoi(ref.value);
if (doi) {
const byDoi = await Dataset.query()
.whereHas('identifier', (q) => q.where('value', doi))
.preload('identifier')
.preload('titles') // needed so mainTitle computes
.first();
if (byDoi) return byDoi;
}
if (ref.related_document_id && ref.related_document_id !== currentDatasetId) {
return await Dataset.query()
.where('id', ref.related_document_id)
.preload('identifier')
.preload('titles')
.first();
}
return null;
}
private normalizeDoi(value: string | null): string | null {
if (!value) return null;
return value
.trim()
.replace(/^https?:\/\/(dx\.)?doi\.org\//i, '')
.replace(/^doi:/i, '');
}
}

View file

@ -2,53 +2,103 @@ import type { HttpContext } from '@adonisjs/core/http';
import File from '#models/file';
import { StatusCodes } from 'http-status-codes';
import * as fs from 'fs';
import * as path from 'path';
import { DateTime } from 'luxon';
// node ace make:controller Author
export default class FileController {
// @Get("download/:id")
public async findOne({ response, params }: HttpContext) {
const id = params.id;
const file = await File.findOrFail(id);
// const file = await File.findOne({
// where: { id: id },
// });
if (file) {
const filePath = '/storage/app/data/' + file.pathName;
const ext = path.extname(filePath);
const fileName = file.label + ext;
try {
fs.accessSync(filePath, fs.constants.R_OK); //| fs.constants.W_OK);
// console.log("can read/write:", path);
response
.header('Cache-Control', 'no-cache private')
.header('Content-Description', 'File Transfer')
.header('Content-Type', file.mimeType)
.header('Content-Disposition', 'inline; filename=' + fileName)
.header('Content-Transfer-Encoding', 'binary')
.header('Access-Control-Allow-Origin', '*')
.header('Access-Control-Allow-Methods', 'GET,POST');
response.status(StatusCodes.OK).download(filePath);
} catch (err) {
// console.log("no access:", path);
response.status(StatusCodes.NOT_FOUND).send({
message: `File with id ${id} doesn't exist on file server`,
});
}
// const file = await File.findOrFail(id);
// Load file with its related dataset to check embargo
const file = await File.query()
.where('id', id)
.preload('dataset') // or 'dataset' - whatever your relationship is named
.firstOrFail();
// res.status(StatusCodes.OK).sendFile(filePath, (err) => {
// // res.setHeader("Content-Type", "application/json");
// // res.removeHeader("Content-Disposition");
// res.status(StatusCodes.NOT_FOUND).send({
// message: `File with id ${id} doesn't exist on file server`,
// });
// });
} else {
response.status(StatusCodes.NOT_FOUND).send({
if (!file) {
return response.status(StatusCodes.NOT_FOUND).send({
message: `Cannot find File with id=${id}.`,
});
}
const dataset = file.dataset;
// Files from unpublished datasets are now blocked
if (dataset.server_state !== 'published') {
return response.status(StatusCodes.FORBIDDEN).send({
message: `File access denied: Dataset is not published.`,
});
}
if (dataset && this.isUnderEmbargo(dataset.embargo_date)) {
return response.status(StatusCodes.FORBIDDEN).send({
message: `File is under embargo until ${dataset.embargo_date?.toFormat('yyyy-MM-dd')}`,
});
}
// Proceed with file download
const filePath = '/storage/app/data/' + file.pathName;
const fileExt = file.filePath.split('.').pop() || '';
// const fileName = file.label + fileExt;
const fileName = file.label.toLowerCase().endsWith(`.${fileExt.toLowerCase()}`) ? file.label : `${file.label}.${fileExt}`;
// Determine if file can be previewed inline in browser
const canPreviewInline = (mimeType: string): boolean => {
const type = mimeType.toLowerCase();
return (
type === 'application/pdf' ||
type.startsWith('image/') ||
type.startsWith('text/') ||
type === 'application/json' ||
type === 'application/xml' ||
// Uncomment if you want video/audio inline
type.startsWith('video/') ||
type.startsWith('audio/')
);
};
const disposition = canPreviewInline(file.mimeType) ? 'inline' : 'attachment';
try {
fs.accessSync(filePath, fs.constants.R_OK); //| fs.constants.W_OK);
// console.log("can read/write:", filePath);
response
.header('Cache-Control', 'no-cache private')
.header('Content-Description', 'File Transfer')
.header('Content-Type', file.mimeType)
.header('Content-Disposition', `${disposition}; filename="${fileName}"`)
.header('Content-Transfer-Encoding', 'binary')
.header('Access-Control-Allow-Origin', '*')
.header('Access-Control-Allow-Methods', 'GET');
response.status(StatusCodes.OK).download(filePath);
} catch (err) {
// console.log("no access:", path);
response.status(StatusCodes.NOT_FOUND).send({
message: `File with id ${id} doesn't exist on file server`,
});
}
}
/**
* Check if the dataset is under embargo
* Compares only dates (ignoring time) for embargo check
* @param embargoDate - The embargo date from dataset
* @returns true if under embargo, false if embargo has passed or no embargo set
*/
private isUnderEmbargo(embargoDate: DateTime | null): boolean {
// No embargo date set - allow download
if (!embargoDate) {
return false;
}
// Get current date at start of day (00:00:00)
const today = DateTime.now().startOf('day');
// Get embargo date at start of day (00:00:00)
const embargoDateOnly = embargoDate.startOf('day');
// File is under embargo if embargo date is after today
// This means the embargo lifts at the start of the embargo date
return embargoDateOnly >= today;
}
}

View file

@ -17,7 +17,8 @@ export default class HomeController {
// .preload('authors')
// .orderBy('server_date_published');
const datasets = await db.from('documents as doc')
const datasets = await db
.from('documents as doc')
.select(['publish_id', 'server_date_published', db.raw(`date_part('year', server_date_published) as pub_year`)])
.where('server_state', serverState)
.innerJoin('link_documents_persons as ba', 'doc.id', 'ba.document_id')
@ -59,7 +60,6 @@ export default class HomeController {
// const year = params.year;
// const from = parseInt(year);
try {
// const datasets = await Database.from('documents as doc')
// .select([Database.raw(`date_part('month', server_date_published) as pub_month`), Database.raw('COUNT(*) as count')])
// .where('server_state', serverState)
@ -68,9 +68,12 @@ export default class HomeController {
// .groupBy('pub_month');
// // .orderBy('server_date_published');
const years = [2021, 2022, 2023]; // Add the second year
// Calculate the last 4 years including the current year
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 4 }, (_, i) => currentYear - (i + 1)).reverse();
const result = await db.from('documents as doc')
const result = await db
.from('documents as doc')
.select([
db.raw(`date_part('year', server_date_published) as pub_year`),
db.raw(`date_part('month', server_date_published) as pub_month`),
@ -83,7 +86,7 @@ export default class HomeController {
.groupBy('pub_year', 'pub_month')
.orderBy('pub_year', 'asc')
.orderBy('pub_month', 'asc');
const labels = Array.from({ length: 12 }, (_, i) => i + 1); // Assuming 12 months
const inputDatasets: Map<string, ChartDataset> = result.reduce((acc, item) => {
@ -100,15 +103,15 @@ export default class HomeController {
acc[pub_year].data[pub_month - 1] = parseInt(count);
return acc ;
return acc;
}, {});
const outputDatasets = Object.entries(inputDatasets).map(([year, data]) => ({
data: data.data,
label: year,
borderColor: data.borderColor,
fill: data.fill
}));
fill: data.fill,
}));
const data = {
labels: labels,
@ -126,11 +129,11 @@ export default class HomeController {
private getRandomHexColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
}
@ -139,5 +142,4 @@ interface ChartDataset {
label: string;
borderColor: string;
fill: boolean;
}

View file

@ -1,93 +1,68 @@
import type { HttpContext } from '@adonisjs/core/http';
import User from '#models/user';
import BackupCode from '#models/backup_code';
// import Hash from '@ioc:Adonis/Core/Hash';
// import InvalidCredentialException from 'App/Exceptions/InvalidCredentialException';
import { authValidator } from '#validators/auth';
import hash from '@adonisjs/core/services/hash';
import db from '@adonisjs/lucid/services/db';
import TwoFactorAuthProvider from '#app/services/TwoFactorAuthProvider';
// import { Authenticator } from '@adonisjs/auth';
// import { LoginState } from 'Contracts/enums';
// import { StatusCodes } from 'http-status-codes';
import ActivityLogger from '#services/activity_logger';
import logger from '@adonisjs/core/services/logger';
// interface MyHttpsContext extends HttpContext {
// auth: Authenticator<User>
// }
export default class AuthController {
// login function{ request, auth, response }:HttpContext
public async login({ request, response, auth, session }: HttpContext) {
// console.log({
// registerBody: request.body(),
// });
// await request.validate(AuthValidator);
await request.validateUsing(authValidator);
// const plainPassword = await request.input('password');
// const email = await request.input('email');
// grab uid and password values off request body
const { email, password } = request.only(['email', 'password']);
try {
// // attempt to verify credential and login user
// await auth.use('web').attempt(email, plainPassword);
await db.connection().rawQuery('SELECT 1');
// const user = await auth.use('web').verifyCredentials(email, password);
const user = await User.verifyCredentials(email, password);
if (user.isTwoFactorEnabled) {
// session.put("login.id", user.id);
// return view.render("pages/two-factor-challenge");
// Noch KEIN abgeschlossenes Login -> nicht loggen.
session.flash('user_id', user.id);
return response.redirect().back();
// let state = LoginState.STATE_VALIDATED;
// return response.status(StatusCodes.OK).json({
// state: state,
// new_user_id: user.id,
// });
}
await auth.use('web').login(user);
} catch (error) {
// if login fails, return vague form message and redirect back
this.recordAuthEvent('auth.login', { user, ip: request.ip() });
} catch (error: any) {
// DB nicht erreichbar -> kein fehlgeschlagener Login-Versuch, weiterwerfen
if (error.code === 'ECONNREFUSED') {
throw error;
}
// Echter Credential-Fehler -> als fehlgeschlagenen Versuch protokollieren
this.recordAuthEvent('auth.login_failed', { email, ip: request.ip() });
session.flash('message', 'Your username, email, or password is incorrect');
return response.redirect().back();
}
// otherwise, redirect todashboard
response.redirect('/apps/dashboard');
return response.redirect('/apps/dashboard');
}
public async twoFactorChallenge({ request, session, auth, response }: HttpContext) {
const { code, backup_code, login_id } = request.only(['code', 'backup_code', 'login_id']);
const { code, backup_code, login_id } = request.only(['code', 'backup_code', 'login_id']);
const user = await User.query().where('id', login_id).firstOrFail();
if (code) {
const isValid = await TwoFactorAuthProvider.validate(user, code);
if (isValid) {
// login user and redirect to dashboard
await auth.use('web').login(user);
response.redirect('/apps/dashboard');
} else {
session.flash('message', 'Your two-factor code is incorrect');
return response.redirect().back();
this.recordAuthEvent('auth.login', { user, email: user.email, ip: request.ip(), method: '2fa_totp' });
return response.redirect('/apps/dashboard');
}
} else if (backup_code) {
const codes: BackupCode[] = await user.getBackupCodes();
// const verifiedBackupCodes = await Promise.all(
// codes.map(async (backupCode) => {
// let isVerified = await hash.verify(backupCode.code, backup_code);
// if (isVerified) {
// return backupCode;
// }
// }),
// );
// const backupCodeToDelete = verifiedBackupCodes.find(Boolean);
let backupCodeToDelete = null;
session.flash('message', 'Your two-factor code is incorrect');
return response.redirect().back();
}
if (backup_code) {
const codes: BackupCode[] = await user.getBackupCodes();
let backupCodeToDelete: BackupCode | null = null;
for (const backupCode of codes) {
const isVerified = await hash.verify(backupCode.code, backup_code);
if (isVerified) {
@ -96,29 +71,68 @@ export default class AuthController {
}
}
if (backupCodeToDelete) {
if (backupCodeToDelete.used === false) {
backupCodeToDelete.used = true;
await backupCodeToDelete.save();
console.log(`BackupCode with id ${backupCodeToDelete.id} has been marked as used.`);
await auth.use('web').login(user);
response.redirect('/apps/dashboard');
} else {
session.flash('message', 'BackupCode already used');
return response.redirect().back();
}
} else {
if (!backupCodeToDelete) {
session.flash('message', 'BackupCode not found');
return response.redirect().back();
}
}
if (backupCodeToDelete.used) {
session.flash('message', 'BackupCode already used');
return response.redirect().back();
}
backupCodeToDelete.used = true;
await backupCodeToDelete.save();
await auth.use('web').login(user);
this.recordAuthEvent('auth.login', { user, email: user.email, ip: request.ip(), method: '2fa_backup_code' });
return response.redirect('/apps/dashboard');
}
// Weder code noch backup_code übergeben
session.flash('message', 'No verification code provided');
return response.redirect().back();
}
// logout function
public async logout({ auth, response }: HttpContext) {
// await auth.logout();
public async logout({ auth, request, response }: HttpContext) {
// Session auflösen -> füllt auth.user, falls eingeloggt
await auth.use('web').check();
const user = auth.use('web').user;
await auth.use('web').logout();
if (user) {
this.recordAuthEvent('auth.logout', { user, email: user.email, ip: request.ip() });
}
return response.redirect('/app/login');
// return response.status(200);
}
/**
* Zentraler Audit-Logger für Auth-Events.
* Fire-and-forget: ein Fehler beim Schreiben darf Login/Logout nie blockieren.
*/
private recordAuthEvent(
type: 'auth.login' | 'auth.logout' | 'auth.login_failed',
opts: { user?: User; email?: string; ip: string; method?: string },
) {
const { user, email, ip, method } = opts;
const description =
type === 'auth.login'
? `${user!.firstName} ${user!.lastName} signed in`
: type === 'auth.logout'
? `${user!.firstName} ${user!.lastName} signed out`
: `Failed login attempt for ${email ?? 'unknown'}`;
void ActivityLogger.log({
type,
description,
userId: user?.id ?? null,
subjectType: user ? 'User' : null,
subjectId: user?.id ?? null,
properties: { ip, ...(method ? { method } : {}) },
}).catch((err) => logger.error({ err }, `failed to record ${type} activity`));
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
import type { HttpContext } from '@adonisjs/core/http';
// import { RequestContract } from '@ioc:Adonis/Core/Request';
import { Request } from '@adonisjs/core/http';
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
import { create } from 'xmlbuilder2';
@ -15,14 +14,11 @@ import { OaiModelException, BadOaiModelException } from '#app/exceptions/OaiMode
import Dataset from '#models/dataset';
import Collection from '#models/collection';
import { getDomain, preg_match } from '#app/utils/utility-functions';
import XmlModel from '#app/Library/XmlModel';
import DatasetXmlSerializer from '#app/Library/DatasetXmlSerializer';
import logger from '@adonisjs/core/services/logger';
import ResumptionToken from '#app/Library/Oai/ResumptionToken';
// import Config from '@ioc:Adonis/Core/Config';
import config from '@adonisjs/core/services/config';
// import { inject } from '@adonisjs/fold';
import { inject } from '@adonisjs/core';
// import { TokenWorkerContract } from "MyApp/Models/TokenWorker";
import TokenWorkerContract from '#library/Oai/TokenWorkerContract';
import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model';
@ -83,13 +79,13 @@ export default class OaiController {
xsltParameter['oai_error_message'] = 'Only POST and GET methods are allowed for OAI-PMH.';
}
let earliestDateFromDb;
// const oaiRequest: OaiParameter = request.body;
try {
this.firstPublishedDataset = await Dataset.earliestPublicationDate();
this.firstPublishedDataset != null &&
(earliestDateFromDb = this.firstPublishedDataset.server_date_published.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"));
this.xsltParameter['earliestDatestamp'] = earliestDateFromDb;
// Pflichtfeld laut OAI-PMH: auch bei leerem Repository einen validen
// UTCdatetime liefern, sonst entsteht ein ungültiges leeres Element.
this.xsltParameter['earliestDatestamp'] =
this.firstPublishedDataset?.server_date_published.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") ?? '1970-01-01T00:00:00Z';
// start the request
await this.handleRequest(oaiRequest, request);
} catch (error) {
@ -122,7 +118,7 @@ export default class OaiController {
// logLevel: 10,
});
xmlOutput = result.principalResult;
} catch (error) {
} catch (error: any) {
return response.status(500).json({
message: 'An error occurred while creating the user',
error: error.message,
@ -157,7 +153,7 @@ export default class OaiController {
const verb = oaiRequest['verb'];
this.xsltParameter['oai_verb'] = verb;
if (verb === 'Identify') {
this.handleIdentify();
this.handleIdentify(oaiRequest);
} else if (verb === 'ListMetadataFormats') {
this.handleListMetadataFormats();
} else if (verb == 'GetRecord') {
@ -184,7 +180,10 @@ export default class OaiController {
}
}
protected handleIdentify() {
protected handleIdentify(oaiRequest: Dictionary) {
// OAI-PMH: Identify akzeptiert außer `verb` keine Argumente.
this.assertOnlyVerb(oaiRequest);
// Get configuration values from environment or a dedicated configuration service
const email = process.env.OAI_EMAIL ?? 'repository@geosphere.at';
const repositoryName = process.env.OAI_REPOSITORY_NAME ?? 'Tethys RDR';
@ -203,6 +202,21 @@ export default class OaiController {
this.xml.root().ele('Datasets');
}
/**
* Wirft badArgument, wenn der Request andere Parameter als `verb` enthält.
* Für Verben ohne zusätzliche Argumente (Identify, ListSets, ListMetadataFormats).
*/
private assertOnlyVerb(oaiRequest: Dictionary) {
const illegalKeys = Object.keys(oaiRequest).filter((key) => key !== 'verb');
if (illegalKeys.length > 0) {
throw new OaiModelException(
StatusCodes.BAD_REQUEST,
`The request includes illegal arguments: ${illegalKeys.join(', ')}.`,
OaiErrorCodes.BADARGUMENT,
);
}
}
protected handleListMetadataFormats() {
this.xml.root().ele('Datasets');
}
@ -292,7 +306,7 @@ export default class OaiController {
this.xsltParameter['repIdentifier'] = repIdentifier;
const datasetNode = this.xml.root().ele('Datasets');
const paginationParams: PagingParameter ={
const paginationParams: PagingParameter = {
cursor: 0,
totalLength: 0,
start: maxRecords + 1,
@ -333,7 +347,7 @@ export default class OaiController {
}
private async handleNoResumptionToken(oaiRequest: Dictionary, paginationParams: PagingParameter, maxRecords: number) {
this.validateMetadataPrefix(oaiRequest, paginationParams);
this.validateMetadataPrefix(oaiRequest, paginationParams);
const finder: ModelQueryBuilderContract<typeof Dataset, Dataset> = Dataset.query().whereIn(
'server_state',
this.deliveringDocumentStates,
@ -347,16 +361,20 @@ export default class OaiController {
finder: ModelQueryBuilderContract<typeof Dataset, Dataset>,
paginationParams: PagingParameter,
oaiRequest: Dictionary,
maxRecords: number
maxRecords: number,
) {
const totalResult = await finder
.clone()
.count('* as total')
.first()
.then((res) => res?.$extras.total);
paginationParams.totalLength = Number(totalResult);
paginationParams.totalLength = Number(totalResult);
const combinedRecords: Dataset[] = await finder.select('publish_id').orderBy('publish_id').offset(0).limit(maxRecords*2);
const combinedRecords: Dataset[] = await finder
.select('publish_id')
.orderBy('publish_id')
.offset(0)
.limit(maxRecords * 2);
paginationParams.activeWorkIds = combinedRecords.slice(0, 100).map((dat) => Number(dat.publish_id));
paginationParams.nextDocIds = combinedRecords.slice(100).map((dat) => Number(dat.publish_id));
@ -602,19 +620,17 @@ export default class OaiController {
}
private async getDatasetXmlDomNode(dataset: Dataset) {
const xmlModel = new XmlModel(dataset);
// xmlModel.setModel(dataset);
xmlModel.excludeEmptyFields();
xmlModel.caching = true;
const serializer = new DatasetXmlSerializer(dataset).enableCaching().excludeEmptyFields();
// const cache = dataset.xmlCache ? dataset.xmlCache : null;
// dataset.load('xmlCache');
if (dataset.xmlCache) {
xmlModel.xmlCache = dataset.xmlCache;
serializer.setCache(dataset.xmlCache);
}
// return cache.getDomDocument();
const domDocument: XMLBuilder | null = await xmlModel.getDomDocument();
return domDocument;
// return cache.toXmlDocument();
const xmlDocument: XMLBuilder | null = await serializer.toXmlDocument();
return xmlDocument;
}
private addSpecInformation(domNode: XMLBuilder, information: string) {

View file

@ -9,6 +9,7 @@ import vine from '@vinejs/vine';
import mail from '@adonisjs/mail/services/main';
import logger from '@adonisjs/core/services/logger';
import { validate } from 'deep-email-validator';
import File from '#models/file';
interface Dictionary {
[index: string]: string;
@ -38,13 +39,21 @@ export default class DatasetsController {
}
datasets.orderBy(attribute, sortOrder);
} else {
// users.orderBy('created_at', 'desc');
datasets.orderBy('id', 'asc');
// datasets.orderBy('id', 'asc');
// Custom ordering to prioritize rejected_editor state
datasets.orderByRaw(`
CASE
WHEN server_state = 'rejected_to_reviewer' THEN 0
ELSE 1
END ASC,
id ASC
`);
}
// const users = await User.query().orderBy('login').paginate(page, limit);
const myDatasets = await datasets
.where('server_state', 'approved')
// .where('server_state', 'approved')
.whereIn('server_state', ['approved', 'rejected_to_reviewer'])
.where('reviewer_id', user.id)
.preload('titles')
@ -62,7 +71,51 @@ export default class DatasetsController {
});
}
public async review({ request, inertia, response }: HttpContext) {
public async review({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id');
const datasetQuery = Dataset.query().where('id', id);
datasetQuery
.preload('titles', (query) => query.orderBy('id', 'asc'))
.preload('descriptions', (query) => query.orderBy('id', 'asc'))
.preload('coverage')
.preload('licenses')
.preload('authors', (query) => query.orderBy('pivot_sort_order', 'asc'))
.preload('contributors', (query) => query.orderBy('pivot_sort_order', 'asc'))
// .preload('subjects')
.preload('subjects', (builder) => {
builder.orderBy('id', 'asc').withCount('datasets');
})
.preload('references')
.preload('project')
.preload('files', (query) => {
query.orderBy('sort_order', 'asc'); // Sort by sort_order column
});
const dataset = await datasetQuery.firstOrFail();
const validStates = ['approved', 'rejected_to_reviewer'];
if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!');
return response
.flash(
'warning',
`Invalid server state. Dataset with id ${id} cannot be reviewed. Datset has server state ${dataset.server_state}.`,
)
.redirect()
.toRoute('reviewer.dataset.list');
}
return inertia.render('Reviewer/Dataset/Review', {
dataset,
can: {
review: await auth.user?.can(['dataset-review']),
reject: await auth.user?.can(['dataset-review-reject']),
},
});
}
public async review_old({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id');
const dataset = await Dataset.query()
.where('id', id)
@ -158,6 +211,10 @@ export default class DatasetsController {
return inertia.render('Reviewer/Dataset/Review', {
dataset,
fields: fields,
can: {
review: await auth.user?.can(['dataset-review']),
reject: await auth.user?.can(['dataset-review-reject']),
},
});
}
@ -166,7 +223,7 @@ export default class DatasetsController {
// const { id } = params;
const dataset = await Dataset.findOrFail(id);
const validStates = ['approved'];
const validStates = ['approved', 'rejected_to_reviewer'];
if (!validStates.includes(dataset.server_state)) {
// throw new Error('Invalid server state!');
// return response.flash('warning', 'Invalid server state. Dataset cannot be released to editor').redirect().back();
@ -180,6 +237,10 @@ export default class DatasetsController {
}
dataset.server_state = 'reviewed';
// if editor has rejected to reviewer:
if (dataset.reject_editor_note != null) {
dataset.reject_editor_note = null;
}
try {
// await dataset.related('editor').associate(user); // speichert schon ab
@ -203,7 +264,7 @@ export default class DatasetsController {
})
.firstOrFail();
const validStates = ['approved'];
const validStates = ['approved', 'rejected_to_reviewer'];
if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!');
return response
@ -250,12 +311,12 @@ export default class DatasetsController {
throw error;
}
const validStates = ['approved'];
const validStates = ['approved', 'rejected_to_reviewer'];
if (!validStates.includes(dataset.server_state)) {
// throw new Error('Invalid server state!');
// return response.flash('warning', 'Invalid server state. Dataset cannot be released to editor').redirect().back();
return response
.flash(
.flash(
`Invalid server state. Dataset with id ${id} cannot be rejected. Datset has server state ${dataset.server_state}.`,
'warning',
)
@ -307,4 +368,41 @@ export default class DatasetsController {
.toRoute('reviewer.dataset.list')
.flash(`You have rejected dataset ${dataset.id}! to editor ${dataset.editor.login}`, 'message');
}
// public async download({ params, response }: HttpContext) {
// const id = params.id;
// // Find the file by ID
// const file = await File.findOrFail(id);
// // const filePath = await drive.use('local').getUrl('/'+ file.filePath)
// const filePath = file.filePath;
// const fileExt = file.filePath.split('.').pop() || '';
// // Set the response headers and download the file
// response.header('Content-Type', file.mime_type || 'application/octet-stream');
// response.attachment(`${file.label}.${fileExt}`);
// return response.download(filePath);
// }
public async download({ params, response }: HttpContext) {
const id = params.id;
// Find the file by ID
const file = await File.findOrFail(id);
// const filePath = await drive.use('local').getUrl('/'+ file.filePath)
const filePath = file.filePath;
const fileExt = file.filePath.split('.').pop() || '';
// Check if label already includes the extension
const fileName = file.label.toLowerCase().endsWith(`.${fileExt.toLowerCase()}`) ? file.label : `${file.label}.${fileExt}`;
// Set the response headers and download the file
response
.header('Cache-Control', 'no-cache private')
.header('Content-Description', 'File Transfer')
.header('Content-Type', file.mime_type || 'application/octet-stream')
// .header('Content-Disposition', 'inline; filename=' + fileName)
.header('Content-Transfer-Encoding', 'binary')
.header('Access-Control-Allow-Origin', '*')
.header('Access-Control-Allow-Methods', 'GET');
response.attachment(fileName);
return response.download(filePath);
}
}

View file

@ -14,9 +14,7 @@ import Person from '#models/person';
import db from '@adonisjs/lucid/services/db';
import { TransactionClientContract } from '@adonisjs/lucid/types/database';
import Subject from '#models/subject';
// import CreateDatasetValidator from '#validators/create_dataset_validator';
import { createDatasetValidator, updateDatasetValidator } from '#validators/dataset';
// import UpdateDatasetValidator from '#validators/update_dataset_validator';
import {
TitleTypes,
DescriptionTypes,
@ -29,23 +27,33 @@ import {
} from '#contracts/enums';
import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model';
import DatasetReference from '#models/dataset_reference';
import { cuid } from '@adonisjs/core/helpers';
import File from '#models/file';
import ClamScan from 'clamscan';
// import { ValidationException } from '@adonisjs/validator';
// import Drive from '@ioc:Adonis/Core/Drive';
// import drive from '#services/drive';
import drive from '@adonisjs/drive/services/main';
import path from 'path';
import { Exception } from '@adonisjs/core/exceptions';
import { MultipartFile } from '@adonisjs/core/types/bodyparser';
import * as crypto from 'crypto';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import type { Multipart } from '@adonisjs/bodyparser';
import * as fs from 'fs';
import { parseBytesSize, getConfigFor, getTmpPath, formatBytes, errorMessage } from '#app/utils/utility-functions';
import validation from '#services/validation_service';
import ActivityLogger from '#services/activity_logger';
import logger from '@adonisjs/core/services/logger';
interface Dictionary {
[index: string]: string;
}
import vine, { SimpleMessagesProvider, errors } from '@vinejs/vine';
export default class DatasetController {
/**
* Bodyparser config
*/
// config: BodyParserConfig = config.get('bodyparser');
public async index({ auth, request, inertia }: HttpContext) {
const user = (await User.find(auth.user?.id)) as User;
const page = request.input('page', 1);
@ -69,8 +77,16 @@ export default class DatasetController {
}
datasets.orderBy(attribute, sortOrder);
} else {
// users.orderBy('created_at', 'desc');
datasets.orderBy('id', 'asc');
// datasets.orderBy('id', 'asc');
// Custom ordering to prioritize rejected_editor state
datasets.orderByRaw(`
CASE
WHEN server_state = 'rejected_editor' THEN 0
WHEN server_state = 'rejected_reviewer' THEN 1
ELSE 2
END ASC,
id ASC
`);
}
// const results = await Database
@ -90,6 +106,7 @@ export default class DatasetController {
'reviewed',
'rejected_editor',
'rejected_reviewer',
'rejected_to_reviewer',
])
.where('account_id', user.id)
.preload('titles')
@ -191,7 +208,9 @@ export default class DatasetController {
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.minLength(1),
.minLength(1) // Ensure at least the main title exists
// .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine
.array(
vine.object({
@ -205,7 +224,8 @@ export default class DatasetController {
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.minLength(1),
// .minLength(1),
.arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine
.array(
vine.object({
@ -216,8 +236,9 @@ export default class DatasetController {
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}),
)
.minLength(1)
@ -232,8 +253,9 @@ export default class DatasetController {
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
}),
)
@ -280,7 +302,8 @@ export default class DatasetController {
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.minLength(1),
// .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine
.array(
vine.object({
@ -294,7 +317,8 @@ export default class DatasetController {
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.minLength(1),
// .minLength(1),
.arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine
.array(
vine.object({
@ -305,8 +329,9 @@ export default class DatasetController {
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}),
)
.minLength(1)
@ -321,8 +346,9 @@ export default class DatasetController {
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
}),
)
@ -402,49 +428,138 @@ export default class DatasetController {
}
public async store({ auth, request, response, session }: HttpContext) {
// node ace make:validator CreateDataset
try {
// Step 2 - Validate request body against the schema
// await request.validate({ schema: newDatasetSchema, messages: this.messages });
// await request.validate(CreateDatasetValidator);
await request.validateUsing(createDatasetValidator);
// console.log({ payload });
} catch (error) {
// Step 3 - Handle errors
// return response.badRequest(error.messages);
throw error;
const uploadedTmpFiles: string[] = [];
const multipart: Multipart = request.multipart;
// NULL GUARD: kein multipart-Body → sauberer Validierungsfehler statt Crash
if (!multipart) {
validation.throw('files', 'Please select at least one file.', 'required');
}
// Configuration for limits
const multipartConfig = getConfigFor('multipart');
const aggregatedLimit = multipartConfig.limit ? parseBytesSize(multipartConfig.limit) : 100 * 1024 * 1024;
let totalUploadedSize = 0;
let filesCount = 0; // Tracker: ensure at least one file is processed
multipart.onFile('files', { deferValidations: true }, async (part) => {
filesCount++;
let fileUploadedSize = 0;
// Accumulate the size per chunk (cheap), defer the limit check to 'end'
part.on('data', (chunk) => {
fileUploadedSize += chunk.length;
});
// After the file is fully read, update the global counter and check the aggregated limit
part.on('end', () => {
totalUploadedSize += fileUploadedSize;
part.file.size = fileUploadedSize;
// Record the temporary file path
if (part.file.tmpPath) {
uploadedTmpFiles.push(part.file.tmpPath);
}
// AGGREGATED LIMIT CHECK: abort immediately if too big
if (totalUploadedSize > aggregatedLimit) {
// Clean up all temporary files if aggregate limit is exceeded
uploadedTmpFiles.forEach((tmpPath) => {
try {
fs.unlinkSync(tmpPath);
} catch (cleanupError) {
console.error('Error cleaning up temporary file:', cleanupError);
}
});
request.multipart.abort(validation.make('files', `Upload limit of ${formatBytes(aggregatedLimit)} exceeded.`, 'limit'));
}
});
// Process file with error handling
try {
// Extract extension from the client file name, e.g. "Tethys 5 - Ampflwang_dataset.zip"
const ext = path.extname(part.file.clientName).replace('.', '');
part.file.extname = ext;
const tmpPath = getTmpPath(multipartConfig);
(part.file as any).tmpPath = tmpPath;
const writeStream = createWriteStream(tmpPath);
await pipeline(part, writeStream);
} catch (error) {
request.multipart.abort(validation.make('files', error.message, 'upload'));
}
});
try {
await multipart.process();
// EMPTY FIELD CHECK: triggered if process finishes but onFile never ran
if (filesCount === 0) {
validation.throw('files', 'Please select at least one file.', 'required');
}
} catch (error) {
// If it's already a validation error, let it bubble up unchanged
if (error instanceof errors.E_VALIDATION_ERROR) throw error;
// Wrap any other stream/size errors
validation.throw('files', errorMessage(error) || 'Upload failed.');
}
// Proceed to transaction and createDatasetAndAssociations
let trx: TransactionClientContract | null = null;
try {
await request.validateUsing(createDatasetValidator);
trx = await db.transaction();
const user = (await User.find(auth.user?.id)) as User;
await this.createDatasetAndAssociations(user, request, trx);
// await this.createDatasetAndAssociations(user, request, trx);
const { dataset, mainTitle } = await this.createDatasetAndAssociations(user, request, trx);
await trx.commit();
console.log('Dataset and related models created successfully');
// NACH dem Commit: Dataset ist garantiert persistiert, keine Waisen-Gefahr.
// Fire-and-forget, damit ein Log-Fehler den bereits erfolgreichen Upload nicht kippt.
void ActivityLogger.log({
type: 'dataset.uploaded',
description: `New publication uploaded: ${mainTitle ?? 'Untitled'}`,
userId: user.id,
subjectType: 'Dataset',
subjectId: dataset.id,
}).catch((err) => logger.error({ err }, 'failed to record dataset.uploaded activity'));
} catch (error) {
// Clean up temporary files if validation or later steps fail
uploadedTmpFiles.forEach((tmpPath) => {
try {
fs.unlinkSync(tmpPath);
} catch (cleanupError) {
console.error('Error cleaning up temporary file:', cleanupError);
}
});
if (trx !== null) {
await trx.rollback();
}
console.error('Failed to create dataset and related models:', error);
// throw new ValidationException(true, { 'upload error': `failed to create dataset and related models. ${error}` });
throw error;
}
session.flash('message', 'Dataset has been created successfully');
return response.redirect().toRoute('dataset.list');
// return response.redirect().back();
}
private async createDatasetAndAssociations(user: User, request: HttpContext['request'], trx: TransactionClientContract) {
private async createDatasetAndAssociations(
user: User,
request: HttpContext['request'],
trx: TransactionClientContract,
// uploadedFiles: Array<MultipartFile>,
) {
// Create a new instance of the Dataset model:
const dataset = new Dataset();
dataset.type = request.input('type');
dataset.creating_corporation = request.input('creating_corporation');
dataset.language = request.input('language');
dataset.embargo_date = request.input('embargo_date');
dataset.project_id = request.input('project_id');
//await dataset.related('user').associate(user); // speichert schon ab
// Dataset.$getRelation('user').boot();
// Dataset.$getRelation('user').setRelated(dataset, user);
@ -460,6 +575,15 @@ export default class DatasetController {
await this.savePersons(dataset, request.input('contributors', []), 'contributor', trx);
//save main and additional titles
// const titles = request.input('titles', []);
// for (const titleData of titles) {
// const title = new Title();
// title.value = titleData.value;
// title.language = titleData.language;
// title.type = titleData.type;
// await dataset.useTransaction(trx).related('titles').save(title);
// }
let mainTitle: string | null = null;
const titles = request.input('titles', []);
for (const titleData of titles) {
const title = new Title();
@ -467,6 +591,11 @@ export default class DatasetController {
title.language = titleData.language;
title.type = titleData.type;
await dataset.useTransaction(trx).related('titles').save(title);
if (titleData.type === 'Main') {
// <-- an eure Typ-Konvention anpassen
mainTitle = titleData.value;
}
}
// save descriptions
@ -553,13 +682,15 @@ export default class DatasetController {
newFile.fileSize = file.size;
newFile.mimeType = mimeType;
newFile.label = file.clientName;
newFile.sortOrder = index;
newFile.sortOrder = index + 1;
newFile.visibleInFrontdoor = true;
newFile.visibleInOai = true;
// let path = coverImage.filePath;
await dataset.useTransaction(trx).related('files').save(newFile);
await newFile.createHashValues(trx);
}
return { dataset, mainTitle }; // <-- statt void
}
private generateRandomString(length: number): string {
@ -704,16 +835,25 @@ export default class DatasetController {
'files.array.minLength': 'At least {{ min }} file upload is required.',
'files.*.size': 'file size is to big',
'files.*.extnames': 'file extension is not supported',
'embargo_date.date.afterOrEqual': `Embargo date must be on or after ${dayjs().add(10, 'day').format('DD.MM.YYYY')}`,
};
// public async release({ params, view }) {
public async release({ request, inertia, response }: HttpContext) {
public async release({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id');
const user = auth.user;
// Check if user is authenticated
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const dataset = await Dataset.query()
.preload('user', (builder) => {
builder.select('id', 'login');
})
.where('account_id', user.id) // Only fetch if user owns it
.where('id', id)
.firstOrFail();
@ -734,9 +874,20 @@ export default class DatasetController {
});
}
public async releaseUpdate({ request, response }: HttpContext) {
public async releaseUpdate({ request, response, auth }: HttpContext) {
const id = request.param('id');
const dataset = await Dataset.query().preload('files').where('id', id).firstOrFail();
const user = auth.user;
// Check if user is authenticated
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const dataset = await Dataset.query()
.preload('files')
.where('id', id)
.where('account_id', user.id) // Only fetch if user owns it
.firstOrFail();
const validStates = ['inprogress', 'rejected_editor'];
if (!validStates.includes(dataset.server_state)) {
@ -814,16 +965,24 @@ export default class DatasetController {
// throw new GeneralException(trans('exceptions.publish.release.update_error'));
}
public async edit({ request, inertia, response }: HttpContext) {
public async edit({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id');
const datasetQuery = Dataset.query().where('id', id);
const user = auth.user;
// Check if user is authenticated
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
// Prefilter by both id AND account_id
const datasetQuery = Dataset.query().where('id', id).where('account_id', user.id); // Only fetch if user owns it
datasetQuery
.preload('titles', (query) => query.orderBy('id', 'asc'))
.preload('descriptions', (query) => query.orderBy('id', 'asc'))
.preload('coverage')
.preload('licenses')
.preload('authors')
.preload('contributors')
.preload('authors', (query) => query.orderBy('pivot_sort_order', 'asc'))
.preload('contributors', (query) => query.orderBy('pivot_sort_order', 'asc'))
// .preload('subjects')
.preload('subjects', (builder) => {
builder.orderBy('id', 'asc').withCount('datasets');
@ -832,17 +991,17 @@ export default class DatasetController {
.preload('files', (query) => {
query.orderBy('sort_order', 'asc'); // Sort by sort_order column
});
// This will throw 404 if dataset doesn't exist OR user doesn't own it
const dataset = await datasetQuery.firstOrFail();
const validStates = ['inprogress', 'rejected_editor'];
if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!');
return response
.flash(
'warning',
`Invalid server state. Dataset with id ${id} cannot be edited. Datset has server state ${dataset.server_state}.`,
'warning',
)
.redirect()
.toRoute('dataset.list');
}
@ -872,19 +1031,6 @@ export default class DatasetController {
const years = Array.from({ length: currentYear - 1990 + 1 }, (_, index) => 1990 + index);
const licenses = await License.query().select('id', 'name_long').where('active', 'true').pluck('name_long', 'id');
// const userHasRoles = user.roles;
// const datasetHasLicenses = await dataset.related('licenses').query().pluck('id');
// const checkeds = dataset.licenses.first().id;
const doctypes = {
analysisdata: { label: 'Analysis', value: 'analysisdata' },
measurementdata: { label: 'Measurements', value: 'measurementdata' },
monitoring: 'Monitoring',
remotesensing: 'Remote Sensing',
gis: 'GIS',
models: 'Models',
mixedtype: 'Mixed Type',
};
return inertia.render('Submitter/Dataset/Edit', {
dataset,
@ -903,25 +1049,114 @@ export default class DatasetController {
subjectTypes: SubjectTypes,
referenceIdentifierTypes: Object.entries(ReferenceIdentifierTypes).map(([key, value]) => ({ value: key, label: value })),
relationTypes: Object.entries(RelationTypes).map(([key, value]) => ({ value: key, label: value })),
doctypes,
doctypes: DatasetTypes,
can: {
edit: await auth.user?.can(['dataset-edit']),
delete: await auth.user?.can(['dataset-delete']),
},
});
}
public async update({ request, response, session }: HttpContext) {
try {
// await request.validate(UpdateDatasetValidator);
await request.validateUsing(updateDatasetValidator);
} catch (error) {
// - Handle errors
// return response.badRequest(error.messages);
throw error;
// return response.badRequest(error.messages);
}
// await request.validate(UpdateDatasetValidator);
const id = request.param('id');
public async update({ request, response, session, auth }: HttpContext) {
// Get the dataset id from the route parameter
const datasetId = request.param('id');
const user = auth.user;
// Check if user is authenticated
if (!user) {
return response.flash('You must be logged in to update a dataset.', 'error').redirect().toRoute('app.login.show');
}
// Prefilter by both id AND account_id
const dataset = await Dataset.query()
.where('id', datasetId)
.where('account_id', user.id) // Only fetch if user owns it
.firstOrFail();
// // Check if the authenticated user is the owner of the dataset
// if (dataset.account_id !== user.id) {
// return response
// .flash(`Unauthorized access. You are not the owner of dataset with id ${id}.`, 'error')
// .redirect()
// .toRoute('dataset.list');
// }
await dataset.load('files');
// Accumulate the size of the already related files
// const preExistingFileSize = dataset.files.reduce((acc, file) => acc + file.fileSize, 0);
let preExistingFileSize = 0;
for (const file of dataset.files) {
preExistingFileSize += Number(file.fileSize);
}
const uploadedTmpFiles: string[] = [];
// Only process multipart if the request has a multipart content type
const contentType = request.request.headers['content-type'] || '';
if (contentType.includes('multipart/form-data')) {
const multipart: Multipart = request.multipart;
// Aggregated limit example (adjust as needed)
const multipartConfig = getConfigFor('multipart');
const aggregatedLimit = multipartConfig.limit ? parseBytesSize(multipartConfig.limit) : 100 * 1024 * 1024;
// Initialize totalUploadedSize with the size of existing files
let totalUploadedSize = preExistingFileSize;
multipart.onFile('files', { deferValidations: true }, async (part) => {
let fileUploadedSize = 0;
part.on('data', (chunk) => {
fileUploadedSize += chunk.length;
});
part.on('end', () => {
totalUploadedSize += fileUploadedSize;
part.file.size = fileUploadedSize;
if (part.file.tmpPath) {
uploadedTmpFiles.push(part.file.tmpPath);
}
if (totalUploadedSize > aggregatedLimit) {
uploadedTmpFiles.forEach((tmpPath) => {
try {
fs.unlinkSync(tmpPath);
} catch (cleanupError) {
console.error('Error cleaning up temporary file:', cleanupError);
}
});
const error = new errors.E_VALIDATION_ERROR({
files: `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`,
});
request.multipart.abort(error);
}
});
part.on('error', (error) => {
request.multipart.abort(error);
});
try {
const fileNameWithoutParams = part.file.clientName.split('?')[0];
const ext = path.extname(fileNameWithoutParams).replace('.', '');
part.file.extname = ext;
const tmpPath = getTmpPath(multipartConfig);
(part.file as any).tmpPath = tmpPath;
const writeStream = createWriteStream(tmpPath);
await pipeline(part, writeStream);
} catch (error) {
request.multipart.abort(new errors.E_VALIDATION_ERROR({ 'upload error': error.message }));
}
});
try {
await multipart.process();
} catch (error) {
// session.flash('errors', error.messages);
return response.redirect().back();
}
}
const id = request.param('id');
let trx: TransactionClientContract | null = null;
try {
await request.validateUsing(updateDatasetValidator);
trx = await db.transaction();
// const user = (await User.find(auth.user?.id)) as User;
// await this.createDatasetAndAssociations(user, request, trx);
@ -982,22 +1217,148 @@ export default class DatasetController {
}
}
// await dataset.useTransaction(trx).related('subjects').sync([]);
const keywords = request.input('subjects');
for (const keywordData of keywords) {
if (keywordData.id) {
const subject = await Subject.findOrFail(keywordData.id);
// await dataset.useTransaction(trx).related('subjects').attach([keywordData.id]);
subject.value = keywordData.value;
subject.type = keywordData.type;
subject.external_key = keywordData.external_key;
if (subject.$isDirty) {
await subject.save();
// ============================================
// IMPROVED SUBJECTS PROCESSING
// ============================================
const subjects = request.input('subjects', []);
const currentDatasetSubjectIds = new Set<number>();
for (const subjectData of subjects) {
let subjectToRelate: Subject;
// Case 1: Subject has an ID (existing subject being updated)
if (subjectData.id) {
const existingSubject = await Subject.findOrFail(subjectData.id);
// Check if the updated value conflicts with another existing subject
const duplicateSubject = await Subject.query()
.where('value', subjectData.value)
.where('type', subjectData.type)
.where('language', subjectData.language || 'en') // Default language if not provided
.where('id', '!=', subjectData.id) // Exclude the current subject
.first();
if (duplicateSubject) {
// A duplicate exists - use the existing duplicate instead
subjectToRelate = duplicateSubject;
// Check if the original subject should be deleted (if it's only used by this dataset)
const originalSubjectUsage = await Subject.query()
.where('id', existingSubject.id)
.withCount('datasets')
.firstOrFail();
if (originalSubjectUsage.$extras.datasets_count <= 1) {
// Only used by this dataset, safe to delete after detaching
await dataset.useTransaction(trx).related('subjects').detach([existingSubject.id]);
await existingSubject.useTransaction(trx).delete();
} else {
// Used by other datasets, just detach from this one
await dataset.useTransaction(trx).related('subjects').detach([existingSubject.id]);
}
} else {
// No duplicate found, update the existing subject
existingSubject.value = subjectData.value;
existingSubject.type = subjectData.type;
existingSubject.language = subjectData.language;
existingSubject.external_key = subjectData.external_key;
if (existingSubject.$isDirty) {
await existingSubject.useTransaction(trx).save();
}
subjectToRelate = existingSubject;
}
}
// Case 2: New subject being added (no ID)
else {
// Use firstOrNew to either find existing or create new subject
subjectToRelate = await Subject.firstOrNew(
{
value: subjectData.value,
type: subjectData.type,
language: subjectData.language || 'en',
},
{
value: subjectData.value,
type: subjectData.type,
language: subjectData.language || 'en',
external_key: subjectData.external_key,
},
);
if (subjectToRelate.$isNew) {
await subjectToRelate.useTransaction(trx).save();
}
}
// Ensure the relationship exists between dataset and subject
const relationshipExists = await dataset.related('subjects').query().where('subject_id', subjectToRelate.id).first();
if (!relationshipExists) {
await dataset.useTransaction(trx).related('subjects').attach([subjectToRelate.id]);
}
// Track which subjects should remain associated with this dataset
currentDatasetSubjectIds.add(subjectToRelate.id);
}
// Handle explicit deletions
const subjectsToDelete = request.input('subjectsToDelete', []);
for (const subjectData of subjectsToDelete) {
if (subjectData.id) {
// const subject = await Subject.findOrFail(subjectData.id);
const subject = await Subject.query()
.where('id', subjectData.id)
.preload('datasets', (builder) => {
builder.orderBy('id', 'asc');
})
.withCount('datasets')
.firstOrFail();
// Detach the subject from this dataset
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
// If this was the only dataset using this subject, delete it entirely
if (subject.$extras.datasets_count <= 1) {
await subject.useTransaction(trx).delete();
}
// Remove from current set if it was added earlier
currentDatasetSubjectIds.delete(subjectData.id);
}
}
// Process references
const references = request.input('references', []);
// First, get existing references to determine which ones to update vs. create
const existingReferences = await dataset.related('references').query();
const existingReferencesMap: Map<number, DatasetReference> = new Map(existingReferences.map((ref) => [ref.id, ref]));
for (const referenceData of references) {
if (existingReferencesMap.has(referenceData.id) && referenceData.id) {
// Update existing reference
const reference = existingReferencesMap.get(referenceData.id);
if (reference) {
reference.merge(referenceData);
if (reference.$isDirty) {
await reference.useTransaction(trx).save();
}
}
} else {
const keyword = new Subject();
keyword.fill(keywordData);
await dataset.useTransaction(trx).related('subjects').save(keyword, false);
// Create new reference
const dataReference = new DatasetReference();
dataReference.fill(referenceData);
await dataset.useTransaction(trx).related('references').save(dataReference);
}
}
// Handle references to delete if provided
const referencesToDelete = request.input('referencesToDelete', []);
for (const referenceData of referencesToDelete) {
if (referenceData.id) {
const reference = await DatasetReference.findOrFail(referenceData.id);
await reference.useTransaction(trx).delete();
}
}
@ -1029,9 +1390,9 @@ export default class DatasetController {
// handle new uploaded files:
const uploadedFiles: MultipartFile[] = request.files('files');
if (Array.isArray(uploadedFiles) && uploadedFiles.length > 0) {
for (const [index, fileData] of uploadedFiles.entries()) {
for (const [index, file] of uploadedFiles.entries()) {
try {
await this.scanFileForViruses(fileData.tmpPath); //, 'gitea.lan', 3310);
await this.scanFileForViruses(file.tmpPath); //, 'gitea.lan', 3310);
// await this.scanFileForViruses("/tmp/testfile.txt");
} catch (error) {
// If the file is infected or there's an error scanning the file, throw a validation exception
@ -1039,29 +1400,29 @@ export default class DatasetController {
}
// move to disk:
const fileName = `file-${cuid()}.${fileData.extname}`; //'file-ls0jyb8xbzqtrclufu2z2e0c.pdf'
const fileName = this.generateFilename(file.extname as string);
const datasetFolder = `files/${dataset.id}`; // 'files/307'
const datasetFullPath = path.join(`${datasetFolder}`, fileName);
// await fileData.moveToDisk(datasetFolder, { name: fileName, overwrite: true }, 'local');
// await fileData.move(drive.makePath(datasetFolder), {
// await file.moveToDisk(datasetFolder, { name: fileName, overwrite: true }, 'local');
// await file.move(drive.makePath(datasetFolder), {
// name: fileName,
// overwrite: true, // overwrite in case of conflict
// });
await fileData.moveToDisk(datasetFullPath, 'local', {
await file.moveToDisk(datasetFullPath, 'local', {
name: fileName,
overwrite: true, // overwrite in case of conflict
disk: 'local',
});
//save to db:
const { clientFileName, sortOrder } = this.extractVariableNameAndSortOrder(fileData.clientName);
const mimeType = fileData.headers['content-type'] || 'application/octet-stream'; // Fallback to a default MIME type
const { clientFileName, sortOrder } = this.extractVariableNameAndSortOrder(file.clientName);
const mimeType = file.headers['content-type'] || 'application/octet-stream'; // Fallback to a default MIME type
const newFile = await dataset
.useTransaction(trx)
.related('files')
.create({
pathName: `${datasetFolder}/${fileName}`,
fileSize: fileData.size,
fileSize: file.size,
mimeType,
label: clientFileName,
sortOrder: sortOrder || index,
@ -1101,16 +1462,24 @@ export default class DatasetController {
await dataset.useTransaction(trx).save();
await trx.commit();
console.log('Dataset and related models created successfully');
console.log('Dataset has been updated successfully');
session.flash('message', 'Dataset has been updated successfully');
// return response.redirect().toRoute('user.index');
return response.redirect().toRoute('dataset.edit', [dataset.id]);
} catch (error) {
// Clean up temporary files if validation or later steps fail
uploadedTmpFiles.forEach((tmpPath) => {
try {
fs.unlinkSync(tmpPath);
} catch (cleanupError) {
console.error('Error cleaning up temporary file:', cleanupError);
}
});
if (trx !== null) {
await trx.rollback();
}
console.error('Failed to create dataset and related models:', error);
console.error('Failed to update dataset and related models:', error);
// throw new ValidationException(true, { 'upload error': `failed to create dataset and related models. ${error}` });
throw error;
}
@ -1135,16 +1504,26 @@ export default class DatasetController {
}
}
public async delete({ request, inertia, response, session }: HttpContext) {
public async delete({ request, inertia, response, session, auth }: HttpContext) {
const id = request.param('id');
const user = auth.user;
// Check if user is authenticated
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
try {
// This will throw 404 if dataset doesn't exist OR user doesn't own it
const dataset = await Dataset.query()
.preload('user', (builder) => {
builder.select('id', 'login');
})
.where('id', id)
.where('account_id', user.id) // Only fetch if user owns it
.preload('files')
.firstOrFail();
const validStates = ['inprogress', 'rejected_editor'];
if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!');
@ -1169,9 +1548,27 @@ export default class DatasetController {
}
}
public async deleteUpdate({ params, session, response }: HttpContext) {
public async deleteUpdate({ params, session, response, auth }: HttpContext) {
try {
const dataset = await Dataset.query().where('id', params.id).preload('files').firstOrFail();
const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
// This will throw 404 if dataset doesn't exist OR user doesn't own it
const dataset = await Dataset.query()
.where('id', params.id)
.where('account_id', user.id) // Only fetch if user owns it
.preload('files')
.firstOrFail();
// // Check if the authenticated user is the owner of the dataset
// if (dataset.account_id !== user.id) {
// return response
// .flash(`Unauthorized access. You are not the owner of dataset with id ${params.id}.`, 'error')
// .redirect()
// .toRoute('dataset.list');
// }
const validStates = ['inprogress', 'rejected_editor'];
if (validStates.includes(dataset.server_state)) {
@ -1236,6 +1633,7 @@ export default class DatasetController {
}
const collectionRoles = await CollectionRole.query()
.whereIn('name', ['ddc', 'ccs'])
.preload('collections', (coll: Collection) => {
// preloa only top level collection with noparent_id
coll.whereNull('parent_id').orderBy('number', 'asc');
@ -1275,7 +1673,7 @@ export default class DatasetController {
// This should be an array of collection ids.
const collections: number[] = request.input('collections', []);
// Synchronize the dataset collections using the transaction.
// Synchronize the dataset collections using the transaction.
await dataset.useTransaction(trx).related('collections').sync(collections);
// Commit the transaction.await trx.commit()

View file

@ -0,0 +1,231 @@
import DocumentXmlCache from '#models/DocumentXmlCache';
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
import Dataset from '#models/dataset';
import Strategy from './Strategy.js';
import { builder } from 'xmlbuilder2';
import logger from '@adonisjs/core/services/logger';
/**
* Configuration for XML serialization
*
* @interface XmlSerializationConfig
*/
export interface XmlSerializationConfig {
/** The dataset model to serialize */
model: Dataset;
/** DOM representation (if available) */
dom?: XMLBuilder;
/** Fields to exclude from serialization */
excludeFields: Array<string>;
/** Whether to exclude empty fields */
excludeEmpty: boolean;
/** Base URI for xlink:ref elements */
baseUri: string;
}
/**
* Options for controlling serialization behavior
*/
export interface SerializationOptions {
/** Enable XML caching */
enableCaching?: boolean;
/** Exclude empty fields from output */
excludeEmptyFields?: boolean;
/** Custom base URI */
baseUri?: string;
/** Fields to exclude */
excludeFields?: string[];
}
/**
* DatasetXmlSerializer
*
* Handles XML serialization of Dataset models with intelligent caching.
* Generates XML representations and manages cache lifecycle to optimize performance.
*
* @example
* ```typescript
* const serializer = new DatasetXmlSerializer(dataset);
* serializer.enableCaching();
* serializer.excludeEmptyFields();
*
* const xmlDocument = await serializer.toXmlDocument();
* ```
*/
export default class DatasetXmlSerializer {
private readonly config: XmlSerializationConfig;
private readonly strategy: Strategy;
private cache: DocumentXmlCache | null = null;
private cachingEnabled = false;
constructor(dataset: Dataset, options: SerializationOptions = {}) {
this.config = {
model: dataset,
excludeEmpty: options.excludeEmptyFields ?? false,
baseUri: options.baseUri ?? '',
excludeFields: options.excludeFields ?? [],
};
this.strategy = new Strategy({
excludeEmpty: options.excludeEmptyFields ?? false,
baseUri: options.baseUri ?? '',
excludeFields: options.excludeFields ?? [],
model: dataset,
});
if (options.enableCaching) {
this.cachingEnabled = true;
}
}
/**
* Enable caching for XML generation
* When enabled, generated XML is stored in database for faster retrieval
*/
public enableCaching(): this {
this.cachingEnabled = true;
return this;
}
/**
* Disable caching for XML generation
*/
public disableCaching(): this {
this.cachingEnabled = false;
return this;
}
set model(model: Dataset) {
this.config.model = model;
}
/**
* Configure to exclude empty fields from XML output
*/
public excludeEmptyFields(): this {
this.config.excludeEmpty = true;
return this;
}
/**
* Set the cache instance directly (useful when preloading)
* @param cache - The DocumentXmlCache instance
*/
public setCache(cache: DocumentXmlCache): this {
this.cache = cache;
return this;
}
/**
* Get the current cache instance
*/
public getCache(): DocumentXmlCache | null {
return this.cache;
}
/**
* Get DOM document with intelligent caching
* Returns cached version if valid, otherwise generates new document
*/
public async toXmlDocument(): Promise<XMLBuilder | null> {
const dataset = this.config.model;
// Try to get from cache first
let cachedDocument: XMLBuilder | null = await this.retrieveFromCache();
if (cachedDocument) {
logger.debug(`Using cached XML for dataset ${dataset.id}`);
return cachedDocument;
}
// Generate fresh document
logger.debug(`[DatasetXmlSerializer] Cache miss - generating fresh XML for dataset ${dataset.id}`);
const freshDocument = await this.strategy.createDomDocument();
if (!freshDocument) {
logger.error(`[DatasetXmlSerializer] Failed to generate XML for dataset ${dataset.id}`);
return null;
}
// Cache if caching is enabled
if (this.cachingEnabled) {
await this.persistToCache(freshDocument, dataset);
}
// Extract the dataset-specific node
return this.extractDatasetNode(freshDocument);
}
/**
* Generate XML string representation
* Convenience method that converts XMLBuilder to string
*/
public async toXmlString(): Promise<string | null> {
const document = await this.toXmlDocument();
return document ? document.end({ prettyPrint: false }) : null;
}
/**
* Persist generated XML document to cache
* Non-blocking - failures are logged but don't interrupt the flow
*/
private async persistToCache(domDocument: XMLBuilder, dataset: Dataset): Promise<void> {
try {
this.cache = this.cache || new DocumentXmlCache();
this.cache.document_id = dataset.id;
this.cache.xml_version = 1;
this.cache.server_date_modified = dataset.server_date_modified.toFormat('yyyy-MM-dd HH:mm:ss');
this.cache.xml_data = domDocument.end();
await this.cache.save();
logger.debug(`Cached XML for dataset ${dataset.id}`);
} catch (error) {
logger.error(`Failed to cache XML for dataset ${dataset.id}: ${error.message}`);
// Don't throw - caching failure shouldn't break the flow
}
}
/**
* Extract the Rdr_Dataset node from full document
*/
private extractDatasetNode(domDocument: XMLBuilder): XMLBuilder | null {
const node = domDocument.find((n) => n.node.nodeName === 'Rdr_Dataset', false, true)?.node;
if (node) {
return builder({ version: '1.0', encoding: 'UTF-8', standalone: true }, node);
}
return domDocument;
}
/**
* Attempt to retrieve valid cached XML document
* Returns null if cache doesn't exist or is stale
*/
private async retrieveFromCache(): Promise<XMLBuilder | null> {
const dataset: Dataset = this.config.model;
if (!this.cache) {
return null;
}
// Check if cache is still valid
const actuallyCached = await DocumentXmlCache.hasValidEntry(dataset.id, dataset.server_date_modified);
if (!actuallyCached) {
logger.debug(`Cache invalid for dataset ${dataset.id}`);
return null;
}
//cache is actual return cached document
try {
if (this.cache) {
return this.cache.getDomDocument();
} else {
return null;
}
} catch (error) {
logger.error(`Failed to retrieve cached document for dataset ${dataset.id}: ${error.message}`);
return null;
}
}
}

View file

@ -1,6 +1,3 @@
// import { Client } from 'guzzle';
// import { Log } from '@adonisjs/core/build/standalone';
// import { DoiInterface } from './interfaces/DoiInterface';
import DoiClientContract from '#app/Library/Doi/DoiClientContract';
import DoiClientException from '#app/exceptions/DoiClientException';
import { StatusCodes } from 'http-status-codes';
@ -12,14 +9,14 @@ export class DoiClient implements DoiClientContract {
public username: string;
public password: string;
public serviceUrl: string;
public apiUrl: string;
constructor() {
// const datacite_environment = process.env.DATACITE_ENVIRONMENT || 'debug';
this.username = process.env.DATACITE_USERNAME || '';
this.password = process.env.DATACITE_PASSWORD || '';
this.serviceUrl = process.env.DATACITE_SERVICE_URL || '';
// this.prefix = process.env.DATACITE_PREFIX || '';
// this.base_domain = process.env.BASE_DOMAIN || '';
this.apiUrl = process.env.DATACITE_API_URL || 'https://api.datacite.org';
if (this.username === '' || this.password === '' || this.serviceUrl === '') {
const message = 'issing configuration settings to properly initialize DOI client';
@ -90,4 +87,240 @@ export class DoiClient implements DoiClientContract {
throw new DoiClientException(error.response.status, error.response.data);
}
}
/**
* Retrieves DOI information from DataCite REST API
*
* @param doiValue The DOI identifier e.g. '10.5072/tethys.999'
* @returns Promise with DOI information or null if not found
*/
public async getDoiInfo(doiValue: string): Promise<any | null> {
try {
// Use configurable DataCite REST API URL
const dataciteApiUrl = `${this.apiUrl}/dois/${doiValue}`;
const response = await axios.get(dataciteApiUrl, {
headers: {
Accept: 'application/vnd.api+json',
},
});
if (response.status === 200 && response.data.data) {
return {
created: response.data.data.attributes.created,
registered: response.data.data.attributes.registered,
updated: response.data.data.attributes.updated,
published: response.data.data.attributes.published,
state: response.data.data.attributes.state,
url: response.data.data.attributes.url,
metadata: response.data.data.attributes,
};
}
} catch (error) {
if (error.response?.status === 404) {
logger.debug(`DOI ${doiValue} not found in DataCite`);
return null;
}
logger.debug(`DataCite REST API failed for ${doiValue}: ${error.message}`);
// Fallback to MDS API
return await this.getDoiInfoFromMds(doiValue);
}
return null;
}
/**
* Fallback method to get DOI info from MDS API
*
* @param doiValue The DOI identifier
* @returns Promise with basic DOI information or null
*/
private async getDoiInfoFromMds(doiValue: string): Promise<any | null> {
try {
const auth = {
username: this.username,
password: this.password,
};
// Get DOI URL
const doiResponse = await axios.get(`${this.serviceUrl}/doi/${doiValue}`, { auth });
if (doiResponse.status === 200) {
// Get metadata if available
try {
const metadataResponse = await axios.get(`${this.serviceUrl}/metadata/${doiValue}`, {
auth,
headers: {
Accept: 'application/xml',
},
});
return {
url: doiResponse.data.trim(),
metadata: metadataResponse.data,
created: new Date().toISOString(), // MDS doesn't provide creation dates
registered: new Date().toISOString(), // Use current time as fallback
source: 'mds',
};
} catch (metadataError) {
// Return basic info even if metadata fetch fails
return {
url: doiResponse.data.trim(),
created: new Date().toISOString(),
registered: new Date().toISOString(),
source: 'mds',
};
}
}
} catch (error) {
if (error.response?.status === 404) {
logger.debug(`DOI ${doiValue} not found in DataCite MDS`);
return null;
}
logger.debug(`DataCite MDS API failed for ${doiValue}: ${error.message}`);
}
return null;
}
/**
* Checks if a DOI exists in DataCite
*
* @param doiValue The DOI identifier
* @returns Promise<boolean> True if DOI exists
*/
public async doiExists(doiValue: string): Promise<boolean> {
const doiInfo = await this.getDoiInfo(doiValue);
return doiInfo !== null;
}
/**
* Gets the last modification date of a DOI
*
* @param doiValue The DOI identifier
* @returns Promise<Date | null> Last modification date or creation date if never updated, null if not found
*/
public async getDoiLastModified(doiValue: string): Promise<Date | null> {
const doiInfo = await this.getDoiInfo(doiValue);
if (doiInfo) {
// Use updated date if available, otherwise fall back to created/registered date
const dateToUse = doiInfo.updated || doiInfo.registered || doiInfo.created;
if (dateToUse) {
logger.debug(
`DOI ${doiValue}: Using ${doiInfo.updated ? 'updated' : doiInfo.registered ? 'registered' : 'created'} date: ${dateToUse}`,
);
return new Date(dateToUse);
}
}
return null;
}
/**
* Makes a DOI unfindable (registered but not discoverable)
* Note: DOIs cannot be deleted, only made unfindable
* await doiClient.makeDoiUnfindable('10.21388/tethys.231');
*
* @param doiValue The DOI identifier e.g. '10.5072/tethys.999'
* @returns Promise<AxiosResponse<any>> The http response
*/
public async makeDoiUnfindable(doiValue: string): Promise<AxiosResponse<any>> {
const auth = {
username: this.username,
password: this.password,
};
try {
// First, check if DOI exists
const exists = await this.doiExists(doiValue);
if (!exists) {
throw new DoiClientException(404, `DOI ${doiValue} not found`);
}
// Delete the DOI URL mapping to make it unfindable
// This removes the URL but keeps the metadata registered
const response = await axios.delete(`${this.serviceUrl}/doi/${doiValue}`, { auth });
// Response Codes for DELETE /doi/{doi}
// 200 OK: operation successful
// 401 Unauthorized: no login
// 403 Forbidden: login problem, quota exceeded
// 404 Not Found: DOI does not exist
if (response.status !== 200) {
const message = `Unexpected DataCite MDS response code ${response.status}`;
logger.error(message);
throw new DoiClientException(response.status, message);
}
logger.info(`DOI ${doiValue} successfully made unfindable`);
return response;
} catch (error) {
logger.error(`Failed to make DOI ${doiValue} unfindable: ${error.message}`);
if (error instanceof DoiClientException) {
throw error;
}
throw new DoiClientException(error.response?.status || 500, error.response?.data || error.message);
}
}
/**
* Makes a DOI findable again by re-registering the URL
* await doiClient.makeDoiFindable(
* '10.21388/tethys.231',
* 'https://doi.dev.tethys.at/10.21388/tethys.231'
* );
*
* @param doiValue The DOI identifier e.g. '10.5072/tethys.999'
* @param landingPageUrl The landing page URL
* @returns Promise<AxiosResponse<any>> The http response
*/
public async makeDoiFindable(doiValue: string, landingPageUrl: string): Promise<AxiosResponse<any>> {
const auth = {
username: this.username,
password: this.password,
};
try {
// Re-register the DOI with its URL to make it findable again
const response = await axios.put(`${this.serviceUrl}/doi/${doiValue}`, `doi=${doiValue}\nurl=${landingPageUrl}`, { auth });
// Response Codes for PUT /doi/{doi}
// 201 Created: operation successful
// 400 Bad Request: request body must be exactly two lines: DOI and URL
// 401 Unauthorized: no login
// 403 Forbidden: login problem, quota exceeded
// 412 Precondition failed: metadata must be uploaded first
if (response.status !== 201) {
const message = `Unexpected DataCite MDS response code ${response.status}`;
logger.error(message);
throw new DoiClientException(response.status, message);
}
logger.info(`DOI ${doiValue} successfully made findable again`);
return response;
} catch (error) {
logger.error(`Failed to make DOI ${doiValue} findable: ${error.message}`);
if (error instanceof DoiClientException) {
throw error;
}
throw new DoiClientException(error.response?.status || 500, error.response?.data || error.message);
}
}
/**
* Gets the current state of a DOI (draft, registered, findable)
* const state = await doiClient.getDoiState('10.21388/tethys.231');
* console.log(`Current state: ${state}`); // 'findable'
*
* @param doiValue The DOI identifier
* @returns Promise<string | null> The DOI state or null if not found
*/
public async getDoiState(doiValue: string): Promise<string | null> {
const doiInfo = await this.getDoiInfo(doiValue);
return doiInfo?.state || null;
}
}

View file

@ -1,8 +1,8 @@
import ResumptionToken from './ResumptionToken.js';
import { createClient, RedisClientType } from 'redis';
import InternalServerErrorException from '#app/exceptions/InternalServerException';
import { sprintf } from 'sprintf-js';
import dayjs from 'dayjs';
// import { sprintf } from 'sprintf-js';
// import dayjs from 'dayjs';
import TokenWorkerContract from './TokenWorkerContract.js';
export default class TokenWorkerService implements TokenWorkerContract {
@ -98,21 +98,21 @@ export default class TokenWorkerService implements TokenWorkerContract {
// return uniqueName;
// }
private async generateUniqueName(): Promise<string> {
let fc = 0;
const uniqueId = dayjs().unix().toString();
let uniqueName: string;
let cacheKeyExists: boolean;
do {
// format values
// %s - String
// %d - Signed decimal number (negative, zero or positive)
// [0-9] (Specifies the minimum width held of to the variable value)
uniqueName = sprintf('%s%05d', uniqueId, fc++);
cacheKeyExists = await this.has(uniqueName);
} while (cacheKeyExists);
return uniqueName;
}
// private async generateUniqueName(): Promise<string> {
// let fc = 0;
// const uniqueId = dayjs().unix().toString();
// let uniqueName: string;
// let cacheKeyExists: boolean;
// do {
// // format values
// // %s - String
// // %d - Signed decimal number (negative, zero or positive)
// // [0-9] (Specifies the minimum width held of to the variable value)
// uniqueName = sprintf('%s%05d', uniqueId, fc++);
// cacheKeyExists = await this.has(uniqueName);
// } while (cacheKeyExists);
// return uniqueName;
// }
public async get(key: string): Promise<ResumptionToken | null> {
if (!this.cache) {

View file

@ -2,7 +2,7 @@ import Dataset from '#models/dataset';
import { Client } from '@opensearch-project/opensearch';
import { create } from 'xmlbuilder2';
import SaxonJS from 'saxon-js';
import XmlModel from '#app/Library/XmlModel';
import DatasetXmlSerializer from '#app/Library/DatasetXmlSerializer';
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
import logger from '@adonisjs/core/services/logger';
import { readFileSync } from 'fs';
@ -17,7 +17,7 @@ interface XslTParameter {
}
export default {
// opensearchNode: process.env.OPENSEARCH_HOST || 'localhost',
client: new Client({ node: `http://${process.env.OPENSEARCH_HOST || 'localhost'}` }), // replace with your OpenSearch endpoint
client: new Client({ node: `${process.env.OPENSEARCH_HOST || 'localhost'}` }), // replace with your OpenSearch endpoint
async getDoiRegisterString(dataset: Dataset): Promise<string | undefined> {
try {
@ -72,31 +72,42 @@ export default {
}
},
/**
* Index a dataset document to OpenSearch/Elasticsearch
*/
async indexDocument(dataset: Dataset, index_name: string): Promise<void> {
try {
const proc = readFileSync('public/assets2/solr.sef.json');
const doc: string = await this.getTransformedString(dataset, proc);
// Load XSLT transformation file
const xsltProc = readFileSync('public/assets2/solr.sef.json');
let document = JSON.parse(doc);
// Transform dataset to JSON document
const jsonDoc: string = await this.getTransformedString(dataset, xsltProc);
const document = JSON.parse(jsonDoc);
// Index document to OpenSearch with doument json body
await this.client.index({
id: dataset.publish_id?.toString(),
index: index_name,
body: document,
refresh: true,
refresh: true, // make immediately searchable
});
logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`);
logger.info(`Dataset ${dataset.publish_id} successfully indexed to ${index_name}`);
} catch (error) {
logger.error(`An error occurred while indexing datsaet with publish_id ${dataset.publish_id}.`);
logger.error(`Failed to index dataset ${dataset.publish_id}: ${error.message}`);
throw error; // Re-throw to allow caller to handle
}
},
/**
* Transform dataset XML to JSON using XSLT
*/
async getTransformedString(dataset: Dataset, proc: Buffer): Promise<string> {
let xml = create({ version: '1.0', encoding: 'UTF-8', standalone: true }, '<root></root>');
const datasetNode = xml.root().ele('Dataset');
await createXmlRecord(dataset, datasetNode);
const xmlString = xml.end({ prettyPrint: false });
// Generate XML string from dataset
const xmlString = await this.generateDatasetXml(dataset);
try {
// Apply XSLT transformation
const result = await SaxonJS.transform({
stylesheetText: proc,
destination: 'serialized',
@ -108,6 +119,18 @@ export default {
return '';
}
},
/**
* Generate XML string from dataset model
*/
async generateDatasetXml(dataset: Dataset): Promise<string> {
const xml = create({ version: '1.0', encoding: 'UTF-8', standalone: true }, '<root></root>');
const datasetNode = xml.root().ele('Dataset');
await createXmlRecord(dataset, datasetNode);
return xml.end({ prettyPrint: false });
},
};
/**
* Return the default global focus trap stack
@ -115,74 +138,49 @@ export default {
* @return {import('focus-trap').FocusTrap[]}
*/
// export const indexDocument = async (dataset: Dataset, index_name: string, proc: Buffer): Promise<void> => {
// try {
// const doc = await getJsonString(dataset, proc);
// let document = JSON.parse(doc);
// await client.index({
// id: dataset.publish_id?.toString(),
// index: index_name,
// body: document,
// refresh: true,
// });
// Logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`);
// } catch (error) {
// Logger.error(`An error occurred while indexing datsaet with publish_id ${dataset.publish_id}.`);
// }
// };
// const getJsonString = async (dataset, proc): Promise<string> => {
// let xml = create({ version: '1.0', encoding: 'UTF-8', standalone: true }, '<root></root>');
// const datasetNode = xml.root().ele('Dataset');
// await createXmlRecord(dataset, datasetNode);
// const xmlString = xml.end({ prettyPrint: false });
// try {
// const result = await transform({
// stylesheetText: proc,
// destination: 'serialized',
// sourceText: xmlString,
// });
// return result.principalResult;
// } catch (error) {
// Logger.error(`An error occurred while creating the user, error: ${error.message},`);
// return '';
// }
// };
/**
* Create complete XML record for dataset
* Handles caching and metadata enrichment
*/
const createXmlRecord = async (dataset: Dataset, datasetNode: XMLBuilder): Promise<void> => {
const domNode = await getDatasetXmlDomNode(dataset);
if (domNode) {
// add frontdoor url and data-type
dataset.publish_id && addLandingPageAttribute(domNode, dataset.publish_id.toString());
addSpecInformation(domNode, 'data-type:' + dataset.type);
if (dataset.collections) {
for (const coll of dataset.collections) {
const collRole = coll.collectionRole;
addSpecInformation(domNode, collRole.oai_name + ':' + coll.number);
}
}
datasetNode.import(domNode);
if (!domNode) {
throw new Error(`Failed to generate XML DOM node for dataset ${dataset.id}`);
}
// Enrich with landing page URL
if (dataset.publish_id) {
addLandingPageAttribute(domNode, dataset.publish_id.toString());
}
// Add data type specification
addSpecInformation(domNode, `data-type:${dataset.type}`);
// Add collection information
if (dataset.collections) {
for (const coll of dataset.collections) {
const collRole = coll.collectionRole;
addSpecInformation(domNode, `${collRole.oai_name}:${coll.number}`);
}
}
datasetNode.import(domNode);
};
const getDatasetXmlDomNode = async (dataset: Dataset): Promise<XMLBuilder | null> => {
const xmlModel = new XmlModel(dataset);
const serializer = new DatasetXmlSerializer(dataset).enableCaching().excludeEmptyFields();
// xmlModel.setModel(dataset);
xmlModel.excludeEmptyFields();
xmlModel.caching = true;
// const cache = dataset.xmlCache ? dataset.xmlCache : null;
// dataset.load('xmlCache');
// Load cache relationship if not already loaded
await dataset.load('xmlCache');
if (dataset.xmlCache) {
xmlModel.xmlCache = dataset.xmlCache;
serializer.setCache(dataset.xmlCache);
}
// return cache.getDomDocument();
const domDocument: XMLBuilder | null = await xmlModel.getDomDocument();
return domDocument;
// Generate or retrieve cached DOM document
const xmlDocument: XMLBuilder | null = await serializer.toXmlDocument();
return xmlDocument;
};
const addLandingPageAttribute = (domNode: XMLBuilder, dataid: string) => {
@ -192,6 +190,6 @@ const addLandingPageAttribute = (domNode: XMLBuilder, dataid: string) => {
domNode.att('landingpage', url);
};
const addSpecInformation= (domNode: XMLBuilder, information: string) => {
const addSpecInformation = (domNode: XMLBuilder, information: string) => {
domNode.ele('SetSpec').att('Value', information);
};
};

View file

@ -1,129 +0,0 @@
import DocumentXmlCache from '#models/DocumentXmlCache';
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
import Dataset from '#models/dataset';
import Strategy from './Strategy.js';
import { DateTime } from 'luxon';
import { builder } from 'xmlbuilder2';
/**
* This is the description of the interface
*
* @interface Conf
* @member {Model} model holds the current dataset model
* @member {XMLBuilder} dom holds the current DOM representation
* @member {Array<string>} excludeFields List of fields to skip on serialization.
* @member {boolean} excludeEmpty True, if empty fields get excluded from serialization.
* @member {string} baseUri Base URI for xlink:ref elements
*/
export interface Conf {
model: Dataset;
dom?: XMLBuilder;
excludeFields: Array<string>;
excludeEmpty: boolean;
baseUri: string;
}
export default class XmlModel {
private config: Conf;
// private strategy = null;
private cache: DocumentXmlCache | null = null;
private _caching = false;
private strategy: Strategy;
constructor(dataset: Dataset) {
// $this->strategy = new Strategy();// Opus_Model_Xml_Version1;
// $this->config = new Conf();
// $this->strategy->setup($this->config);
this.config = {
excludeEmpty: false,
baseUri: '',
excludeFields: [],
model: dataset,
};
this.strategy = new Strategy({
excludeEmpty: true,
baseUri: '',
excludeFields: [],
model: dataset,
});
}
set model(model: Dataset) {
this.config.model = model;
}
public excludeEmptyFields(): void {
this.config.excludeEmpty = true;
}
get xmlCache(): DocumentXmlCache | null {
return this.cache;
}
set xmlCache(cache: DocumentXmlCache) {
this.cache = cache;
}
get caching(): boolean {
return this._caching;
}
set caching(caching: boolean) {
this._caching = caching;
}
public async getDomDocument(): Promise<XMLBuilder | null> {
const dataset = this.config.model;
let domDocument: XMLBuilder | null = await this.getDomDocumentFromXmlCache();
if (domDocument == null) {
domDocument = await this.strategy.createDomDocument();
// domDocument = create({ version: '1.0', encoding: 'UTF-8', standalone: true }, '<root></root>');
if (this._caching) {
// caching is desired:
this.cache = this.cache || new DocumentXmlCache();
this.cache.document_id = dataset.id;
this.cache.xml_version = 1; // (int)$this->strategy->getVersion();
this.cache.server_date_modified = dataset.server_date_modified.toFormat('yyyy-MM-dd HH:mm:ss');
this.cache.xml_data = domDocument.end();
await this.cache.save();
}
const node = domDocument.find(
(n) => {
const test = n.node.nodeName == 'Rdr_Dataset';
return test;
},
false,
true,
)?.node;
if (node != undefined) {
domDocument = builder({ version: '1.0', encoding: 'UTF-8', standalone: true }, node);
}
}
return domDocument;
}
private async getDomDocumentFromXmlCache(): Promise<XMLBuilder | null> {
const dataset: Dataset = this.config.model;
if (!this.cache) {
return null;
}
//.toFormat('YYYY-MM-DD HH:mm:ss');
let date: DateTime = dataset.server_date_modified;
const actuallyCached: boolean = await DocumentXmlCache.hasValidEntry(dataset.id, date);
if (!actuallyCached) {
return null;
}
//cache is actual return it for oai:
try {
if (this.cache) {
return this.cache.getDomDocument();
} else {
return null;
}
} catch (error) {
return null;
}
}
}

View file

@ -0,0 +1,43 @@
// app/controllers/activities_controller.ts
import type { HttpContext } from '@adonisjs/core/http';
import Activity from '#models/activity';
export default class ActivitiesController {
async index({ response }: HttpContext) {
// const activities = await Activity.query()
// .preload('user', (q) => q.select('id', 'login'))
// .orderBy('created_at', 'desc')
// .limit(10);
// return response.json(
// activities.map((a) => ({
// id: a.id,
// type: a.type,
// description: a.description,
// user: a.user?.login ?? null,
// created_at: a.createdAt.toISO(), // relativeTime() expects ISO
// })),
// );
try {
const activities = await Activity.query()
.preload('user', (q) => q.select('id', 'login'))
.orderBy('created_at', 'desc')
.limit(10);
return response.json(
activities.map((a) => ({
id: a.id,
type: a.type,
description: a.description,
user: a.user?.login ?? null,
created_at: a.createdAt.toISO(), // relativeTime() expects ISO
})),
);
} catch (error) {
console.error('Error fetching activities:', error);
return response.status(500).json({ error: 'Internal Server Error' });
}
}
}

View file

@ -0,0 +1,54 @@
// app/controllers/projects_controller.ts
import Project from '#models/project';
import type { HttpContext } from '@adonisjs/core/http';
import { createProjectValidator, updateProjectValidator } from '#validators/project';
export default class ProjectsController {
// GET /settings/projects
public async index({ inertia, auth }: HttpContext) {
const projects = await Project.all();
// return inertia.render('Admin/Project/Index', { projects });
return inertia.render('Admin/Project/Index', {
projects: projects,
can: {
edit: await auth.user?.can(['settings']),
create: await auth.user?.can(['settings']),
},
});
}
// GET /settings/projects/create
public async create({ inertia }: HttpContext) {
return inertia.render('Admin/Project/Create');
}
// POST /settings/projects
public async store({ request, response, session }: HttpContext) {
// Validate the request data
const data = await request.validateUsing(createProjectValidator);
await Project.create(data);
session.flash('success', 'Project created successfully');
return response.redirect().toRoute('settings.project.index');
}
// GET /settings/projects/:id/edit
public async edit({ params, inertia }: HttpContext) {
const project = await Project.findOrFail(params.id);
return inertia.render('Admin/Project/Edit', { project });
}
// PUT /settings/projects/:id
public async update({ params, request, response, session }: HttpContext) {
const project = await Project.findOrFail(params.id);
// Validate the request data
const data = await request.validateUsing(updateProjectValidator);
await project.merge(data).save();
session.flash('success', 'Project updated successfully');
return response.redirect().toRoute('settings.project.index');
}
}

View file

@ -0,0 +1,43 @@
// import { Exception } from '@adonisjs/core/exceptions'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http';
export default class DbHandlerException extends ExceptionHandler {
// constructor() {
// super(Logger)
// }
async handle(error: any, ctx: HttpContext) {
// Check for AggregateError type
if (error.type === 'AggregateError' && error.aggregateErrors) {
const dbErrors = error.aggregateErrors.some((err: any) => err.code === 'ECONNREFUSED' && err.port === 5432);
if (dbErrors) {
return ctx.response.status(503).json({
status: 'error',
message: 'PostgreSQL database connection failed. Please ensure the database service is running.',
details: {
code: error.code,
type: error.type,
ports: error.aggregateErrors.map((err: any) => ({
port: err.port,
address: err.address,
})),
},
});
}
}
// Handle simple ECONNREFUSED errors
if (error.code === 'ECONNREFUSED') {
return ctx.response.status(503).json({
status: 'error',
message: 'Database connection failed. Please ensure PostgreSQL is running.',
code: error.code,
});
}
return super.handle(error, ctx);
}
static status = 500;
}

View file

@ -1,125 +1,53 @@
/*
|--------------------------------------------------------------------------
| Http Exception Handler
|--------------------------------------------------------------------------
|
| AdonisJs will forward all exceptions occurred during an HTTP request to
| the following class. You can learn more about exception handling by
| reading docs.
|
| The exception handler extends a base `HttpExceptionHandler` which is not
| mandatory, however it can do lot of heavy lifting to handle the errors
| properly.
|
*/
import app from '@adonisjs/core/services/app';
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http';
// import logger from '@adonisjs/core/services/logger';
import type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http';
import app from '@adonisjs/core/services/app'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
import type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http'
export default class HttpExceptionHandler extends ExceptionHandler {
/**
* In debug mode, the exception handler will display verbose errors
* with pretty printed stack traces.
*/
protected debug = !app.inProduction;
protected debug = !app.inProduction
protected renderStatusPages = true
/**
* Status pages are used to display a custom HTML pages for certain error
* codes. You might want to enable them in production only, but feel
* free to enable them in development as well.
*/
protected renderStatusPages = true; //app.inProduction;
protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {
'404': (error, ctx) =>
ctx.inertia
? ctx.inertia.render('Errors/ServerError', { error: error.message, code: error.status })
: ctx.response.status(error.status).send(error.message),
'401..403': (error, ctx) => {
if (ctx.inertia) {
return ctx.inertia.render('Errors/ServerError', { error: error.message, code: error.status });
}
return ctx.response.status(error.status).send(error.message);
},
'500..599': (error, ctx) => {
const isDbError =
error.code === 'ECONNREFUSED' &&
(error.errors?.some((e: any) => e.port === 5432) ?? error.message?.includes('5432'));
/**
* Status pages is a collection of error code range and a callback
* to return the HTML contents to send as a response.
*/
// protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {
// '401..403': (error, { view }) => {
// return view.render('./errors/unauthorized', { error });
// },
// '404': (error, { view }) => {
// return view.render('./errors/not-found', { error });
// },
// '500..599': (error, { view }) => {
// return view.render('./errors/server-error', { error });
// },
// };
protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {
'404': (error, { inertia }) => {
return inertia.render('Errors/ServerError', {
error: error.message,
code: error.status,
});
},
'401..403': async (error, { inertia }) => {
// session.flash('errors', error.message);
return inertia.render('Errors/ServerError', {
error: error.message,
code: error.status,
});
},
'500..599': (error, { inertia }) => inertia.render('Errors/ServerError', { error: error.message, code: error.status }),
};
if (isDbError && ctx.inertia) {
return ctx.inertia.render('Errors/postgres_error', {
status: 'error',
message: 'PostgreSQL database connection failed.',
details: {
code: error.code,
type: error.status
// Entferne das .map() auf error.errors, da es oft undefined ist
}
});
}
// constructor() {
// super(logger);
// }
public async handle(error: any, ctx: HttpContext) {
const { response, request, session } = ctx;
/**
* Handle failed authentication attempt
*/
// if (['E_INVALID_AUTH_PASSWORD', 'E_INVALID_AUTH_UID'].includes(error.code)) {
// session.flash('errors', { login: error.message });
// return response.redirect('/login');
// }
// if ([401].includes(error.status)) {
// session.flash('errors', { login: error.message });
// return response.redirect('/dashboard');
// }
// https://github.com/inertiajs/inertia-laravel/issues/56
// let test = response.getStatus(); //200
// let header = request.header('X-Inertia'); // true
// if (request.header('X-Inertia') && [500, 503, 404, 403, 401, 200].includes(response.getStatus())) {
if (request.header('X-Inertia') && [422].includes(error.status)) {
// session.flash('errors', error.messages.errors);
session.flash('errors', error.messages);
return response.redirect().back();
// return inertia.render('errors/server_error', {
// return inertia.render('errors/server_error', {
// // status: response.getStatus(),
// error: error,
// });
// ->toResponse($request)
// ->setStatusCode($response->status());
}
// Dynamically change the error templates based on the absence of X-Inertia header
// if (!ctx.request.header('X-Inertia')) {
// this.statusPages = {
// '401..403': (error, { view }) => view.render('./errors/unauthorized', { error }),
// '404': (error, { view }) => view.render('./errors/not-found', { error }),
// '500..599': (error, { view }) => view.render('./errors/server-error', { error }),
// };
// }
/**
* Forward rest of the exceptions to the parent class
*/
return super.handle(error, ctx);
if (ctx.inertia) {
return ctx.inertia.render('Errors/ServerError', { error: error.message, code: 500 });
}
return ctx.response.status(500).send(error.message);
}
};
public async handle(error: any, ctx: HttpContext) {
/**
* The method is used to report error to the logging service or
* the a third party error monitoring service.
*
* @note You should not attempt to send a response from this method.
* WICHTIG: Validierungsfehler (422) NICHT manuell abfangen!
* AdonisJS 6 + VineJS + Inertia machen das automatisch.
* Wenn du es hier manuell machst, überschreibst du den Standard-Flow.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx);
}
}
return super.handle(error, ctx)
}
}

View file

@ -4,7 +4,8 @@ import { builder, create } from 'xmlbuilder2';
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
import db from '@adonisjs/lucid/services/db';
import { DateTime } from 'luxon';
import type { BelongsTo } from "@adonisjs/lucid/types/relations";
import type { BelongsTo } from '@adonisjs/lucid/types/relations';
import logger from '@adonisjs/core/services/logger';
export default class DocumentXmlCache extends BaseModel {
public static namingStrategy = new SnakeCaseNamingStrategy();
@ -66,33 +67,38 @@ export default class DocumentXmlCache extends BaseModel {
}
/**
* Check if a dataset in a specific xml version is already cached or not.
* Check if a valid (non-stale) cache entry exists
* Cache is valid only if it was created AFTER the dataset's last modification
*
* @param mixed datasetId
* @param mixed serverDateModified
* @returns {Promise<boolean>} Returns true on cached hit else false.
* @param datasetId - The dataset ID to check
* @param datasetServerDateModified - The dataset's last modification timestamp
* @returns true if valid cache exists, false otherwise
*/
// public static async hasValidEntry(datasetId: number, datasetServerDateModified: DateTime): Promise<boolean> {
// // const formattedDate = dayjs(datasetServerDateModified).format('YYYY-MM-DD HH:mm:ss');
// const query = Database.from(this.table)
// .where('document_id', datasetId)
// .where('server_date_modified', '2023-08-17 16:51:03')
// .first();
// const row = await query;
// return !!row;
// }
// Assuming 'DocumentXmlCache' has a table with a 'server_date_modified' column in your database
public static async hasValidEntry(datasetId: number, datasetServerDateModified: DateTime): Promise<boolean> {
const serverDateModifiedString: string = datasetServerDateModified.toFormat('yyyy-MM-dd HH:mm:ss'); // Convert DateTime to ISO string
const query = db.from(this.table)
const row = await db
.from(this.table)
.where('document_id', datasetId)
.where('server_date_modified', '>=', serverDateModifiedString) // Check if server_date_modified is newer or equal
.where('server_date_modified', '>', serverDateModifiedString) // Check if server_date_modified is newer or equal
.first();
const row = await query;
return !!row;
const isValid = !!row;
if (isValid) {
logger.debug(`Valid cache found for dataset ${datasetId}`);
} else {
logger.debug(`No valid cache for dataset ${datasetId} (dataset modified: ${serverDateModifiedString})`);
}
return isValid;
}
/**
* Invalidate (delete) cache entry
*/
public async invalidate(): Promise<void> {
await this.delete();
logger.debug(`Invalidated cache for document ${this.document_id}`);
}
}

57
app/models/activity.ts Normal file
View file

@ -0,0 +1,57 @@
// app/models/activity.ts
import { DateTime } from 'luxon';
import { belongsTo, column } from '@adonisjs/lucid/orm';
import BaseModel from './base_model.js';
import type { BelongsTo } from '@adonisjs/lucid/types/relations';
import User from '#models/user';
import { SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm';
export default class Activity extends BaseModel {
public static namingStrategy = new SnakeCaseNamingStrategy();
public static primaryKey = 'id';
public static table = 'activities';
@column({ isPrimary: true })
declare id: number;
@column()
declare type: string;
@column()
declare userId: number | null;
@column()
declare subjectType: string | null;
@column()
declare subjectId: number | null;
@column()
declare description: string;
// Manual JSON (de)serialization keeps this working on SQLite/MySQL.
// On Postgres json/jsonb the driver already parses — drop the `consume`
// JSON.parse there to avoid double-handling.
// @column({
// prepare: (value: Record<string, any> | null) => (value ? JSON.stringify(value) : null),
// consume: (value: string | null) => (value ? JSON.parse(value) : null),
// })
// declare properties: Record<string, any> | null;
@column()
declare properties: Record<string, any> | null;
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime;
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime;
// @belongsTo(() => User)
// declare user: BelongsTo<typeof User>;
@belongsTo(() => User, {
foreignKey: 'userId',
})
declare user: BelongsTo<typeof User>;
}

View file

@ -5,7 +5,10 @@ import {
belongsTo,
hasMany,
computed,
hasOne
hasOne,
afterCreate,
beforeUpdate,
afterUpdate,
} from '@adonisjs/lucid/orm';
import { DateTime } from 'luxon';
import dayjs from 'dayjs';
@ -23,10 +26,11 @@ import DatasetIdentifier from './dataset_identifier.js';
import Project from './project.js';
import DocumentXmlCache from './DocumentXmlCache.js';
import DatasetExtension from '#models/traits/dataset_extension';
import type { ManyToMany } from "@adonisjs/lucid/types/relations";
import type { BelongsTo } from "@adonisjs/lucid/types/relations";
import type { HasMany } from "@adonisjs/lucid/types/relations";
import type { HasOne } from "@adonisjs/lucid/types/relations";
import type { ManyToMany } from '@adonisjs/lucid/types/relations';
import type { BelongsTo } from '@adonisjs/lucid/types/relations';
import type { HasMany } from '@adonisjs/lucid/types/relations';
import type { HasOne } from '@adonisjs/lucid/types/relations';
import ActivityLogger from '#services/activity_logger';
export default class Dataset extends DatasetExtension {
public static namingStrategy = new SnakeCaseNamingStrategy();
@ -46,7 +50,7 @@ export default class Dataset extends DatasetExtension {
@column({ columnName: 'creating_corporation' })
public creating_corporation: string;
@column.dateTime({
@column.dateTime({
columnName: 'embargo_date',
serialize: (value: Date | null) => {
return value ? dayjs(value).format('YYYY-MM-DD') : value;
@ -60,7 +64,7 @@ export default class Dataset extends DatasetExtension {
@column({})
public language: string;
@column({columnName: 'publish_id'})
@column({ columnName: 'publish_id' })
public publish_id: number | null = null;
@column({})
@ -266,10 +270,12 @@ export default class Dataset extends DatasetExtension {
return model || null;
}
static async getMax (column: string) {
let dataset = await this.query().max(column + ' as max_publish_id').firstOrFail();
static async getMax(column: string) {
let dataset = await this.query()
.max(column + ' as max_publish_id')
.firstOrFail();
return dataset.$extras.max_publish_id;
}
}
@computed({
serializeAs: 'remaining_time',
@ -284,4 +290,34 @@ export default class Dataset extends DatasetExtension {
return 0;
}
}
// @afterCreate()
// static async logUploaded(dataset: Dataset) {
// await dataset.preload('titles');
// await ActivityLogger.log({
// type: 'dataset.uploaded',
// description: `New publication uploaded: ${dataset.mainTitle ?? 'Untitled'}`,
// subjectType: 'Dataset',
// subjectId: dataset.id,
// });
// }
@beforeUpdate()
static capturePublish(dataset: Dataset) {
// $dirty is populated here, before persistence
(dataset as any).$becamePublished = dataset.$dirty.status !== undefined && dataset.status === 'published';
}
@afterUpdate()
static async logPublished(dataset: Dataset) {
if ((dataset as any).$becamePublished) {
await ActivityLogger.log({
type: 'dataset.published',
description: `Publication published: ${dataset.mainTitle}`,
subjectType: 'Dataset',
subjectId: dataset.id,
});
}
}
}

View file

@ -3,7 +3,7 @@ import { DateTime } from 'luxon';
import dayjs from 'dayjs';
import Dataset from './dataset.js';
import BaseModel from './base_model.js';
import type { ManyToMany } from "@adonisjs/lucid/types/relations";
import type { ManyToMany } from '@adonisjs/lucid/types/relations';
export default class Person extends BaseModel {
public static namingStrategy = new SnakeCaseNamingStrategy();
@ -30,7 +30,7 @@ export default class Person extends BaseModel {
@column({})
public lastName: string;
@column({})
@column({ columnName: 'identifier_orcid' })
public identifierOrcid: string;
@column({})
@ -64,9 +64,8 @@ export default class Person extends BaseModel {
// return '2023-03-21 08:45:00';
// }
@computed({
serializeAs: 'dataset_count',
serializeAs: 'dataset_count',
})
public get datasetCount() {
const stock = this.$extras.datasets_count; //my pivot column name was "stock"
@ -79,6 +78,16 @@ export default class Person extends BaseModel {
return contributor_type;
}
@computed({ serializeAs: 'allow_email_contact' })
public get allowEmailContact() {
// If the datasets relation is missing or empty, return false instead of null.
if (!this.datasets || this.datasets.length === 0) {
return false;
}
// Otherwise return the pivot attribute from the first related dataset.
return this.datasets[0].$extras?.pivot_allow_email_contact;
}
@manyToMany(() => Dataset, {
pivotForeignKey: 'person_id',
pivotRelatedForeignKey: 'document_id',
@ -86,4 +95,34 @@ export default class Person extends BaseModel {
pivotColumns: ['role', 'sort_order', 'allow_email_contact'],
})
public datasets: ManyToMany<typeof Dataset>;
// public toJSON() {
// const json = super.toJSON();
// // Check if this person is loaded through a pivot relationship with sensitive roles
// const pivotRole = this.$extras?.pivot_role;
// if (pivotRole === 'author' || pivotRole === 'contributor') {
// // Remove sensitive information for public-facing roles
// delete json.email;
// // delete json.identifierOrcid;
// }
// return json;
// }
// @afterFind()
// public static async afterFindHook(person: Person) {
// if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
// person.email = undefined as any;
// }
// }
// @afterFetch()
// public static async afterFetchHook(persons: Person[]) {
// persons.forEach(person => {
// if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
// person.email = undefined as any;
// }
// });
// }
}

57
app/models/types.ts Normal file
View file

@ -0,0 +1,57 @@
/**
* Qs module config
*/
type QueryStringConfig = {
depth?: number
allowPrototypes?: boolean
plainObjects?: boolean
parameterLimit?: number
arrayLimit?: number
ignoreQueryPrefix?: boolean
delimiter?: RegExp | string
allowDots?: boolean
charset?: 'utf-8' | 'iso-8859-1' | undefined
charsetSentinel?: boolean
interpretNumericEntities?: boolean
parseArrays?: boolean
comma?: boolean
}
/**
* Base config used by all types
*/
type BodyParserBaseConfig = {
encoding: string
limit: string | number
types: string[]
}
/**
* Body parser config for parsing JSON requests
*/
export type BodyParserJSONConfig = BodyParserBaseConfig & {
strict: boolean
convertEmptyStringsToNull: boolean
}
/**
* Parser config for parsing form data
*/
export type BodyParserFormConfig = BodyParserBaseConfig & {
queryString: QueryStringConfig
convertEmptyStringsToNull: boolean
}
/**
* Parser config for parsing raw body (untouched)
*/
export type BodyParserRawConfig = BodyParserBaseConfig
/**
* Body parser config for all supported form types
*/
export type BodyParserConfig = {
allowedMethods: string[]
json: BodyParserJSONConfig
form: BodyParserFormConfig
raw: BodyParserRawConfig
multipart: BodyParserMultipartConfig
}

View file

@ -14,6 +14,7 @@ import type { ManyToMany } from '@adonisjs/lucid/types/relations';
import type { HasMany } from '@adonisjs/lucid/types/relations';
import { compose } from '@adonisjs/core/helpers';
import BackupCode from './backup_code.js';
import Activity from './activity.js';
const AuthFinder = withAuthFinder(() => hash.use('laravel'), {
uids: ['email'],
@ -89,24 +90,11 @@ export default class User extends compose(BaseModel, AuthFinder) {
@column({})
public avatar: string;
// @hasOne(() => TotpSecret, {
// foreignKey: 'user_id',
// })
// public totp_secret: HasOne<typeof TotpSecret>;
// @beforeSave()
// public static async hashPassword(user: User) {
// if (user.$dirty.password) {
// user.password = await hash.use('laravel').make(user.password);
// }
// }
public get isTwoFactorEnabled(): boolean {
return Boolean(this?.twoFactorSecret && this.state == TotpState.STATE_ENABLED);
// return Boolean(this.totp_secret?.twoFactorSecret);
}
@manyToMany(() => Role, {
pivotForeignKey: 'account_id',
pivotRelatedForeignKey: 'role_id',
@ -124,6 +112,11 @@ export default class User extends compose(BaseModel, AuthFinder) {
})
public backupcodes: HasMany<typeof BackupCode>;
@hasMany(() => Activity, {
foreignKey: 'user_id',
})
public activities: HasMany<typeof Activity>;
@computed({
serializeAs: 'is_admin',
})
@ -142,7 +135,9 @@ export default class User extends compose(BaseModel, AuthFinder) {
@beforeFind()
@beforeFetch()
public static preloadRoles(user: User) {
user.preload('roles')
user.preload('roles', (builder) => {
builder.select(['id', 'name', 'display_name', 'description']);
});
}
public async getBackupCodes(this: User): Promise<BackupCode[]> {

View file

@ -0,0 +1,27 @@
// app/services/activity_logger.ts
import Activity from '#models/activity'
interface LogOptions {
type: string
description: string
userId?: number | null
subjectType?: string | null
subjectId?: number | string | null
properties?: Record<string, any>
}
export default class ActivityLogger {
static async log(options: LogOptions): Promise<void> {
await Activity.create({
type: options.type,
description: options.description,
userId: options.userId ?? null,
subjectType: options.subjectType ?? null,
subjectId: options.subjectId != null ? Number(options.subjectId) : null,
properties: options.properties ?? null,
})
// Invalidate the cache if you add one (see Redis section).
// await redis.del('activities:recent')
}
}

View file

@ -0,0 +1,54 @@
import app from '@adonisjs/core/services/app'
import { errors } from '@vinejs/vine'
/**
* The ValidationService handles manual construction of validation errors
* that are compatible with the VanillaErrorReporter and AdonisJS Session.
*/
export class ValidationService {
/**
* Builds a validation error in the array-of-objects format without throwing it.
* Use this when you need the error object itself, e.g. multipart.abort(error).
*/
make(field: string, message: string, rule: string = 'manual') {
return new errors.E_VALIDATION_ERROR([
{
field,
message,
rule,
},
])
}
/**
* Throws a manual validation error in the array-of-objects format
* which prevents the ".reduce is not a function" error in the session.
*/
throw(field: string, message: string, rule: string = 'manual') {
throw this.make(field, message, rule)
}
/**
* Throws multiple manual validation errors at once.
*/
throwMany(errorObjects: Array<{ field: string; message: string; rule?: string }>) {
throw new errors.E_VALIDATION_ERROR(
errorObjects.map((err) => ({
field: err.field,
message: err.message,
rule: err.rule || 'manual',
}))
)
}
}
/**
* Initialize and export the singleton instance
*/
let validation: ValidationService
await app.booted(async () => {
validation = await app.container.make(ValidationService)
})
export { validation as default }

View file

@ -1,3 +1,16 @@
import { join, isAbsolute } from 'node:path';
import type { BodyParserConfig } from '#models/types';
import { createId } from '@paralleldrive/cuid2';
import { tmpdir } from 'node:os';
import config from '@adonisjs/core/services/config';
import Dataset from '#models/dataset';
import { TransactionClientContract } from '@adonisjs/lucid/types/database';
import Person from '#models/person';
interface Dictionary {
[index: string]: string;
}
export function sum(a: number, b: number): number {
return a + b;
}
@ -24,3 +37,93 @@ export function preg_match(regex: RegExp, str: string) {
const result: boolean = regex.test(str);
return result;
}
/**
* Returns the tmp path for storing the files temporarly
*/
export function getTmpPath(config: BodyParserConfig['multipart']): string {
if (typeof config.tmpFileName === 'function') {
const tmpPath = config.tmpFileName();
return isAbsolute(tmpPath) ? tmpPath : join(tmpdir(), tmpPath);
}
return join(tmpdir(), createId());
}
/**
* Returns config for a given type
*/
export function getConfigFor<K extends keyof BodyParserConfig>(type: K): BodyParserConfig[K] {
const bodyParserConfig: BodyParserConfig = config.get('bodyparser');
const configType = bodyParserConfig[type];
return configType;
}
export function parseBytesSize(size: string): number {
const units: Record<string, number> = {
kb: 1024,
mb: 1024 * 1024,
gb: 1024 * 1024 * 1024,
tb: 1024 * 1024 * 1024 * 1024,
};
const match = size.match(/^(\d+)(kb|mb|gb|tb)$/i); // Regex to match size format
if (!match) {
throw new Error('Invalid size format');
}
const [, value, unit] = match;
return parseInt(value) * units[unit.toLowerCase()];
}
// Helper function to format bytes as human-readable text
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export async function savePersons(dataset: Dataset, persons: any[], role: string, trx: TransactionClientContract) {
for (const [key, person] of persons.entries()) {
const pivotData = {
role: role,
sort_order: key + 1,
allow_email_contact: false,
...extractPivotAttributes(person), // Merge pivot attributes here
};
if (person.id !== undefined) {
await dataset
.useTransaction(trx)
.related('persons')
.attach({
[person.id]: pivotData,
});
} else {
const dataPerson = new Person();
dataPerson.fill(person);
await dataset.useTransaction(trx).related('persons').save(dataPerson, false, pivotData);
}
}
}
// Helper function to extract pivot attributes from a person object
function extractPivotAttributes(person: any) {
const pivotAttributes: Dictionary = {};
for (const key in person) {
if (key.startsWith('pivot_')) {
// pivotAttributes[key] = person[key];
const cleanKey = key.replace('pivot_', ''); // Remove 'pivot_' prefix
pivotAttributes[cleanKey] = person[key];
}
}
return pivotAttributes;
}
// in #app/utils/utility-functions
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -40,7 +40,8 @@ export const createDatasetValidator = vine.compile(
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.minLength(1),
// .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine
.array(
vine.object({
@ -54,7 +55,8 @@ export const createDatasetValidator = vine.compile(
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.minLength(1),
// .minLength(1),
.arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine
.array(
vine.object({
@ -65,8 +67,9 @@ export const createDatasetValidator = vine.compile(
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}),
)
.minLength(1)
@ -81,9 +84,10 @@ export const createDatasetValidator = vine.compile(
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}),
)
.distinct('email')
@ -187,7 +191,8 @@ export const updateDatasetValidator = vine.compile(
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.minLength(1),
// .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine
.array(
vine.object({
@ -201,7 +206,7 @@ export const updateDatasetValidator = vine.compile(
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.minLength(1),
.arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine
.array(
vine.object({
@ -212,8 +217,9 @@ export const updateDatasetValidator = vine.compile(
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}),
)
.minLength(1)
@ -228,8 +234,9 @@ export const updateDatasetValidator = vine.compile(
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
}),
)
@ -303,21 +310,149 @@ export const updateDatasetValidator = vine.compile(
.fileScan({ removeInfected: true }),
)
.dependentArrayMinLength({ dependentArray: 'fileInputs', min: 1 }),
fileInputs: vine.array(
vine.object({
label: vine.string().trim().maxLength(100),
//extnames: extensions,
}),
),
fileInputs: vine
.array(
vine.object({
label: vine.string().trim().maxLength(100),
}),
)
.optional(),
}),
);
// files: schema.array([rules.minLength(1)]).members(
// schema.file({
// size: '512mb',
// extnames: ['jpg', 'gif', 'png', 'tif', 'pdf', 'zip', 'fgb', 'nc', 'qml', 'ovr', 'gpkg', 'gml', 'gpx', 'kml', 'kmz', 'json'],
// }),
// ),
export const updateEditorDatasetValidator = vine.compile(
vine.object({
// first step
language: vine
.string()
.trim()
.regex(/^[a-zA-Z0-9]+$/),
licenses: vine.array(vine.number()).minLength(1), // define at least one license for the new dataset
rights: vine.string().in(['true']),
// second step
type: vine.string().trim().minLength(3).maxLength(255),
creating_corporation: vine.string().trim().minLength(3).maxLength(255),
titles: vine
.array(
vine.object({
value: vine.string().trim().minLength(3).maxLength(255),
type: vine.enum(Object.values(TitleTypes)),
language: vine
.string()
.trim()
.minLength(2)
.maxLength(255)
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
// .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine
.array(
vine.object({
value: vine.string().trim().minLength(3).maxLength(2500),
type: vine.enum(Object.values(DescriptionTypes)),
language: vine
.string()
.trim()
.minLength(2)
.maxLength(255)
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine
.array(
vine.object({
email: vine
.string()
.trim()
.maxLength(255)
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
}),
)
.minLength(1)
.distinct('email'),
contributors: vine
.array(
vine.object({
email: vine
.string()
.trim()
.maxLength(255)
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
last_name: vine.string().trim().minLength(3).maxLength(255),
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
}),
)
.distinct('email')
.optional(),
// third step
project_id: vine.number().optional(),
// embargo_date: schema.date.optional({ format: 'yyyy-MM-dd' }, [rules.after(10, 'days')]),
embargo_date: vine
.date({
formats: ['YYYY-MM-DD'],
})
.afterOrEqual((_field) => {
return dayjs().add(10, 'day').format('YYYY-MM-DD');
})
.optional(),
coverage: vine.object({
x_min: vine.number(),
x_max: vine.number(),
y_min: vine.number(),
y_max: vine.number(),
elevation_absolut: vine.number().positive().optional(),
elevation_min: vine.number().positive().optional().requiredIfExists('elevation_max'),
elevation_max: vine.number().positive().optional().requiredIfExists('elevation_min'),
// type: vine.enum(Object.values(DescriptionTypes)),
depth_absolut: vine.number().negative().optional(),
depth_min: vine.number().negative().optional().requiredIfExists('depth_max'),
depth_max: vine.number().negative().optional().requiredIfExists('depth_min'),
time_abolute: vine.date({ formats: { utc: true } }).optional(),
time_min: vine
.date({ formats: { utc: true } })
.beforeField('time_max')
.optional()
.requiredIfExists('time_max'),
time_max: vine
.date({ formats: { utc: true } })
.afterField('time_min')
.optional()
.requiredIfExists('time_min'),
}),
references: vine
.array(
vine.object({
value: vine.string().trim().minLength(3).maxLength(255).validateReference({ typeField: 'type' }),
type: vine.enum(Object.values(ReferenceIdentifierTypes)),
relation: vine.enum(Object.values(RelationTypes)),
label: vine.string().trim().minLength(2).maxLength(255),
}),
)
.optional(),
subjects: vine
.array(
vine.object({
value: vine.string().trim().minLength(3).maxLength(255),
// pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
language: vine.string().trim().minLength(2).maxLength(255),
}),
)
.minLength(3)
.distinct('value'),
}),
);
let messagesProvider = new SimpleMessagesProvider({
'minLength': '{{ field }} must be at least {{ min }} characters long',
@ -369,8 +504,10 @@ let messagesProvider = new SimpleMessagesProvider({
'files.array.minLength': 'At least {{ min }} file upload is required.',
'files.*.size': 'file size is to big',
'files.*.extnames': 'file extension is not supported',
'embargo_date.date.afterOrEqual': `Embargo date must be on or after ${dayjs().add(10, 'day').format('DD.MM.YYYY')}`,
});
createDatasetValidator.messagesProvider = messagesProvider;
updateDatasetValidator.messagesProvider = messagesProvider;
updateEditorDatasetValidator.messagesProvider = messagesProvider;
// export default createDatasetValidator;

28
app/validators/project.ts Normal file
View file

@ -0,0 +1,28 @@
// app/validators/project.ts
import vine from '@vinejs/vine';
export const createProjectValidator = vine.compile(
vine.object({
label: vine.string().trim().minLength(1).maxLength(50) .regex(/^[a-z0-9-]+$/),
name: vine
.string()
.trim()
.minLength(3)
.maxLength(255)
.regex(/^[a-zA-Z0-9äöüßÄÖÜ\s-]+$/),
description: vine.string().trim().maxLength(255).minLength(5).optional(),
}),
);
export const updateProjectValidator = vine.compile(
vine.object({
// label is NOT included since it's readonly
name: vine
.string()
.trim()
.minLength(3)
.maxLength(255)
.regex(/^[a-zA-Z0-9äöüßÄÖÜ\s-]+$/),
description: vine.string().trim().maxLength(255).minLength(5).optional(),
}),
);

View file

@ -8,20 +8,20 @@ export const createRoleValidator = vine.compile(
vine.object({
name: vine
.string()
.isUnique({ table: 'roles', column: 'name' })
.trim()
.minLength(3)
.maxLength(255)
.regex(/^[a-zA-Z0-9]+$/), //Must be alphanumeric with hyphens or underscores
.isUnique({ table: 'roles', column: 'name' })
.regex(/^[a-zA-Z0-9]+$/), // Must be alphanumeric
display_name: vine
.string()
.isUnique({ table: 'roles', column: 'display_name' })
.trim()
.minLength(3)
.maxLength(255)
.isUnique({ table: 'roles', column: 'display_name' })
.regex(/^[a-zA-Z0-9]+$/),
description: vine.string().trim().escape().minLength(3).maxLength(255).optional(),
permissions: vine.array(vine.number()).minLength(1), // define at least one permission for the new role
permissions: vine.array(vine.number()).minLength(1), // At least one permission required
}),
);
@ -29,21 +29,28 @@ export const updateRoleValidator = vine.withMetaData<{ roleId: number }>().compi
vine.object({
name: vine
.string()
// .unique(async (db, value, field) => {
// const result = await db.from('roles').select('id').whereNot('id', field.meta.roleId).where('name', value).first();
// return result.length ? false : true;
// })
.trim()
.minLength(3)
.maxLength(255)
.isUnique({
table: 'roles',
column: 'name',
whereNot: (field) => field.meta.roleId,
})
.regex(/^[a-zA-Z0-9]+$/),
display_name: vine
.string()
.trim()
.minLength(3)
.maxLength(255),
.maxLength(255)
.isUnique({
table: 'roles',
column: 'display_name',
whereNot: (field) => field.meta.roleId,
})
.regex(/^[a-zA-Z0-9]+$/),
description: vine.string().trim().escape().minLength(3).maxLength(255).optional(),
permissions: vine.array(vine.number()).minLength(1), // define at least one permission for the new role
permissions: vine.array(vine.number()).minLength(1), // At least one permission required
}),
);

View file

@ -16,7 +16,7 @@ export const createUserValidator = vine.compile(
first_name: vine.string().trim().minLength(3).maxLength(255),
last_name: vine.string().trim().minLength(3).maxLength(255),
email: vine.string().maxLength(255).email().normalizeEmail().isUnique({ table: 'accounts', column: 'email' }),
password: vine.string().confirmed().trim().minLength(3).maxLength(60),
new_password: vine.string().confirmed({ confirmationField: 'password_confirmation' }).trim().minLength(3).maxLength(60),
roles: vine.array(vine.number()).minLength(1), // define at least one role for the new user
}),
);
@ -42,7 +42,7 @@ export const updateUserValidator = vine.withMetaData<{ objId: number }>().compil
.email()
.normalizeEmail()
.isUnique({ table: 'accounts', column: 'email', whereNot: (field) => field.meta.objId }),
password: vine.string().confirmed().trim().minLength(3).maxLength(60).optional(),
new_password: vine.string().confirmed({ confirmationField: 'password_confirmation' }).trim().minLength(3).maxLength(60).optional(),
roles: vine.array(vine.number()).minLength(1), // define at least one role for the new user
}),
);

View file

@ -1,203 +1,50 @@
// import { ValidationError } from '../errors/validation_error.js';
import { errors } from '@vinejs/vine';
import type { ErrorReporterContract, FieldContext } from '@vinejs/vine/types';
import string from '@poppinss/utils/string';
import { errors } from '@vinejs/vine'
import type { ErrorReporterContract, FieldContext } from '@vinejs/vine/types'
/**
* Shape of the Vanilla error node
*/
export type VanillaErrorNode = {
[field: string]: string[];
};
export interface MessagesBagContract {
get(pointer: string, rule: string, message: string, arrayExpressionPointer?: string, args?: any): string;
}
/**
* Message bag exposes the API to pull the most appropriate message for a
* given validation failure.
*/
export class MessagesBag implements MessagesBagContract {
messages: Message;
wildCardCallback;
constructor(messages: string[]) {
this.messages = messages;
this.wildCardCallback = typeof this.messages['*'] === 'function' ? this.messages['*'] : undefined;
}
/**
* Transform message by replace placeholders with runtime values
*/
transform(message: any, rule: string, pointer: string, args: any) {
/**
* No interpolation required
*/
if (!message.includes('{{')) {
return message;
}
return string.interpolate(message, { rule, field: pointer, options: args || {} });
}
/**
* Returns the most appropriate message for the validation failure.
*/
get(pointer: string, rule: string, message: string, arrayExpressionPointer: string, args: any) {
let validationMessage = this.messages[`${pointer}.${rule}`];
/**
* Fetch message for the array expression pointer if it exists
*/
if (!validationMessage && arrayExpressionPointer) {
validationMessage = this.messages[`${arrayExpressionPointer}.${rule}`];
}
/**
* Fallback to the message for the rule
*/
if (!validationMessage) {
validationMessage = this.messages[rule];
}
/**
* Transform and return message. The wildcard callback is invoked when custom message
* is not defined
*/
return validationMessage
? this.transform(validationMessage, rule, pointer, args)
: this.wildCardCallback
? this.wildCardCallback(pointer, rule, arrayExpressionPointer, args)
: message;
}
}
/**
* Shape of the error message collected by the SimpleErrorReporter
*/
type SimpleError = {
message: string;
field: string;
rule: string;
index?: number;
meta?: Record<string, any>;
};
export interface Message {
[key: string]: any;
}
/**
* Simple error reporter collects error messages as an array of object.
* Each object has following properties.
*
* - message: string
* - field: string
* - rule: string
* - index?: number (in case of an array member)
* - args?: Record<string, any>
* Der VanillaErrorReporter sammelt Validierungsfehler im Standardformat,
* damit die AdonisJS Session-Middleware sie korrekt verarbeiten (reducen) kann.
*/
export class VanillaErrorReporter implements ErrorReporterContract {
// private messages;
// private bail;
/**
* Boolean, um zu prüfen, ob Fehler vorliegen
*/
hasErrors: boolean = false
/**
* Sammlung der Fehler als Array (erforderlich für AdonisJS 6 Session)
*/
errors: any[] = []
/**
* Diese Methode wird von VineJS für jeden Validierungsfehler aufgerufen
*/
report(
message: string,
rule: string,
field: FieldContext,
meta?: Record<string, any>
): void {
this.hasErrors = true
/**
* Boolean to know one or more errors have been reported
*/
hasErrors: boolean = false;
/**
* Collection of errors
*/
// errors: SimpleError[] = [];
errors: Message = {};
/**
* Report an error.
* Wir pushen das Objekt in das Array.
* Das Feld 'field' erhält den vollständigen Pfad (z.B. "user.email").
*/
this.errors.push({
message,
rule,
field: field.getFieldPath(),
...meta,
});
}
// constructor(messages: MessagesBagContract) {
// this.messages = messages;
// }
report(message: string, rule: string, field: FieldContext, meta?: Record<string, any> | undefined): void {
// const error: SimpleError = {
// message,
// rule,
// field: field.getFieldPath()
// };
// if (meta) {
// error.meta = meta;
// }
// if (field.isArrayMember) {
// error.index = field.name as number;
// }
// this.errors.push(error);
this.hasErrors = true;
// if (this.errors[field.getFieldPath()]) {
// this.errors[field.getFieldPath()]?.push(message);
// } else {
// this.errors[field.getFieldPath()] = [message];
// }
const error: SimpleError = {
message,
rule,
field: field.getFieldPath(), // ?field.wildCardPath.split('.')[0] : field.getFieldPath(),
};
// field: 'titles.0.value'
// message: 'Main Title is required'
// rule: 'required' "required"
if (meta) {
error.meta = meta;
}
// if (field.isArrayMember) {
// error.index = field.name;
// }
this.hasErrors = true;
// var test = field.getFieldPath();
// this.errors.push(error);
// if (this.errors[error.field]) {
// this.errors[error.field]?.push(message);
// }
if (field.isArrayMember) {
// Check if the field has wildCardPath and if the error field already exists
if (this.errors[error.field]) {
// Do nothing, as we don't want to push further messages
} else {
// If the error field already exists, push the message
if (this.errors[error.field]) {
this.errors[error.field].push(message);
} else {
this.errors[error.field] = [message];
}
}
} else {
if (this.errors[error.field]) {
this.errors[error.field]?.push(message);
} else {
this.errors[error.field] = [message];
}
}
// } else {
// // normal field
// this.errors[field.field] = [message];
// }
/**
* Collecting errors as per the JSONAPI spec
*/
// this.errors.push({
// code: rule,
// detail: message,
// source: {
// pointer: field.wildCardPath,
// },
// ...(meta ? { meta } : {}),
// });
// let pointer: string = field.wildCardPath as string; //'display_name'
// // if (field.isArrayMember) {
// // this.errors[pointer] = field.name;
// // }
// this.errors[pointer] = this.errors[pointer] || [];
// // this.errors[pointer].push(message);
// this.errors[pointer].push(this.messages.get(pointer, rule, message, arrayExpressionPointer, args));
}
/**
* Returns an instance of the validation error
*/
createError() {
return new errors.E_VALIDATION_ERROR(this.errors);
}
}
export {};
/**
* Erstellt die eigentliche Exception.
* Da 'this.errors' nun ein Array ist, funktioniert .reduce()
* in der Session-Middleware reibungslos.
*/
createError() {
return new errors.E_VALIDATION_ERROR(this.errors);
}
}

View file

@ -5,7 +5,23 @@ LogSyslog no
LogVerbose yes
DatabaseDirectory /var/lib/clamav
LocalSocket /var/run/clamav/clamd.socket
# LocalSocketMode 666
# Optional: allow multiple threads
MaxThreads 20
# Disable TCP socket
# TCPSocket 0
# TCP port address.
# Default: no
# TCPSocket 3310
# TCP address.
# By default we bind to INADDR_ANY, probably not wise.
# Enable the following to provide some degree of protection
# from the outside world.
# Default: no
# TCPAddr 127.0.0.1
Foreground no
PidFile /var/run/clamav/clamd.pid
LocalSocketGroup node
User node
# LocalSocketGroup node # Changed from 'clamav'
# User node # Changed from 'clamav' - clamd runs as clamav user

View file

@ -0,0 +1,482 @@
/*
|--------------------------------------------------------------------------
| node ace make:command fix-dataset-cross-references
| DONE: create commands/fix_dataset_cross_references.ts
|--------------------------------------------------------------------------
*/
import { BaseCommand, flags } from '@adonisjs/core/ace';
import type { CommandOptions } from '@adonisjs/core/types/ace';
import { DateTime } from 'luxon';
import Dataset from '#models/dataset';
import DatasetReference from '#models/dataset_reference';
import AppConfig from '#models/appconfig';
// import env from '#start/env';
interface MissingCrossReference {
sourceDatasetId: number;
targetDatasetId: number;
sourcePublishId: number | null;
targetPublishId: number | null;
sourceDoi: string | null;
targetDoi: string | null;
referenceType: string;
relation: string;
doi: string | null;
reverseRelation: string;
sourceReferenceLabel: string | null;
}
export default class DetectMissingCrossReferences extends BaseCommand {
static commandName = 'detect:missing-cross-references';
static description = 'Detect missing bidirectional cross-references between versioned datasets';
public static needsApplication = true;
@flags.boolean({ alias: 'f', description: 'Fix missing cross-references automatically' })
public fix: boolean = false;
@flags.boolean({ alias: 'v', description: 'Verbose output' })
public verbose: boolean = false;
@flags.number({ alias: 'p', description: 'Filter by specific publish_id (source or target dataset)' })
public publish_id?: number;
// example: node ace detect:missing-cross-references --verbose -p 227 //if you want to filter by specific publish_id with details
// example: node ace detect:missing-cross-references --verbose
// example: node ace detect:missing-cross-references --fix -p 227 //if you want to filter by specific publish_id and fix it
// example: node ace detect:missing-cross-references
public static options: CommandOptions = {
startApp: true,
staysAlive: false,
};
// Define the allowed relations that we want to process
private readonly ALLOWED_RELATIONS = [
'IsNewVersionOf',
'IsPreviousVersionOf',
'IsVariantFormOf',
'IsOriginalFormOf',
'Continues',
'IsContinuedBy',
'HasPart',
'IsPartOf',
];
// private readonly ALLOWED_RELATIONS = ['IsPreviousVersionOf', 'IsOriginalFormOf'];
async run() {
this.logger.info('🔍 Detecting missing cross-references...');
this.logger.info(`📋 Processing only these relations: ${this.ALLOWED_RELATIONS.join(', ')}`);
if (this.publish_id) {
this.logger.info(`Filtering by publish_id: ${this.publish_id}`);
}
try {
const missingReferences = await this.findMissingCrossReferences();
// Store count in AppConfig if not fixing and count >= 1
if (!this.fix && missingReferences.length >= 1) {
await this.storeMissingCrossReferencesCount(missingReferences.length);
}
if (missingReferences.length === 0) {
const filterMsg = this.publish_id ? ` for publish_id ${this.publish_id}` : '';
this.logger.success(`All cross-references are properly linked for the specified relations${filterMsg}!`);
// Clear the count if no missing references
if (!this.fix) {
await this.storeMissingCrossReferencesCount(0);
}
return;
}
const filterMsg = this.publish_id ? ` (filtered by publish_id ${this.publish_id})` : '';
this.logger.warning(`Found ${missingReferences.length} missing cross-reference(s)${filterMsg}:`);
// Show brief list if not verbose mode
if (!this.verbose) {
for (const missing of missingReferences) {
const sourceDoi = missing.sourceDoi ? ` DOI: ${missing.sourceDoi}` : '';
const targetDoi = missing.targetDoi ? ` DOI: ${missing.targetDoi}` : '';
this.logger.info(
`Dataset ${missing.sourceDatasetId} (Publish ID: ${missing.sourcePublishId}${sourceDoi}) ${missing.relation} Dataset ${missing.targetDatasetId} (Publish ID: ${missing.targetPublishId}${targetDoi}) → missing reverse: ${missing.reverseRelation}`,
);
}
} else {
// Verbose mode - show detailed info
for (const missing of missingReferences) {
this.logger.info(
`Dataset ${missing.sourceDatasetId} references ${missing.targetDatasetId}, but reverse reference is missing`,
);
this.logger.info(` - Reference type: ${missing.referenceType}`);
this.logger.info(` - Relation: ${missing.relation}`);
this.logger.info(` - DOI: ${missing.doi}`);
}
}
if (this.fix) {
await this.fixMissingReferences(missingReferences);
// Clear the count after fixing
await this.storeMissingCrossReferencesCount(0);
this.logger.success('All missing cross-references have been fixed!');
} else {
if (this.verbose) {
this.printMissingReferencesList(missingReferences);
}
this.logger.info('💡 Run with --fix flag to automatically create missing cross-references');
if (this.publish_id) {
this.logger.info(`🎯 Currently filtering by publish_id: ${this.publish_id}`);
}
}
} catch (error) {
this.logger.error('Error detecting missing cross-references:', error);
process.exit(1);
}
}
private async storeMissingCrossReferencesCount(count: number): Promise<void> {
try {
await AppConfig.updateOrCreate(
{
appid: 'commands',
configkey: 'missing_cross_references_count',
},
{
configvalue: count.toString(),
},
);
this.logger.info(`📊 Stored missing cross-references count in database: ${count}`);
} catch (error) {
this.logger.error('Failed to store missing cross-references count:', error);
}
}
private async findMissingCrossReferences(): Promise<MissingCrossReference[]> {
const missingReferences: {
sourceDatasetId: number;
targetDatasetId: number;
sourcePublishId: number | null;
targetPublishId: number | null;
sourceDoi: string | null;
targetDoi: string | null;
referenceType: string;
relation: string;
doi: string | null;
reverseRelation: string;
sourceReferenceLabel: string | null;
}[] = [];
this.logger.info('📊 Querying dataset references...');
// Find all references that point to Tethys datasets (DOI or URL containing tethys DOI)
// Only from datasets that are published AND only for allowed relations
const tethysReferencesQuery = DatasetReference.query()
.whereIn('type', ['DOI', 'URL'])
.whereIn('relation', this.ALLOWED_RELATIONS) // Only process allowed relations
.where((query) => {
query.where('value', 'like', '%doi.org/10.24341/tethys.%').orWhere('value', 'like', '%tethys.at/dataset/%');
})
.preload('dataset', (datasetQuery) => {
datasetQuery.preload('identifier');
})
.whereHas('dataset', (datasetQuery) => {
datasetQuery.where('server_state', 'published');
});
if (typeof this.publish_id === 'number') {
tethysReferencesQuery.whereHas('dataset', (datasetQuery) => {
datasetQuery.where('publish_id', this.publish_id as number);
});
}
const tethysReferences = await tethysReferencesQuery.exec();
this.logger.info(`🔗 Found ${tethysReferences.length} Tethys references from published datasets (allowed relations only)`);
let processedCount = 0;
let skippedCount = 0;
for (const reference of tethysReferences) {
processedCount++;
// if (this.verbose && processedCount % 10 === 0) {
// this.logger.info(`📈 Processed ${processedCount}/${tethysReferences.length} references...`);
// }
// Double-check that this relation is in our allowed list (safety check)
if (!this.ALLOWED_RELATIONS.includes(reference.relation)) {
skippedCount++;
if (this.verbose) {
this.logger.info(`⏭️ Skipping relation "${reference.relation}" - not in allowed list`);
}
continue;
}
// Extract dataset publish_id from DOI or URL
// const targetDatasetPublish = this.extractDatasetPublishIdFromReference(reference.value);
// Extract DOI from reference URL
const doi = this.extractDoiFromReference(reference.value);
// if (!targetDatasetPublish) {
// if (this.verbose) {
// this.logger.warning(`Could not extract publish ID from: ${reference.value}`);
// }
// continue;
// }
if (!doi) {
if (this.verbose) {
this.logger.warning(`Could not extract DOI from: ${reference.value}`);
}
continue;
}
// // Check if target dataset exists and is published
// const targetDataset = await Dataset.query()
// .where('publish_id', targetDatasetPublish)
// .where('server_state', 'published')
// .preload('identifier')
// .first();
// Check if target dataset exists and is published by querying via identifier
const targetDataset = await Dataset.query()
.where('server_state', 'published')
.whereHas('identifier', (query) => {
query.where('value', doi);
})
.preload('identifier')
.first();
if (!targetDataset) {
if (this.verbose) {
this.logger.warning(`⚠️ Target dataset with publish_id ${doi} not found or not published`);
}
continue;
}
// Ensure we have a valid source dataset with proper preloading
if (!reference.dataset) {
this.logger.warning(`⚠️ Source dataset ${reference.document_id} not properly loaded, skipping...`);
continue;
}
// Check if reverse reference exists
const reverseReferenceExists = await this.checkReverseReferenceExists(
targetDataset.id,
reference.document_id,
reference.relation,
reference.dataset.identifier.value
);
if (!reverseReferenceExists) {
const reverseRelation = this.getReverseRelation(reference.relation);
if (reverseRelation) {
// Only add if we have a valid reverse relation
missingReferences.push({
sourceDatasetId: reference.document_id,
targetDatasetId: targetDataset.id,
sourcePublishId: reference.dataset.publish_id || null,
targetPublishId: targetDataset.publish_id || null,
referenceType: reference.type,
relation: reference.relation,
doi: reference.value,
reverseRelation: reverseRelation,
sourceDoi: reference.dataset.identifier ? reference.dataset.identifier.value : null,
targetDoi: targetDataset.identifier ? targetDataset.identifier.value : null,
sourceReferenceLabel: reference.label || null,
});
}
}
}
this.logger.info(`✅ Processed ${processedCount} references (${skippedCount} skipped due to relation filtering)`);
return missingReferences;
}
private extractDoiFromReference(reference: string): string | null {
// Match DOI pattern, with or without URL prefix
const doiPattern = /(?:https?:\/\/)?(?:doi\.org\/)?(.+)/i;
const match = reference.match(doiPattern);
if (match && match[1]) {
return match[1]; // Returns just "10.24341/tethys.99.2"
}
return null;
}
private extractDatasetPublishIdFromReference(value: string): number | null {
// Extract from DOI: https://doi.org/10.24341/tethys.107 -> 107
const doiMatch = value.match(/10\.24341\/tethys\.(\d+)/);
if (doiMatch) {
return parseInt(doiMatch[1]);
}
// Extract from URL: https://tethys.at/dataset/107 -> 107
const urlMatch = value.match(/tethys\.at\/dataset\/(\d+)/);
if (urlMatch) {
return parseInt(urlMatch[1]);
}
return null;
}
private async checkReverseReferenceExists(
targetDatasetId: number,
sourceDatasetId: number,
originalRelation: string,
sourceDatasetIdentifier: string | null,
): Promise<boolean> {
const reverseRelation = this.getReverseRelation(originalRelation);
if (!reverseRelation) {
return true; // If no reverse relation is defined, consider it as "exists" to skip processing
}
// Only check for reverse references where the source dataset is also published
const reverseReference = await DatasetReference.query()
// We don't filter by source document_id here to find any incoming reference from any published dataset
.where('document_id', targetDatasetId)
// .where('related_document_id', sourceDatasetId) // Ensure it's an incoming reference
.where('relation', reverseRelation)
.where('value', 'like', `%${sourceDatasetIdentifier}`) // Basic check to ensure it points back to source dataset
.first();
return !!reverseReference;
}
private getReverseRelation(relation: string): string | null {
const relationMap: Record<string, string> = {
IsNewVersionOf: 'IsPreviousVersionOf',
IsPreviousVersionOf: 'IsNewVersionOf',
IsVariantFormOf: 'IsOriginalFormOf',
IsOriginalFormOf: 'IsVariantFormOf',
Continues: 'IsContinuedBy',
IsContinuedBy: 'Continues',
HasPart: 'IsPartOf',
IsPartOf: 'HasPart',
};
// Only return reverse relation if it exists in our map, otherwise return null
return relationMap[relation] || null;
}
private printMissingReferencesList(missingReferences: MissingCrossReference[]) {
console.log('┌─────────────────────────────────────────────────────────────────────────────────┐');
console.log('│ MISSING CROSS-REFERENCES REPORT │');
console.log('│ (Published Datasets Only - Filtered Relations) │');
console.log('└─────────────────────────────────────────────────────────────────────────────────┘');
console.log();
missingReferences.forEach((missing, index) => {
console.log(
`${index + 1}. Dataset ${missing.sourceDatasetId} (Publish ID: ${missing.sourcePublishId} Identifier: ${missing.sourceDoi})
${missing.relation} Dataset ${missing.targetDatasetId} (Publish ID: ${missing.targetPublishId} Identifier: ${missing.targetDoi})`,
);
console.log(` ├─ Current relation: "${missing.relation}"`);
console.log(` ├─ Missing reverse relation: "${missing.reverseRelation}"`);
console.log(` ├─ Reference type: ${missing.referenceType}`);
console.log(` └─ DOI/URL: ${missing.doi}`);
console.log();
});
console.log('┌─────────────────────────────────────────────────────────────────────────────────┐');
console.log(`│ SUMMARY: ${missingReferences.length} missing reverse reference(s) detected │`);
console.log(`│ Processed relations: ${this.ALLOWED_RELATIONS.join(', ')}`);
console.log('└─────────────────────────────────────────────────────────────────────────────────┘');
}
private async fixMissingReferences(missingReferences: MissingCrossReference[]) {
this.logger.info('🔧 Creating missing cross-references in database...');
let fixedCount = 0;
let errorCount = 0;
for (const [index, missing] of missingReferences.entries()) {
try {
// Get both source and target datasets
const sourceDataset = await Dataset.query()
.where('id', missing.sourceDatasetId)
.where('server_state', 'published')
.preload('identifier')
.preload('titles') // Preload titles to get mainTitle
.first();
const targetDataset = await Dataset.query().where('id', missing.targetDatasetId).where('server_state', 'published').first();
if (!sourceDataset) {
this.logger.warning(`⚠️ Source dataset ${missing.sourceDatasetId} not found or not published, skipping...`);
errorCount++;
continue;
}
if (!targetDataset) {
this.logger.warning(`⚠️ Target dataset ${missing.targetDatasetId} not found or not published, skipping...`);
errorCount++;
continue;
}
// **NEW: Update the original reference if related_document_id is missing**
const originalReference = await DatasetReference.query()
.where('document_id', missing.sourceDatasetId)
.where('relation', missing.relation)
.where('value', 'like', `%${missing.targetDoi}%`)
.first();
if (originalReference && !originalReference.related_document_id) {
originalReference.related_document_id = missing.targetDatasetId;
await originalReference.save();
if (this.verbose) {
this.logger.info(`🔗 Updated original reference with related_document_id: ${missing.targetDatasetId}`);
}
}
// Create the reverse reference using the referenced_by relationship
// Example: If Dataset 297 IsNewVersionOf Dataset 144
// We create an incoming reference for Dataset 144 that shows Dataset 297 IsPreviousVersionOf it
const reverseReference = new DatasetReference();
// Don't set document_id - this creates an incoming reference via related_document_id
reverseReference.document_id = missing.targetDatasetId; //
reverseReference.related_document_id = missing.sourceDatasetId;
reverseReference.type = 'DOI';
reverseReference.relation = missing.reverseRelation;
// Use the source dataset's DOI for the value (what's being referenced)
if (sourceDataset.identifier?.value) {
reverseReference.value = `https://doi.org/${sourceDataset.identifier.value}`;
} else {
// Fallback to dataset URL if no DOI
reverseReference.value = `https://tethys.at/dataset/${sourceDataset.publish_id || missing.sourceDatasetId}`;
}
// Use the source dataset's main title for the label
//reverseReference.label = sourceDataset.mainTitle || `Dataset ${missing.sourceDatasetId}`;
// get label of forward reference
reverseReference.label = missing.sourceReferenceLabel || sourceDataset.mainTitle || `Dataset ${missing.sourceDatasetId}`;
// reverseReference.notes = `Auto-created by detect:missing-cross-references command on ${DateTime.now().toISO()} to fix missing bidirectional reference.`;
// Save the new reverse reference
// Also save 'server_date_modified' on target dataset to trigger any downstream updates (e.g. search index)
targetDataset.server_date_modified = DateTime.now();
await targetDataset.save();
await reverseReference.save();
fixedCount++;
if (this.verbose) {
this.logger.info(
`✅ [${index + 1}/${missingReferences.length}] Created reverse reference: Dataset ${missing.sourceDatasetId} -> ${missing.targetDatasetId} (${missing.reverseRelation})`,
);
} else if ((index + 1) % 10 === 0) {
this.logger.info(`📈 Fixed ${fixedCount}/${missingReferences.length} references...`);
}
} catch (error) {
this.logger.error(
`❌ Error creating reverse reference for datasets ${missing.targetDatasetId} -> ${missing.sourceDatasetId}:`,
error,
);
errorCount++;
}
}
this.logger.info(`📊 Fix completed: ${fixedCount} created, ${errorCount} errors`);
}
}

View file

@ -0,0 +1,189 @@
/*
|--------------------------------------------------------------------------
| node ace make:command fix-version-related-ids
| DONE: create commands/fix_version_related_ids.ts
|--------------------------------------------------------------------------
| Repairs the `related_document_id` foreign key on version references
| (IsNewVersionOf / IsPreviousVersionOf, both directions).
|
| The DOI stored in `value` is the reliable link; `related_document_id`
| is frequently NULL or self-referential. This command resolves the target
| dataset via its DOI and sets `related_document_id` accordingly, correcting
| both NULL and wrong-but-non-null values.
|
| Examples:
| node ace fix:version-related-ids // dry run, all datasets
| node ace fix:version-related-ids --verbose // dry run with per-row detail
| node ace fix:version-related-ids --fix // apply changes
| node ace fix:version-related-ids --fix -p 226 // apply, only refs owned by publish_id 226
*/
import { BaseCommand, flags } from '@adonisjs/core/ace';
import type { CommandOptions } from '@adonisjs/core/types/ace';
import Dataset from '#models/dataset';
import DatasetReference from '#models/dataset_reference';
export default class FixVersionRelatedIds extends BaseCommand {
static commandName = 'fix:version-related-ids';
static description =
'Backfill/repair related_document_id on IsNewVersionOf / IsPreviousVersionOf references by resolving the target dataset via its DOI';
public static needsApplication = true;
@flags.boolean({ alias: 'f', description: 'Apply changes. Without this flag the command runs as a dry run.' })
public fix: boolean = false;
@flags.boolean({ alias: 'v', description: 'Verbose output (per-reference detail)' })
public verbose: boolean = false;
@flags.number({ alias: 'p', description: 'Only process references owned by this publish_id' })
public publish_id?: number;
public static options: CommandOptions = {
startApp: true,
staysAlive: false,
};
// Only the version relations, both directions.
private readonly VERSION_RELATIONS = ['IsNewVersionOf', 'IsPreviousVersionOf'];
async run() {
this.logger.info(`🔍 Scanning ${this.VERSION_RELATIONS.join(' / ')} references...`);
this.logger.info(this.fix ? '✏️ Mode: APPLY (changes will be written)' : '👀 Mode: DRY RUN (no changes written)');
if (typeof this.publish_id === 'number') {
this.logger.info(`🎯 Filtering by owning publish_id: ${this.publish_id}`);
}
try {
const query = DatasetReference.query()
.whereIn('relation', this.VERSION_RELATIONS)
.whereIn('type', ['DOI', 'URL'])
.where((q) => {
q.where('value', 'like', '%doi.org/10.24341/tethys.%').orWhere('value', 'like', '%tethys.at/dataset/%');
});
// Restrict to references owned by a specific dataset (by publish_id), if requested.
if (typeof this.publish_id === 'number') {
query.whereHas('dataset', (d) => d.where('publish_id', this.publish_id as number));
}
const refs = await query.exec();
this.logger.info(`🔗 Found ${refs.length} version reference(s) to inspect`);
let alreadyCorrect = 0;
let filledFromNull = 0;
let correctedWrong = 0;
let unresolved = 0;
for (const ref of refs) {
const target = await this.resolveTarget(ref);
if (!target) {
unresolved++;
if (this.verbose) {
this.logger.warning(`⚠️ Reference ${ref.id}: could not resolve target (value: ${ref.value})`);
}
continue;
}
// Never let a reference point at its own owning document.
if (target.id === ref.document_id) {
unresolved++;
if (this.verbose) {
this.logger.warning(
`⚠️ Reference ${ref.id}: target resolves to its own document (${ref.document_id}); skipping self-link`,
);
}
continue;
}
if (ref.related_document_id === target.id) {
alreadyCorrect++;
continue;
}
const previous = ref.related_document_id;
const wasNull = previous === null || previous === undefined;
if (this.fix) {
ref.related_document_id = target.id;
await ref.save();
}
if (wasNull) {
filledFromNull++;
} else {
correctedWrong++;
}
if (this.verbose) {
const action = this.fix ? 'Updated' : '📝 Would update';
this.logger.info(
`${action} reference ${ref.id} (doc ${ref.document_id}, ${ref.relation}): ` +
`related_document_id ${previous ?? 'NULL'}${target.id} (publish_id ${target.publish_id})`,
);
}
}
this.logger.info('────────────────────────────────────────');
this.logger.info(`✔️ Already correct: ${alreadyCorrect}`);
this.logger.info(` Filled from NULL: ${filledFromNull}`);
this.logger.info(`🔧 Corrected wrong value: ${correctedWrong}`);
this.logger.info(`⚠️ Unresolved/skipped: ${unresolved}`);
this.logger.info('────────────────────────────────────────');
const changes = filledFromNull + correctedWrong;
if (!this.fix && changes > 0) {
this.logger.info(`💡 Dry run only. Re-run with --fix to write ${changes} change(s).`);
} else if (this.fix) {
this.logger.success(`Done. ${changes} reference(s) updated.`);
} else {
this.logger.success('Nothing to change — all version references already linked correctly.');
}
} catch (error) {
this.logger.error('Error fixing version related_document_id values:', error);
process.exit(1);
}
}
/**
* Resolve the dataset a version reference points to.
* Prefers the DOI in `value` (reliable); falls back to a tethys publish_id URL.
*/
private async resolveTarget(ref: DatasetReference): Promise<Dataset | null> {
const doi = this.normalizeDoi(ref.value);
if (doi) {
const byDoi = await Dataset.query()
.whereHas('identifier', (q) => q.where('value', doi))
.first();
if (byDoi) return byDoi;
}
const publishId = this.extractPublishId(ref.value);
if (publishId) {
const byPublishId = await Dataset.query().where('publish_id', publishId).first();
if (byPublishId) return byPublishId;
}
return null;
}
/**
* Strip the resolver prefix so a reference value like
* "https://doi.org/10.24341/tethys.108.2" matches the identifier
* table value "10.24341/tethys.108.2". Returns null if it isn't a DOI.
*/
private normalizeDoi(value: string | null): string | null {
if (!value) return null;
const cleaned = value
.trim()
.replace(/^https?:\/\/(dx\.)?doi\.org\//i, '')
.replace(/^doi:/i, '');
return /^10\.\d{4,}\//.test(cleaned) ? cleaned : null;
}
private extractPublishId(value: string | null): number | null {
if (!value) return null;
const urlMatch = value.match(/tethys\.at\/dataset\/(\d+)/);
return urlMatch ? parseInt(urlMatch[1], 10) : null;
}
}

View file

@ -4,7 +4,7 @@
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
import { create } from 'xmlbuilder2';
import Dataset from '#models/dataset';
import XmlModel from '#app/Library/XmlModel';
import XmlModel from '#app/Library/DatasetXmlSerializer';
import { readFileSync } from 'fs';
import SaxonJS from 'saxon-js';
import { Client } from '@opensearch-project/opensearch';
@ -12,10 +12,8 @@ import { getDomain } from '#app/utils/utility-functions';
import { BaseCommand, flags } from '@adonisjs/core/ace';
import { CommandOptions } from '@adonisjs/core/types/ace';
import env from '#start/env';
// import db from '@adonisjs/lucid/services/db';
// import { default as Dataset } from '#models/dataset';
import logger from '@adonisjs/core/services/logger';
import { DateTime } from 'luxon';
const opensearchNode = env.get('OPENSEARCH_HOST', 'localhost');
const client = new Client({ node: `${opensearchNode}` }); // replace with your OpenSearch endpoint
@ -30,11 +28,10 @@ export default class IndexDatasets extends BaseCommand {
public publish_id: number;
public static options: CommandOptions = {
startApp: true,
staysAlive: false,
startApp: true, // Ensures the IoC container is ready to use
staysAlive: false, // Command exits after running
};
async run() {
logger.debug('Hello world!');
// const { default: Dataset } = await import('#models/dataset');
@ -44,10 +41,12 @@ export default class IndexDatasets extends BaseCommand {
const index_name = 'tethys-records';
for (var dataset of datasets) {
// Logger.info(`File publish_id ${dataset.publish_id}`);
// const jsonString = await this.getJsonString(dataset, proc);
// console.log(jsonString);
await this.indexDocument(dataset, index_name, proc);
const shouldUpdate = await this.shouldUpdateDataset(dataset, index_name);
if (shouldUpdate) {
await this.indexDocument(dataset, index_name, proc);
} else {
logger.info(`Dataset with publish_id ${dataset.publish_id} is up to date, skipping indexing`);
}
}
}
@ -65,6 +64,46 @@ export default class IndexDatasets extends BaseCommand {
return await query.exec();
}
private async shouldUpdateDataset(dataset: Dataset, index_name: string): Promise<boolean> {
try {
// Check if publish_id exists before proceeding
if (!dataset.publish_id) {
// Return true to update since document doesn't exist in OpenSearch yet
return true;
}
// Get the existing document from OpenSearch
const response = await client.get({
index: index_name,
id: dataset.publish_id?.toString(),
});
const existingDoc = response.body._source;
// Compare server_date_modified
if (existingDoc && existingDoc.server_date_modified) {
// Convert Unix timestamp (seconds) to milliseconds for DateTime.fromMillis()
const existingModified = DateTime.fromMillis(Number(existingDoc.server_date_modified) * 1000);
const currentModified = dataset.server_date_modified;
// Only update if the dataset has been modified more recently
if (currentModified <= existingModified) {
return false;
}
}
return true;
} catch (error) {
// If document doesn't exist or other error, we should index it
if (error.statusCode === 404) {
logger.info(`Dataset with publish_id ${dataset.publish_id} not found in index, will create new document`);
return true;
}
logger.warn(`Error checking existing document for publish_id ${dataset.publish_id}: ${error.message}`);
return true; // Index anyway if we can't determine the status
}
}
private async indexDocument(dataset: Dataset, index_name: string, proc: Buffer): Promise<void> {
try {
const doc = await this.getJsonString(dataset, proc);
@ -78,7 +117,8 @@ export default class IndexDatasets extends BaseCommand {
});
logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`);
} catch (error) {
logger.error(`An error occurred while indexing dataset with publish_id ${dataset.publish_id}.`);
logger.error(`An error occurred while indexing dataset with publish_id ${dataset.publish_id}.
Error: ${error.message}`);
}
}
@ -111,19 +151,16 @@ export default class IndexDatasets extends BaseCommand {
}
private async getDatasetXmlDomNode(dataset: Dataset): Promise<XMLBuilder | null> {
const xmlModel = new XmlModel(dataset);
const serializer = new XmlModel(dataset).enableCaching().excludeEmptyFields();
// xmlModel.setModel(dataset);
xmlModel.excludeEmptyFields();
xmlModel.caching = true;
// const cache = dataset.xmlCache ? dataset.xmlCache : null;
// dataset.load('xmlCache');
if (dataset.xmlCache) {
xmlModel.xmlCache = dataset.xmlCache;
serializer.setCache(dataset.xmlCache);
}
// return cache.getDomDocument();
const domDocument: XMLBuilder | null = await xmlModel.getDomDocument();
return domDocument;
// return cache.toXmlDocument();
const xmlDocument: XMLBuilder | null = await serializer.toXmlDocument();
return xmlDocument;
}
private addSpecInformation(domNode: XMLBuilder, information: string) {

View file

@ -0,0 +1,346 @@
/*
|--------------------------------------------------------------------------
| node ace make:command list-updateable-datacite
| DONE: create commands/list_updeatable_datacite.ts
|--------------------------------------------------------------------------
*/
import { BaseCommand, flags } from '@adonisjs/core/ace';
import { CommandOptions } from '@adonisjs/core/types/ace';
import Dataset from '#models/dataset';
import { DoiClient } from '#app/Library/Doi/DoiClient';
import env from '#start/env';
import logger from '@adonisjs/core/services/logger';
import { DateTime } from 'luxon';
import pLimit from 'p-limit';
export default class ListUpdateableDatacite extends BaseCommand {
static commandName = 'list:updateable-datacite';
static description = 'List all datasets that need DataCite DOI updates';
public static needsApplication = true;
// private chunkSize = 100; // Set chunk size for pagination
@flags.boolean({ alias: 'v', description: 'Verbose output showing detailed information' })
public verbose: boolean = false;
@flags.boolean({ alias: 'c', description: 'Show only count of updatable datasets' })
public countOnly: boolean = false;
@flags.boolean({ alias: 'i', description: 'Show only publish IDs (useful for scripting)' })
public idsOnly: boolean = false;
@flags.number({ description: 'Chunk size for processing datasets (default: 50)' })
public chunkSize: number = 50;
//example: node ace list:updateable-datacite
//example: node ace list:updateable-datacite --verbose
//example: node ace list:updateable-datacite --count-only
//example: node ace list:updateable-datacite --ids-only
//example: node ace list:updateable-datacite --chunk-size 50
public static options: CommandOptions = {
startApp: true,
stayAlive: false,
};
async run() {
const prefix = env.get('DATACITE_PREFIX', '');
const base_domain = env.get('BASE_DOMAIN', '');
if (!prefix || !base_domain) {
logger.error('Missing DATACITE_PREFIX or BASE_DOMAIN environment variables');
return;
}
// Prevent conflicting flags
if ((this.verbose && this.countOnly) || (this.verbose && this.idsOnly)) {
logger.error('Flags --verbose cannot be combined with --count-only or --ids-only');
return;
}
const chunkSize = this.chunkSize || 50;
let page = 1;
let hasMoreDatasets = true;
let totalProcessed = 0;
const updatableDatasets: Dataset[] = [];
if (!this.countOnly && !this.idsOnly) {
logger.info(`Processing datasets in chunks of ${chunkSize}...`);
}
while (hasMoreDatasets) {
const datasets = await this.getDatasets(page, chunkSize);
if (datasets.length === 0) {
hasMoreDatasets = false;
break;
}
if (!this.countOnly && !this.idsOnly) {
logger.info(`Processing chunk ${page} (${datasets.length} datasets)...`);
}
const chunkUpdatableDatasets = await this.processChunk(datasets);
updatableDatasets.push(...chunkUpdatableDatasets);
totalProcessed += datasets.length;
page += 1;
if (datasets.length < chunkSize) {
hasMoreDatasets = false;
}
}
if (!this.countOnly && !this.idsOnly) {
logger.info(`Processed ${totalProcessed} datasets total, found ${updatableDatasets.length} that need updates`);
}
if (this.countOnly) {
console.log(updatableDatasets.length);
} else if (this.idsOnly) {
updatableDatasets.forEach((dataset) => console.log(dataset.publish_id));
} else if (this.verbose) {
await this.showVerboseOutput(updatableDatasets);
} else {
this.showSimpleOutput(updatableDatasets);
}
}
/**
* Processes a chunk of datasets to determine which ones need DataCite updates
*
* This method handles parallel processing of datasets within a chunk, providing
* efficient error handling and filtering of results.
*
* @param datasets - Array of Dataset objects to process
* @returns Promise<Dataset[]> - Array of datasets that need updates
*/
// private async processChunk(datasets: Dataset[]): Promise<Dataset[]> {
// // Process datasets in parallel using Promise.allSettled for better error handling
// //
// // Why Promise.allSettled vs Promise.all?
// // - Promise.all fails fast: if ANY promise rejects, the entire operation fails
// // - Promise.allSettled waits for ALL promises: some can fail, others succeed
// // - This is crucial for batch processing where we don't want one bad dataset
// // to stop processing of the entire chunk
// const results = await Promise.allSettled(
// datasets.map(async (dataset) => {
// try {
// // Check if this specific dataset needs a DataCite update
// const needsUpdate = await this.shouldUpdateDataset(dataset);
// // Return the dataset if it needs update, null if it doesn't
// // This creates a sparse array that we'll filter later
// return needsUpdate ? dataset : null;
// } catch (error) {
// // Error handling for individual dataset checks
// //
// // Log warnings only if we're not in silent modes (count-only or ids-only)
// // This prevents log spam when running automated scripts
// if (!this.countOnly && !this.idsOnly) {
// logger.warn(`Error checking dataset ${dataset.publish_id}: ${error.message}`);
// }
// // IMPORTANT DECISION: Return the dataset anyway if we can't determine status
// //
// // Why? It's safer to include a dataset that might not need updating
// // than to miss one that actually does need updating. This follows the
// // "fail-safe" principle - if we're unsure, err on the side of caution
// return dataset;
// }
// }),
// );
// // Filter and extract results from Promise.allSettled response
// //
// // Promise.allSettled returns an array of objects with this structure:
// // - { status: 'fulfilled', value: T } for successful promises
// // - { status: 'rejected', reason: Error } for failed promises
// //
// // We need to:
// // 1. Only get fulfilled results (rejected ones are already handled above)
// // 2. Filter out null values (datasets that don't need updates)
// // 3. Extract the actual Dataset objects from the wrapper
// return results
// .filter(
// (result): result is PromiseFulfilledResult<Dataset | null> =>
// // Type guard: only include fulfilled results that have actual values
// // This filters out:
// // - Rejected promises (shouldn't happen due to try/catch, but safety first)
// // - Fulfilled promises that returned null (datasets that don't need updates)
// result.status === 'fulfilled' && result.value !== null,
// )
// .map((result) => result.value!); // Extract the Dataset from the wrapper
// // The ! is safe here because we filtered out null values above
// }
private async processChunk(datasets: Dataset[]): Promise<Dataset[]> {
// Limit concurrency to avoid API flooding (e.g., max 5 at once)
const limit = pLimit(5);
const tasks = datasets.map((dataset) =>
limit(async () => {
try {
const needsUpdate = await this.shouldUpdateDataset(dataset);
return needsUpdate ? dataset : null;
} catch (error) {
if (!this.countOnly && !this.idsOnly) {
logger.warn(
`Error checking dataset ${dataset.publish_id}: ${
error instanceof Error ? error.message : JSON.stringify(error)
}`,
);
}
// Fail-safe: include dataset if uncertain
return dataset;
}
}),
);
const results = await Promise.allSettled(tasks);
return results
.filter((result): result is PromiseFulfilledResult<Dataset | null> => result.status === 'fulfilled' && result.value !== null)
.map((result) => result.value!);
}
private async getDatasets(page: number, chunkSize: number): Promise<Dataset[]> {
return await Dataset.query()
.orderBy('publish_id', 'asc')
.preload('identifier')
.preload('xmlCache')
.preload('titles')
.where('server_state', 'published')
.whereHas('identifier', (identifierQuery) => {
identifierQuery.where('type', 'doi');
})
.forPage(page, chunkSize); // Get files for the current page
}
private async shouldUpdateDataset(dataset: Dataset): Promise<boolean> {
try {
let doiIdentifier = dataset.identifier;
if (!doiIdentifier) {
await dataset.load('identifier');
doiIdentifier = dataset.identifier;
}
if (!doiIdentifier || doiIdentifier.type !== 'doi') {
return false;
}
const datasetModified =
dataset.server_date_modified instanceof DateTime
? dataset.server_date_modified
: DateTime.fromJSDate(dataset.server_date_modified);
if (!datasetModified) {
return true;
}
if (datasetModified > DateTime.now()) {
return false;
}
const doiClient = new DoiClient();
const DOI_CHECK_TIMEOUT = 300; // ms
const doiLastModified = await Promise.race([
doiClient.getDoiLastModified(doiIdentifier.value),
this.createTimeoutPromise(DOI_CHECK_TIMEOUT),
]).catch(() => null);
if (!doiLastModified) {
// If uncertain, better include dataset for update
return true;
}
const doiModified = DateTime.fromJSDate(doiLastModified);
if (datasetModified > doiModified) {
const diffInSeconds = Math.abs(datasetModified.diff(doiModified, 'seconds').seconds);
const toleranceSeconds = 600;
return diffInSeconds > toleranceSeconds;
}
return false;
} catch (error) {
return true; // safer: include dataset if unsure
}
}
/**
* Create a timeout promise for API calls
*/
private createTimeoutPromise(timeoutMs: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(`API call timeout after ${timeoutMs}ms`)), timeoutMs);
});
}
private showSimpleOutput(updatableDatasets: Dataset[]): void {
if (updatableDatasets.length === 0) {
console.log('No datasets need DataCite updates.');
return;
}
console.log(`\nFound ${updatableDatasets.length} dataset(s) that need DataCite updates:\n`);
updatableDatasets.forEach((dataset) => {
console.log(`publish_id ${dataset.publish_id} needs update - ${dataset.mainTitle || 'Untitled'}`);
});
console.log(`\nTo update these datasets, run:`);
console.log(` node ace update:datacite`);
console.log(`\nOr update specific datasets:`);
console.log(` node ace update:datacite -p <publish_id>`);
}
private async showVerboseOutput(updatableDatasets: Dataset[]): Promise<void> {
if (updatableDatasets.length === 0) {
console.log('No datasets need DataCite updates.');
return;
}
console.log(`\nFound ${updatableDatasets.length} dataset(s) that need DataCite updates:\n`);
for (const dataset of updatableDatasets) {
await this.showDatasetDetails(dataset);
}
console.log(`\nSummary: ${updatableDatasets.length} datasets need updates`);
}
private async showDatasetDetails(dataset: Dataset): Promise<void> {
try {
let doiIdentifier = dataset.identifier;
if (!doiIdentifier) {
await dataset.load('identifier');
doiIdentifier = dataset.identifier;
}
const doiValue = doiIdentifier?.value || 'N/A';
const datasetModified = dataset.server_date_modified;
// Get DOI info from DataCite
const doiClient = new DoiClient();
const doiLastModified = await doiClient.getDoiLastModified(doiValue);
const doiState = await doiClient.getDoiState(doiValue);
console.log(`┌─ Dataset ${dataset.publish_id} ───────────────────────────────────────────────────────────────`);
console.log(`│ Title: ${dataset.mainTitle || 'Untitled'}`);
console.log(`│ DOI: ${doiValue}`);
console.log(`│ DOI State: ${doiState || 'Unknown'}`);
console.log(`│ Dataset Modified: ${datasetModified ? datasetModified.toISO() : 'N/A'}`);
console.log(`│ DOI Modified: ${doiLastModified ? DateTime.fromJSDate(doiLastModified).toISO() : 'N/A'}`);
console.log(`│ Status: NEEDS UPDATE`);
console.log(`└─────────────────────────────────────────────────────────────────────────────────────────────\n`);
} catch (error) {
console.log(`┌─ Dataset ${dataset.publish_id} ───────────────────────────────────────────────────────────────`);
console.log(`│ Title: ${dataset.mainTitle || 'Untitled'}`);
console.log(`│ DOI: ${dataset.identifier?.value || 'N/A'}`);
console.log(`│ Error: ${error.message}`);
console.log(`│ Status: NEEDS UPDATE (Error checking)`);
console.log(`└─────────────────────────────────────────────────────────────────────────────────────────────\n`);
}
}
}

266
commands/update_datacite.ts Normal file
View file

@ -0,0 +1,266 @@
/*
|--------------------------------------------------------------------------
| node ace make:command update-datacite
| DONE: create commands/update_datacite.ts
|--------------------------------------------------------------------------
*/
import { BaseCommand, flags } from '@adonisjs/core/ace';
import { CommandOptions } from '@adonisjs/core/types/ace';
import Dataset from '#models/dataset';
import { DoiClient } from '#app/Library/Doi/DoiClient';
import DoiClientException from '#app/exceptions/DoiClientException';
import Index from '#app/Library/Utils/Index';
import env from '#start/env';
import logger from '@adonisjs/core/services/logger';
import { DateTime } from 'luxon';
import { getDomain } from '#app/utils/utility-functions';
export default class UpdateDatacite extends BaseCommand {
static commandName = 'update:datacite';
static description = 'Update DataCite DOI records for published datasets';
public static needsApplication = true;
@flags.number({ alias: 'p', description: 'Specific publish_id to update' })
public publish_id: number;
@flags.boolean({ alias: 'f', description: 'Force update all records regardless of modification date' })
public force: boolean = false;
@flags.boolean({ alias: 'd', description: 'Dry run - show what would be updated without making changes' })
public dryRun: boolean = false;
@flags.boolean({ alias: 's', description: 'Show detailed stats for each dataset that needs updating' })
public stats: boolean = false;
//example: node ace update:datacite -p 123 --force --dry-run
public static options: CommandOptions = {
startApp: true, // Whether to boot the application before running the command
stayAlive: false, // Whether to keep the process alive after the command has executed
};
async run() {
logger.info('Starting DataCite update process...');
const prefix = env.get('DATACITE_PREFIX', '');
const base_domain = env.get('BASE_DOMAIN', '');
const apiUrl = env.get('DATACITE_API_URL', 'https://api.datacite.org');
if (!prefix || !base_domain) {
logger.error('Missing DATACITE_PREFIX or BASE_DOMAIN environment variables');
return;
}
logger.info(`Using DataCite API: ${apiUrl}`);
const datasets = await this.getDatasets();
logger.info(`Found ${datasets.length} datasets to process`);
let updated = 0;
let skipped = 0;
let errors = 0;
for (const dataset of datasets) {
try {
const shouldUpdate = this.force || (await this.shouldUpdateDataset(dataset));
if (this.stats) {
// Stats mode: show detailed information for datasets that need updating
if (shouldUpdate) {
await this.showDatasetStats(dataset);
updated++;
} else {
skipped++;
}
continue;
}
if (!shouldUpdate) {
logger.info(`Dataset ${dataset.publish_id}: Up to date, skipping`);
skipped++;
continue;
}
if (this.dryRun) {
logger.info(`Dataset ${dataset.publish_id}: Would update DataCite record (dry run)`);
updated++;
continue;
}
await this.updateDataciteRecord(dataset, prefix, base_domain);
logger.info(`Dataset ${dataset.publish_id}: Successfully updated DataCite record`);
updated++;
} catch (error) {
logger.error(`Dataset ${dataset.publish_id}: Failed to update - ${error.message}`);
errors++;
}
}
if (this.stats) {
logger.info(`\nDataCite Stats Summary: ${updated} datasets need updating, ${skipped} are up to date`);
} else {
logger.info(`DataCite update completed. Updated: ${updated}, Skipped: ${skipped}, Errors: ${errors}`);
}
}
private async getDatasets(): Promise<Dataset[]> {
const query = Dataset.query()
.preload('identifier')
.preload('xmlCache')
.where('server_state', 'published')
.whereHas('identifier', (identifierQuery) => {
identifierQuery.where('type', 'doi');
});
if (this.publish_id) {
query.where('publish_id', this.publish_id);
}
return await query.exec();
}
private async shouldUpdateDataset(dataset: Dataset): Promise<boolean> {
try {
let doiIdentifier = dataset.identifier;
if (!doiIdentifier) {
await dataset.load('identifier');
doiIdentifier = dataset.identifier;
}
if (!doiIdentifier || doiIdentifier.type !== 'doi') {
return false;
}
const datasetModified = dataset.server_date_modified;
const now = DateTime.now();
if (!datasetModified) {
return true; // Update if modification date is missing
}
if (datasetModified > now) {
return false; // Skip invalid future dates
}
// Check DataCite DOI modification date
const doiClient = new DoiClient();
const doiLastModified = await doiClient.getDoiLastModified(doiIdentifier.value);
if (!doiLastModified) {
return false; // not Update if we can't get DOI info
}
const doiModified = DateTime.fromJSDate(doiLastModified);
if (datasetModified > doiModified) {
// if dataset was modified after DOI creation
// Calculate the difference in seconds
const diffInSeconds = Math.abs(datasetModified.diff(doiModified, 'seconds').seconds);
// Define tolerance threshold (60 seconds = 1 minute)
const toleranceSeconds = 60;
// Only update if the difference is greater than the tolerance
// This prevents unnecessary updates for minor timestamp differences
return diffInSeconds > toleranceSeconds;
} else {
return false; // No update needed
}
} catch (error) {
return false; // not update if we can't determine status or other error
}
}
private async updateDataciteRecord(dataset: Dataset, prefix: string, base_domain: string): Promise<void> {
try {
// Get the DOI identifier (HasOne relationship)
let doiIdentifier = dataset.identifier;
if (!doiIdentifier) {
await dataset.load('identifier');
doiIdentifier = dataset.identifier;
}
if (!doiIdentifier || doiIdentifier.type !== 'doi') {
throw new Error('No DOI identifier found for dataset');
}
// Generate XML metadata
const xmlMeta = (await Index.getDoiRegisterString(dataset)) as string;
if (!xmlMeta) {
throw new Error('Failed to generate XML metadata');
}
// Construct DOI value and landing page URL
const doiValue = doiIdentifier.value; // Use existing DOI value
const landingPageUrl = `https://doi.${getDomain(base_domain)}/${doiValue}`;
// Update DataCite record
const doiClient = new DoiClient();
const dataciteResponse = await doiClient.registerDoi(doiValue, xmlMeta, landingPageUrl);
if (dataciteResponse?.status === 201) {
// // Update dataset modification date
// dataset.server_date_modified = DateTime.now();
// await dataset.save();
// // Update search index
// const index_name = 'tethys-records';
// await Index.indexDocument(dataset, index_name);
logger.debug(`Dataset ${dataset.publish_id}: DataCite record and search index updated successfully`);
} else {
throw new DoiClientException(
dataciteResponse?.status || 500,
`Unexpected DataCite response code: ${dataciteResponse?.status}`,
);
}
} catch (error) {
if (error instanceof DoiClientException) {
throw error;
}
throw new Error(`Failed to update DataCite record: ${error.message}`);
}
}
/**
* Shows detailed statistics for a dataset that needs updating
*/
private async showDatasetStats(dataset: Dataset): Promise<void> {
try {
let doiIdentifier = dataset.identifier;
if (!doiIdentifier) {
await dataset.load('identifier');
doiIdentifier = dataset.identifier;
}
const doiValue = doiIdentifier?.value || 'N/A';
const doiStatus = doiIdentifier?.status || 'N/A';
const datasetModified = dataset.server_date_modified;
// Get DOI info from DataCite
const doiClient = new DoiClient();
const doiLastModified = await doiClient.getDoiLastModified(doiValue);
const doiState = await doiClient.getDoiState(doiValue);
console.log(`
Dataset ${dataset.publish_id}
DOI Value: ${doiValue}
DOI Status (DB): ${doiStatus}
DOI State (DataCite): ${doiState || 'Unknown'}
Dataset Modified: ${datasetModified ? datasetModified.toISO() : 'N/A'}
DOI Modified: ${doiLastModified ? DateTime.fromJSDate(doiLastModified).toISO() : 'N/A'}
Needs Update: YES - Dataset newer than DOI
`);
} catch (error) {
console.log(`
Dataset ${dataset.publish_id}
DOI Value: ${dataset.identifier?.value || 'N/A'}
Error: ${error.message}
Needs Update: YES - Error checking status
`);
}
}
}

18
components.d.ts vendored
View file

@ -11,3 +11,21 @@ declare module '@vue/runtime-core' {
NInput: (typeof import('naive-ui'))['NInput'];
}
}
// types/leaflet-src-dom-DomEvent.d.ts
declare module 'leaflet/src/dom/DomEvent' {
export type DomEventHandler = (e?: any) => void;
// Attach event listeners. `obj` can be any DOM node or object with event handling.
export function on(obj: any, types: string, fn: DomEventHandler, context?: any): void;
// Detach event listeners.
export function off(obj: any, types: string, fn?: DomEventHandler, context?: any): void;
// Prevent default on native events
export function preventDefault(ev?: Event | undefined): void;
// Optional: other helpers you might need later
export function stopPropagation(ev?: Event | undefined): void;
export function stop(ev?: Event | undefined): void;
}

View file

@ -128,7 +128,7 @@ allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
| projects/:id/file
| ```
*/
processManually: [],
processManually: ['/submitter/dataset/submit', '/submitter/dataset/:id/update'],
/*
|--------------------------------------------------------------------------
@ -185,8 +185,8 @@ allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
| and fields data.
|
*/
// limit: '20mb',
limit: env.get('UPLOAD_LIMIT', '513mb'),
limit: '513mb',
//limit: env.get('UPLOAD_LIMIT', '513mb'),
/*
|--------------------------------------------------------------------------

View file

@ -1,6 +1,7 @@
import { defineConfig } from '@adonisjs/inertia';
import type { HttpContext } from '@adonisjs/core/http';
import type { InferSharedProps } from '@adonisjs/inertia/types'
import type { InferSharedProps } from '@adonisjs/inertia/types';
import env from '#start/env';
const inertiaConfig = defineConfig({
/**
@ -21,6 +22,8 @@ const inertiaConfig = defineConfig({
return ctx.session?.flashMessages.get('user_id');
},
opensearch_host: env.get('OPENSEARCH_HOST'),
flash: (ctx) => {
return {
message: ctx.session?.flashMessages.get('message'),
@ -31,15 +34,15 @@ const inertiaConfig = defineConfig({
// params: ({ params }) => params,
authUser: async ({ auth }: HttpContext) => {
if (auth?.user) {
await auth.user.load('roles');
return auth.user;
// {
// 'id': auth.user.id,
// 'login': auth.user.login,
// };
} else {
return null;
if (!auth?.user) return null
await auth.user.load('roles') // sicherstellen, dass geladen ist
return {
id: auth.user.id,
login: auth.user.login,
email: auth.user.email,
first_name: auth.user.first_name,
last_name: auth.user.last_name,
roles: auth.user.roles.map((role) => role.name),
}
},
},
@ -56,8 +59,8 @@ const inertiaConfig = defineConfig({
export default inertiaConfig
declare module '@adonisjs/inertia/types' {
export interface SharedProps extends InferSharedProps<typeof inertiaConfig> {}
}
export interface SharedProps extends InferSharedProps<typeof inertiaConfig> { }
}
// import { InertiaConfig } from '@ioc:EidelLev/Inertia';

View file

@ -16,7 +16,7 @@ const mailConfig = defineConfig({
host: env.get('SMTP_HOST', ''),
port: env.get('SMTP_PORT'),
secure: false,
// ignoreTLS: true,
ignoreTLS: true,
requireTLS: false,
/**

View file

@ -96,7 +96,7 @@ const sessionConfig = defineConfig({
* variable in order to infer the store name without any
* errors.
*/
store: env.get('SESSION_DRIVER'),
store: 'file', //env.get('SESSION_DRIVER'),
/*
|--------------------------------------------------------------------------
| Configuration for the file driver
@ -122,6 +122,9 @@ const sessionConfig = defineConfig({
// redisConnection: 'local',
stores: {
cookie: stores.cookie(),
file: stores.file({
location: './tmp/sessions', // Where the data will live
}),
},
});
export default sessionConfig;

View file

@ -21,6 +21,7 @@ export enum ServerStates {
rejected_reviewer = 'rejected_reviewer',
rejected_editor = 'rejected_editor',
reviewed = 'reviewed',
rejected_to_reviewer = 'rejected_to_reviewer',
}
// for table dataset_titles

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
import { BaseSchema } from '@adonisjs/lucid/schema';
export default class extends BaseSchema {
protected tableName = 'activities';
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id');
table.string('type').notNullable().index(); // 'dataset.uploaded', 'auth.login'
table.integer('user_id').unsigned().nullable().references('id').inTable('accounts').onDelete('SET NULL');
table.string('subject_type').nullable(); // manual morph: model name
table.bigInteger('subject_id').unsigned().nullable();
table.string('description').notNullable();
table.json('properties').nullable();
table.timestamp('created_at');
table.timestamp('updated_at');
table.index(['subject_type', 'subject_id'])
table.index('created_at')
});
}
async down() {
this.schema.dropTable(this.tableName);
}
}

View file

@ -86,3 +86,22 @@ export default class Documents extends BaseSchema {
// CONSTRAINT documents_server_state_check CHECK (server_state::text = ANY (ARRAY['deleted'::character varying::text, 'inprogress'::character varying::text, 'published'::character varying::text, 'released'::character varying::text, 'editor_accepted'::character varying::text, 'approved'::character varying::text, 'rejected_reviewer'::character varying::text, 'rejected_editor'::character varying::text, 'reviewed'::character varying::text])),
// CONSTRAINT documents_type_check CHECK (type::text = ANY (ARRAY['analysisdata'::character varying::text, 'measurementdata'::character varying::text, 'monitoring'::character varying::text, 'remotesensing'::character varying::text, 'gis'::character varying::text, 'models'::character varying::text, 'mixedtype'::character varying::text]))
// )
// ALTER TABLE documents DROP CONSTRAINT documents_server_state_check;
// ALTER TABLE documents
// ADD CONSTRAINT documents_server_state_check CHECK (
// server_state::text = ANY (ARRAY[
// 'deleted',
// 'inprogress',
// 'published',
// 'released',
// 'editor_accepted',
// 'approved',
// 'rejected_reviewer',
// 'rejected_editor',
// 'reviewed',
// 'rejected_to_reviewer' -- new value added
// ]::text[])
// );

View file

@ -32,3 +32,21 @@ export default class CollectionsRoles extends BaseSchema {
// visible_oai boolean NOT NULL DEFAULT true,
// CONSTRAINT collections_roles_pkey PRIMARY KEY (id)
// )
// change to normal intzeger:
// ALTER TABLE collections_roles ALTER COLUMN id DROP DEFAULT;
// DROP SEQUENCE IF EXISTS collections_roles_id_seq;
// -- Step 1: Temporarily change one ID to a value not currently used
// UPDATE collections_roles SET id = 99 WHERE name = 'ccs';
// -- Step 2: Change 'ddc' ID to 2 (the old 'ccs' ID)
// UPDATE collections_roles SET id = 2 WHERE name = 'ddc';
// -- Step 3: Change the temporary ID (99) to 3 (the old 'ddc' ID)
// UPDATE collections_roles SET id = 3 WHERE name = 'ccs';
// UPDATE collections_roles SET id = 99 WHERE name = 'bk';
// UPDATE collections_roles SET id = 1 WHERE name = 'institutes';
// UPDATE collections_roles SET id = 4 WHERE name = 'pacs';
// UPDATE collections_roles SET id = 7 WHERE name = 'bk';

View file

@ -5,7 +5,7 @@ export default class Collections extends BaseSchema {
public async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').defaultTo("nextval('collections_id_seq')");
table.increments('id');//.defaultTo("nextval('collections_id_seq')");
table.integer('role_id').unsigned();
table
.foreign('role_id', 'collections_role_id_foreign')
@ -25,6 +25,8 @@ export default class Collections extends BaseSchema {
.onUpdate('CASCADE');
table.boolean('visible').notNullable().defaultTo(true);
table.boolean('visible_publish').notNullable().defaultTo(true);
table.integer('left_id').unsigned();
table.integer('right_id').unsigned();
});
}
@ -59,3 +61,26 @@ export default class Collections extends BaseSchema {
// change to normal intzeger:
// ALTER TABLE collections ALTER COLUMN id DROP DEFAULT;
// DROP SEQUENCE IF EXISTS collections_id_seq;
// ALTER TABLE collections
// ADD COLUMN left_id INTEGER;
// COMMENT ON COLUMN collections.left_id IS 'comment';
// ALTER TABLE collections
// ADD COLUMN right_id INTEGER;
// COMMENT ON COLUMN collections.right_id IS 'comment';
// -- Step 1: Drop the existing default
// ALTER TABLE collections
// ALTER COLUMN visible DROP DEFAULT,
// ALTER COLUMN visible_publish DROP DEFAULT;
// -- Step 2: Change column types with proper casting
// ALTER TABLE collections
// ALTER COLUMN visible TYPE smallint USING CASE WHEN visible THEN 1 ELSE 0 END,
// ALTER COLUMN visible_publish TYPE smallint USING CASE WHEN visible_publish THEN 1 ELSE 0 END;
// -- Step 3: Set new defaults as smallint
// ALTER TABLE collections
// ALTER COLUMN visible SET DEFAULT 1,
// ALTER COLUMN visible_publish SET DEFAULT 1;

View file

@ -1,47 +1,74 @@
#!/bin/bash
# # Run freshclam to update virus definitions
# freshclam
# # Sleep for a few seconds to give ClamAV time to start
# sleep 5
# # Start the ClamAV daemon
# /etc/init.d/clamav-daemon start
# bootstrap clam av service and clam av database updater
set -m
function process_file() {
if [[ ! -z "$1" ]]; then
local SETTING_LIST=$(echo "$1" | tr ',' '\n' | grep "^[A-Za-z][A-Za-z]*=.*$")
local SETTING
for SETTING in ${SETTING_LIST}; do
# Remove any existing copies of this setting. We do this here so that
# settings with multiple values (e.g. ExtraDatabase) can still be added
# multiple times below
local KEY=${SETTING%%=*}
sed -i $2 -e "/^${KEY} /d"
done
echo "Starting ClamAV services..."
for SETTING in ${SETTING_LIST}; do
# Split on first '='
local KEY=${SETTING%%=*}
local VALUE=${SETTING#*=}
echo "${KEY} ${VALUE}" >> "$2"
done
fi
}
# process_file "${CLAMD_SETTINGS_CSV}" /etc/clamav/clamd.conf
# process_file "${FRESHCLAM_SETTINGS_CSV}" /etc/clamav/freshclam.conf
# Try to download database if missing
# if [ ! "$(ls -A /var/lib/clamav 2>/dev/null)" ]; then
# echo "Downloading ClamAV database (this may take a while)..."
# # Simple freshclam run without complex config
# if freshclam --datadir=/var/lib/clamav --quiet; then
# echo "✓ Database downloaded successfully"
# else
# echo "⚠ Database download failed - creating minimal setup"
# # Create a dummy file so clamd doesn't immediately fail
# touch /var/lib/clamav/.dummy
# fi
# fi
# Start freshclam daemon for automatic updates
echo "Starting freshclam daemon for automatic updates..."
# sg clamav -c "freshclam -d" &
# Added --daemon-notify to freshclam - This notifies clamd when the database updates
freshclam -d --daemon-notify=/etc/clamav/clamd.conf &
#freshclam -d &
# start in background
freshclam -d &
# /etc/init.d/clamav-freshclam start &
clamd
# Start clamd in background
# Start clamd in foreground (so dumb-init can supervise it)
# /etc/init.d/clamav-daemon start &
# change back to CMD of dockerfile
# Give freshclam a moment to start
sleep 2
# Start clamd daemon in background using sg
echo "Starting ClamAV daemon..."
# sg clamav -c "clamd" &
# Use sg to run clamd with proper group permissions
# sg clamav -c "clamd" &
# clamd --config-file=/etc/clamav/clamd.conf &
clamd &
# Give services time to start
echo "Waiting for services to initialize..."
sleep 8
# simple check
if pgrep clamd > /dev/null; then
echo "✓ ClamAV daemon is running"
else
echo "⚠ ClamAV daemon status uncertain, but continuing..."
fi
# Check if freshclam daemon is running
if pgrep freshclam > /dev/null; then
echo "✓ Freshclam daemon is running"
else
echo "⚠ Freshclam daemon status uncertain, but continuing..."
fi
# # Optional: Test socket connectivity
# if [ -S /var/run/clamav/clamd.socket ]; then
# echo "✓ ClamAV socket exists"
# else
# echo "⚠ WARNING: ClamAV socket not found - services may still be starting"
# fi
# # change back to CMD of dockerfile
echo "✓ ClamAV setup complete"
echo "Starting main application..."
# exec dumb-init -- "$@"
exec "$@"

View file

@ -0,0 +1,278 @@
# Dataset Indexing Command
AdonisJS Ace command for indexing and synchronizing published datasets with OpenSearch for search functionality.
## Overview
The `index:datasets` command processes published datasets and creates/updates corresponding search index documents in OpenSearch. It intelligently compares modification timestamps to only re-index datasets when necessary, optimizing performance while maintaining search index accuracy.
## Command Syntax
```bash
node ace index:datasets [options]
```
## Options
| Flag | Alias | Description |
|------|-------|-------------|
| `--publish_id <number>` | `-p` | Index a specific dataset by publish_id |
## Usage Examples
### Basic Operations
```bash
# Index all published datasets that have been modified since last indexing
node ace index:datasets
# Index a specific dataset by publish_id
node ace index:datasets --publish_id 231
node ace index:datasets -p 231
```
## How It Works
### 1. **Dataset Selection**
The command processes datasets that meet these criteria:
- `server_state = 'published'` - Only published datasets
- Has preloaded `xmlCache` relationship for metadata transformation
- Optionally filtered by specific `publish_id`
### 2. **Smart Update Detection**
For each dataset, the command:
- Checks if the dataset exists in the OpenSearch index
- Compares `server_date_modified` timestamps
- Only re-indexes if the dataset is newer than the indexed version
### 3. **Document Processing**
The indexing process involves:
1. **XML Generation**: Creates structured XML from dataset metadata
2. **XSLT Transformation**: Converts XML to JSON using Saxon-JS processor
3. **Index Update**: Updates or creates the document in OpenSearch
4. **Logging**: Records success/failure for each operation
## Index Structure
### Index Configuration
- **Index Name**: `tethys-records`
- **Document ID**: Dataset `publish_id`
- **Refresh**: `true` (immediate availability)
### Document Fields
The indexed documents contain:
- **Metadata Fields**: Title, description, authors, keywords
- **Identifiers**: DOI, publish_id, and other identifiers
- **Temporal Data**: Publication dates, coverage periods
- **Geographic Data**: Spatial coverage information
- **Technical Details**: Data formats, access information
- **Timestamps**: Creation and modification dates
## Example Output
### Successful Run
```bash
node ace index:datasets
```
```
Found 150 published datasets to process
Dataset with publish_id 231 successfully indexed
Dataset with publish_id 245 is up to date, skipping indexing
Dataset with publish_id 267 successfully indexed
An error occurred while indexing dataset with publish_id 289. Error: Invalid XML metadata
Processing completed: 148 indexed, 1 skipped, 1 error
```
### Specific Dataset
```bash
node ace index:datasets --publish_id 231
```
```
Found 1 published dataset to process
Dataset with publish_id 231 successfully indexed
Processing completed: 1 indexed, 0 skipped, 0 errors
```
## Update Logic
The command uses intelligent indexing to avoid unnecessary processing:
| Condition | Action | Reason |
|-----------|--------|--------|
| Dataset not in index | ✅ Index | New dataset needs indexing |
| Dataset newer than indexed version | ✅ Re-index | Dataset has been updated |
| Dataset same/older than indexed version | ❌ Skip | Already up to date |
| OpenSearch document check fails | ✅ Index | Better safe than sorry |
| Invalid XML metadata | ❌ Skip + Log Error | Cannot process invalid data |
### Timestamp Comparison
```typescript
// Example comparison logic
const existingModified = DateTime.fromMillis(Number(existingDoc.server_date_modified) * 1000);
const currentModified = dataset.server_date_modified;
if (currentModified <= existingModified) {
// Skip - already up to date
return false;
}
// Proceed with indexing
```
## XML Transformation Process
### 1. **XML Generation**
```xml
<?xml version="1.0" encoding="UTF-8" standalone="true"?>
<root>
<Dataset>
<!-- Dataset metadata fields -->
<title>Research Dataset Title</title>
<description>Dataset description...</description>
<!-- Additional metadata -->
</Dataset>
</root>
```
### 2. **XSLT Processing**
The command uses Saxon-JS with a compiled stylesheet (`solr.sef.json`) to transform XML to JSON:
```javascript
const result = await SaxonJS.transform({
stylesheetText: proc,
destination: 'serialized',
sourceText: xmlString,
});
```
### 3. **Final JSON Document**
```json
{
"id": "231",
"title": "Research Dataset Title",
"description": "Dataset description...",
"authors": ["Author Name"],
"server_date_modified": 1634567890,
"publish_id": 231
}
```
## Configuration Requirements
### Environment Variables
```bash
# OpenSearch Configuration
OPENSEARCH_HOST=localhost:9200
# For production:
# OPENSEARCH_HOST=your-opensearch-cluster:9200
```
### Required Files
- **XSLT Stylesheet**: `public/assets2/solr.sef.json` - Compiled Saxon-JS stylesheet for XML transformation
### Database Relationships
The command expects these model relationships:
```typescript
// Dataset model must have:
@hasOne(() => XmlCache, { foreignKey: 'dataset_id' })
public xmlCache: HasOne<typeof XmlCache>
```
## Error Handling
The command handles various error scenarios gracefully:
### Common Errors and Solutions
| Error | Cause | Solution |
|-------|-------|----------|
| `XSLT transformation failed` | Invalid XML or missing stylesheet | Check XML structure and stylesheet path |
| `OpenSearch connection error` | Service unavailable | Verify OpenSearch is running and accessible |
| `JSON parse error` | Malformed transformation result | Check XSLT stylesheet output format |
| `Missing xmlCache relationship` | Data integrity issue | Ensure xmlCache exists for dataset |
### Error Logging
```bash
# Typical error log entry
An error occurred while indexing dataset with publish_id 231.
Error: XSLT transformation failed: Invalid XML structure at line 15
```
## Performance Considerations
### Batch Processing
- Processes datasets sequentially to avoid overwhelming OpenSearch
- Each dataset is committed individually for reliability
- Failed indexing of one dataset doesn't stop processing others
### Resource Usage
- **Memory**: XML/JSON transformations require temporary memory
- **Network**: OpenSearch API calls for each dataset
- **CPU**: XSLT transformations are CPU-intensive
### Optimization Tips
```bash
# Index only recently modified datasets (run regularly)
node ace index:datasets
# Index specific datasets when needed
node ace index:datasets --publish_id 231
# Consider running during off-peak hours for large batches
```
## Integration with Other Systems
### Search Functionality
The indexed documents power:
- **Dataset Search**: Full-text search across metadata
- **Faceted Browsing**: Filter by authors, keywords, dates
- **Geographic Search**: Spatial query capabilities
- **Auto-complete**: Suggest dataset titles and keywords
### Related Commands
- [`update:datacite`](update-datacite.md) - Often run after indexing to sync DOI metadata
- **Database migrations** - May require re-indexing after schema changes
### API Integration
The indexed data is consumed by:
- **Search API**: `/api/search` endpoints
- **Browse API**: `/api/datasets` with filtering
- **Recommendations**: Related dataset suggestions
## Monitoring and Maintenance
### Regular Tasks
```bash
# Daily indexing (recommended cron job)
0 2 * * * cd /path/to/project && node ace index:datasets
# Weekly full re-index (if needed)
0 3 * * 0 cd /path/to/project && node ace index:datasets --force
```
### Health Checks
- Monitor OpenSearch cluster health
- Check for failed indexing operations in logs
- Verify search functionality is working
- Compare dataset counts between database and index
### Troubleshooting
```bash
# Check specific dataset indexing
node ace index:datasets --publish_id 231
# Verify OpenSearch connectivity
curl -X GET "localhost:9200/_cluster/health"
# Check index statistics
curl -X GET "localhost:9200/tethys-records/_stats"
```
## Best Practices
1. **Regular Scheduling**: Run the command regularly (daily) to keep the search index current
2. **Monitor Logs**: Watch for transformation errors or OpenSearch issues
3. **Backup Strategy**: Include OpenSearch indices in backup procedures
4. **Resource Management**: Monitor OpenSearch cluster resources during bulk operations
5. **Testing**: Verify search functionality after major indexing operations
6. **Coordination**: Run indexing before DataCite updates when both are needed

View file

@ -0,0 +1,216 @@
# DataCite Update Command
AdonisJS Ace command for updating DataCite DOI records for published datasets.
## Overview
The `update:datacite` command synchronizes your local dataset metadata with DataCite DOI records. It intelligently compares modification dates to only update records when necessary, reducing unnecessary API calls and maintaining data consistency.
## Command Syntax
```bash
node ace update:datacite [options]
```
## Options
| Flag | Alias | Description |
|------|-------|-------------|
| `--publish_id <number>` | `-p` | Update a specific dataset by publish_id |
| `--force` | `-f` | Force update all records regardless of modification date |
| `--dry-run` | `-d` | Preview what would be updated without making changes |
| `--stats` | `-s` | Show detailed statistics for datasets that need updating |
## Usage Examples
### Basic Operations
```bash
# Update all datasets that have been modified since their DOI was last updated
node ace update:datacite
# Update a specific dataset
node ace update:datacite --publish_id 231
node ace update:datacite -p 231
# Force update all datasets with DOIs (ignores modification dates)
node ace update:datacite --force
```
### Preview and Analysis
```bash
# Preview what would be updated (dry run)
node ace update:datacite --dry-run
# Show detailed statistics for datasets that need updating
node ace update:datacite --stats
# Show stats for a specific dataset
node ace update:datacite --stats --publish_id 231
```
### Combined Options
```bash
# Dry run for a specific dataset
node ace update:datacite --dry-run --publish_id 231
# Show stats for all datasets (including up-to-date ones)
node ace update:datacite --stats --force
```
## Command Modes
### 1. **Normal Mode** (Default)
Updates DataCite records for datasets that have been modified since their DOI was last updated.
**Example Output:**
```
Using DataCite API: https://api.test.datacite.org
Found 50 datasets to process
Dataset 231: Successfully updated DataCite record
Dataset 245: Up to date, skipping
Dataset 267: Successfully updated DataCite record
DataCite update completed. Updated: 15, Skipped: 35, Errors: 0
```
### 2. **Dry Run Mode** (`--dry-run`)
Shows what would be updated without making any changes to DataCite.
**Use Case:** Preview updates before running the actual command.
**Example Output:**
```
Dataset 231: Would update DataCite record (dry run)
Dataset 267: Would update DataCite record (dry run)
Dataset 245: Up to date, skipping
DataCite update completed. Updated: 2, Skipped: 1, Errors: 0
```
### 3. **Stats Mode** (`--stats`)
Shows detailed information for each dataset that needs updating, including why it needs updating.
**Use Case:** Debug synchronization issues, monitor dataset/DOI status, generate reports.
**Example Output:**
```
┌─ Dataset 231 ─────────────────────────────────────────────────────────
│ DOI Value: 10.21388/tethys.231
│ DOI Status (DB): findable
│ DOI State (DataCite): findable
│ Dataset Modified: 2024-09-15T10:30:00.000Z
│ DOI Modified: 2024-09-10T08:15:00.000Z
│ Needs Update: YES - Dataset newer than DOI
└───────────────────────────────────────────────────────────────────────
┌─ Dataset 267 ─────────────────────────────────────────────────────────
│ DOI Value: 10.21388/tethys.267
│ DOI Status (DB): findable
│ DOI State (DataCite): findable
│ Dataset Modified: 2024-09-18T14:20:00.000Z
│ DOI Modified: 2024-09-16T12:45:00.000Z
│ Needs Update: YES - Dataset newer than DOI
└───────────────────────────────────────────────────────────────────────
DataCite Stats Summary: 2 datasets need updating, 48 are up to date
```
## Update Logic
The command uses intelligent update detection:
1. **Compares modification dates**: Dataset `server_date_modified` vs DOI last modification date from DataCite
2. **Validates data integrity**: Checks for missing or future dates
3. **Handles API failures gracefully**: Updates anyway if DataCite info can't be retrieved
4. **Uses dual API approach**: DataCite REST API (primary) with MDS API fallback
### When Updates Happen
| Condition | Action | Reason |
|-----------|--------|--------|
| Dataset modified > DOI modified | ✅ Update | Dataset has newer changes |
| Dataset modified ≤ DOI modified | ❌ Skip | DOI is up to date |
| Dataset date in future | ❌ Skip | Invalid data, needs investigation |
| Dataset date missing | ✅ Update | Can't determine staleness |
| DataCite API error | ✅ Update | Better safe than sorry |
| `--force` flag used | ✅ Update | Override all logic |
## Environment Configuration
Required environment variables:
```bash
# DataCite Credentials
DATACITE_USERNAME=your_username
DATACITE_PASSWORD=your_password
# API Endpoints (environment-specific)
DATACITE_API_URL=https://api.test.datacite.org # Test environment
DATACITE_SERVICE_URL=https://mds.test.datacite.org # Test MDS
DATACITE_API_URL=https://api.datacite.org # Production
DATACITE_SERVICE_URL=https://mds.datacite.org # Production MDS
# Project Configuration
DATACITE_PREFIX=10.21388 # Your DOI prefix
BASE_DOMAIN=tethys.at # Your domain
```
## Error Handling
The command handles various error scenarios:
- **Invalid modification dates**: Logs errors but continues processing other datasets
- **DataCite API failures**: Falls back to MDS API, then to safe update
- **Missing DOI identifiers**: Skips datasets without DOI identifiers
- **Network issues**: Continues with next dataset after logging error
## Integration
The command integrates with:
- **Dataset Model**: Uses `server_date_modified` for change detection
- **DatasetIdentifier Model**: Reads DOI values and status
- **OpenSearch Index**: Updates search index after DataCite update
- **DoiClient**: Handles all DataCite API interactions
## Common Workflows
### Daily Maintenance
```bash
# Update any datasets modified today
node ace update:datacite
```
### Pre-Deployment Check
```bash
# Check what would be updated before deployment
node ace update:datacite --dry-run
```
### Debugging Sync Issues
```bash
# Investigate why specific dataset isn't syncing
node ace update:datacite --stats --publish_id 231
```
### Full Resync
```bash
# Force update all DOI records (use with caution)
node ace update:datacite --force
```
### Monitoring Report
```bash
# Generate sync status report
node ace update:datacite --stats > datacite-sync-report.txt
```
## Best Practices
1. **Regular Updates**: Run daily or after bulk dataset modifications
2. **Test First**: Use `--dry-run` or `--stats` before bulk operations
3. **Monitor Logs**: Check for data integrity warnings
4. **Environment Separation**: Use correct API URLs for test vs production
5. **Rate Limiting**: The command handles DataCite rate limits automatically

View file

@ -1,229 +1,47 @@
##
## Example config file for freshclam
## Please read the freshclam.conf(5) manual before editing this file.
## Container-optimized freshclam configuration
##
# Comment or remove the line below.
# Path to the database directory.
# WARNING: It must match clamd.conf's directive!
# Default: hardcoded (depends on installation options)
# Database directory
DatabaseDirectory /var/lib/clamav
# Path to the log file (make sure it has proper permissions)
# Default: disabled
# Log to stdout for container logging
# UpdateLogFile /dev/stdout
# Maximum size of the log file.
# Value of 0 disables the limit.
# You may use 'M' or 'm' for megabytes (1M = 1m = 1048576 bytes)
# and 'K' or 'k' for kilobytes (1K = 1k = 1024 bytes).
# in bytes just don't use modifiers. If LogFileMaxSize is enabled,
# log rotation (the LogRotate option) will always be enabled.
# Default: 1M
#LogFileMaxSize 2M
# Log time with each message.
# Default: no
# Basic logging settings
LogTime yes
# Enable verbose logging.
# Default: no
LogVerbose yes
# Use system logger (can work together with UpdateLogFile).
# Default: no
LogSyslog no
# Specify the type of syslog messages - please refer to 'man syslog'
# for facility names.
# Default: LOG_LOCAL6
#LogFacility LOG_MAIL
# Enable log rotation. Always enabled when LogFileMaxSize is enabled.
# Default: no
#LogRotate yes
# This option allows you to save the process identifier of the daemon
# Default: disabled
#PidFile /var/run/freshclam.pid
# PID file location
PidFile /var/run/clamav/freshclam.pid
# By default when started freshclam drops privileges and switches to the
# "clamav" user. This directive allows you to change the database owner.
# Default: clamav (may depend on installation options)
# Database owner
DatabaseOwner node
# Use DNS to verify virus database version. Freshclam uses DNS TXT records
# to verify database and software versions. With this directive you can change
# the database verification domain.
# WARNING: Do not touch it unless you're configuring freshclam to use your
# own database verification domain.
# Default: current.cvd.clamav.net
#DNSDatabaseInfo current.cvd.clamav.net
# Uncomment the following line and replace XY with your country
# code. See http://www.iana.org/cctld/cctld-whois.htm for the full list.
# You can use db.XY.ipv6.clamav.net for IPv6 connections.
# Mirror settings for Austria
DatabaseMirror db.at.clamav.net
# database.clamav.net is a round-robin record which points to our most
# reliable mirrors. It's used as a fall back in case db.XY.clamav.net is
# not working. DO NOT TOUCH the following line unless you know what you
# are doing.
DatabaseMirror database.clamav.net
# How many attempts to make before giving up.
# Default: 3 (per mirror)
#MaxAttempts 5
# With this option you can control scripted updates. It's highly recommended
# to keep it enabled.
# Default: yes
#ScriptedUpdates yes
# By default freshclam will keep the local databases (.cld) uncompressed to
# make their handling faster. With this option you can enable the compression;
# the change will take effect with the next database update.
# Default: no
#CompressLocalDatabase no
# With this option you can provide custom sources (http:// or file://) for
# database files. This option can be used multiple times.
# Default: no custom URLs
#DatabaseCustomURL http://myserver.com/mysigs.ndb
#DatabaseCustomURL file:///mnt/nfs/local.hdb
# This option allows you to easily point freshclam to private mirrors.
# If PrivateMirror is set, freshclam does not attempt to use DNS
# to determine whether its databases are out-of-date, instead it will
# use the If-Modified-Since request or directly check the headers of the
# remote database files. For each database, freshclam first attempts
# to download the CLD file. If that fails, it tries to download the
# CVD file. This option overrides DatabaseMirror, DNSDatabaseInfo
# and ScriptedUpdates. It can be used multiple times to provide
# fall-back mirrors.
# Default: disabled
#PrivateMirror mirror1.mynetwork.com
#PrivateMirror mirror2.mynetwork.com
# Update settings
ScriptedUpdates yes
# Number of database checks per day.
# Default: 12 (every two hours)
#Checks 24
Checks 12
# Proxy settings
# Default: disabled
#HTTPProxyServer myproxy.com
#HTTPProxyPort 1234
#HTTPProxyUsername myusername
#HTTPProxyPassword mypass
# If your servers are behind a firewall/proxy which applies User-Agent
# filtering you can use this option to force the use of a different
# User-Agent header.
# Default: clamav/version_number
#HTTPUserAgent SomeUserAgentIdString
# Use aaa.bbb.ccc.ddd as client address for downloading databases. Useful for
# multi-homed systems.
# Default: Use OS'es default outgoing IP address.
#LocalIPAddress aaa.bbb.ccc.ddd
# Send the RELOAD command to clamd.
# Default: no
#NotifyClamd /path/to/clamd.conf
# Run command after successful database update.
# Default: disabled
#OnUpdateExecute command
# Run command when database update process fails.
# Default: disabled
#OnErrorExecute command
# Run command when freshclam reports outdated version.
# In the command string %v will be replaced by the new version number.
# Default: disabled
#OnOutdatedExecute command
# Don't fork into background.
# Default: no
# Don't fork (good for containers)
Foreground no
# Enable debug messages in libclamav.
# Default: no
#Debug yes
# Connection timeouts
ConnectTimeout 60
ReceiveTimeout 60
# Timeout in seconds when connecting to database server.
# Default: 30
#ConnectTimeout 60
# Test databases before using them
TestDatabases yes
# Timeout in seconds when reading from database server.
# Default: 30
#ReceiveTimeout 60
# With this option enabled, freshclam will attempt to load new
# databases into memory to make sure they are properly handled
# by libclamav before replacing the old ones.
# Default: yes
#TestDatabases yes
# When enabled freshclam will submit statistics to the ClamAV Project about
# the latest virus detections in your environment. The ClamAV maintainers
# will then use this data to determine what types of malware are the most
# detected in the field and in what geographic area they are.
# Freshclam will connect to clamd in order to get recent statistics.
# Default: no
#SubmitDetectionStats /path/to/clamd.conf
# Country of origin of malware/detection statistics (for statistical
# purposes only). The statistics collector at ClamAV.net will look up
# your IP address to determine the geographical origin of the malware
# reported by your installation. If this installation is mainly used to
# scan data which comes from a different location, please enable this
# option and enter a two-letter code (see http://www.iana.org/domains/root/db/)
# of the country of origin.
# Default: disabled
#DetectionStatsCountry country-code
# This option enables support for our "Personal Statistics" service.
# When this option is enabled, the information on malware detected by
# your clamd installation is made available to you through our website.
# To get your HostID, log on http://www.stats.clamav.net and add a new
# host to your host list. Once you have the HostID, uncomment this option
# and paste the HostID here. As soon as your freshclam starts submitting
# information to our stats collecting service, you will be able to view
# the statistics of this clamd installation by logging into
# http://www.stats.clamav.net with the same credentials you used to
# generate the HostID. For more information refer to:
# http://www.clamav.net/documentation.html#cctts
# This feature requires SubmitDetectionStats to be enabled.
# Default: disabled
#DetectionStatsHostID unique-id
# This option enables support for Google Safe Browsing. When activated for
# the first time, freshclam will download a new database file (safebrowsing.cvd)
# which will be automatically loaded by clamd and clamscan during the next
# reload, provided that the heuristic phishing detection is turned on. This
# database includes information about websites that may be phishing sites or
# possible sources of malware. When using this option, it's mandatory to run
# freshclam at least every 30 minutes.
# Freshclam uses the ClamAV's mirror infrastructure to distribute the
# database and its updates but all the contents are provided under Google's
# terms of use. See http://www.google.com/transparencyreport/safebrowsing
# and http://www.clamav.net/documentation.html#safebrowsing
# for more information.
# Default: disabled
#SafeBrowsing yes
# This option enables downloading of bytecode.cvd, which includes additional
# detection mechanisms and improvements to the ClamAV engine.
# Default: enabled
#Bytecode yes
# Download an additional 3rd party signature database distributed through
# the ClamAV mirrors.
# This option can be used multiple times.
#ExtraDatabase dbname1
#ExtraDatabase dbname2
# Enable bytecode signatures
Bytecode yes

6
index.d.ts vendored
View file

@ -183,3 +183,9 @@ declare module 'saxon-js' {
export function transform(options: ITransformOptions): Promise<ITransformOutput> | ITransformOutput;
}
declare global {
interface File {
sort_order?: number;
}
}

6614
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -39,7 +39,7 @@
"@types/clamscan": "^2.0.4",
"@types/escape-html": "^1.0.4",
"@types/fs-extra": "^11.0.4",
"@types/leaflet": "^1.9.16",
"@types/leaflet": "^1.9.21",
"@types/luxon": "^3.4.2",
"@types/node": "^22.10.2",
"@types/proxy-addr": "^2.0.0",
@ -56,9 +56,8 @@
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-adonis": "^2.1.1",
"eslint-plugin-prettier": "^5.0.0-alpha.2",
"hot-hook": "^0.4.0",
"numeral": "^2.0.6",
"pinia": "^2.0.30",
"pinia": "^3.0.2",
"pino-pretty": "^13.0.0",
"postcss-loader": "^8.1.1",
"prettier": "^3.4.2",
@ -66,20 +65,21 @@
"tailwindcss": "^3.4.17",
"ts-loader": "^9.4.2",
"ts-node-maintained": "^10.9.5",
"typescript": "~5.7",
"typescript": "^5.9.3",
"vite": "^6.0.11",
"vue": "^3.4.26",
"vue-facing-decorator": "^3.0.0",
"vue-facing-decorator": "^4.0.1",
"vue-loader": "^17.0.1",
"webpack-dev-server": "^5.1.0",
"xslt3": "^2.5.0"
},
"dependencies": {
"@adonisjs/auth": "^9.2.4",
"@adonisjs/core": "^6.17.0",
"@adonisjs/bodyparser": "^10.0.1",
"@adonisjs/core": "^6.21.0",
"@adonisjs/cors": "^2.2.1",
"@adonisjs/drive": "^3.2.0",
"@adonisjs/inertia": "^2.1.3",
"@adonisjs/inertia": "^3.1.1",
"@adonisjs/lucid": "^21.5.1",
"@adonisjs/mail": "^9.2.2",
"@adonisjs/redis": "^9.1.0",
@ -91,7 +91,7 @@
"@fontsource/archivo-black": "^5.0.1",
"@fontsource/inter": "^5.0.1",
"@inertiajs/inertia": "^0.11.1",
"@inertiajs/vue3": "^2.0.3",
"@inertiajs/vue3": "^2.3.25",
"@opensearch-project/opensearch": "^3.2.0",
"@phc/format": "^1.0.0",
"@poppinss/manager": "^5.0.2",
@ -114,13 +114,14 @@
"node-exceptions": "^4.0.1",
"notiwind": "^2.0.0",
"pg": "^8.9.0",
"phc-argon2": "^1.1.4",
"qrcode": "^1.5.3",
"redis": "^4.6.10",
"redis": "^6.0.0",
"reflect-metadata": "^0.2.1",
"saxon-js": "^2.5.0",
"toastify-js": "^1.12.0",
"vuedraggable": "^4.1.0",
"xmlbuilder2": "^3.1.1"
"xmlbuilder2": "^4.0.3"
},
"hotHook": {
"boundaries": [

View file

@ -0,0 +1,34 @@
import { ApplicationService } from '@adonisjs/core/types';
export default class RuleProvider {
constructor(protected app: ApplicationService) {}
public register() {
// Register your own bindings
}
public async boot() {
// IoC container is ready
// await import("../src/rules/index.js");
await import('#start/rules/unique');
await import('#start/rules/translated_language');
await import('#start/rules/unique_person');
// () => import('#start/rules/file_length'),
// () => import('#start/rules/file_scan'),
// () => import('#start/rules/allowed_extensions_mimetypes'),
await import('#start/rules/dependent_array_min_length');
await import('#start/rules/referenceValidation');
await import('#start/rules/valid_mimetype');
await import('#start/rules/array_contains_types');
await import('#start/rules/orcid');
}
public async ready() {
// App is ready
}
public async shutdown() {
// Cleanup, since app is going down
}
}

View file

@ -6,17 +6,16 @@
import type { ApplicationService } from '@adonisjs/core/types';
import vine, { symbols, BaseLiteralType, Vine } from '@vinejs/vine';
import type { FieldContext, FieldOptions } from '@vinejs/vine/types';
// import type { MultipartFile, FileValidationOptions } from '@adonisjs/bodyparser/types';
import type { MultipartFile } from '@adonisjs/core/bodyparser';
import type { FileValidationOptions } from '@adonisjs/core/types/bodyparser';
import { Request, RequestValidator } from '@adonisjs/core/http';
import MimeType from '#models/mime_type';
/**
* Validation options accepted by the "file" rule
*/
export type FileRuleValidationOptions = Partial<FileValidationOptions> | ((field: FieldContext) => Partial<FileValidationOptions>);
/**
* Extend VineJS
*/
@ -25,6 +24,7 @@ declare module '@vinejs/vine' {
myfile(options?: FileRuleValidationOptions): VineMultipartFile;
}
}
/**
* Extend HTTP request class
*/
@ -36,19 +36,54 @@ declare module '@adonisjs/core/http' {
* Checks if the value is an instance of multipart file
* from bodyparser.
*/
export function isBodyParserFile(file: MultipartFile | unknown): boolean {
export function isBodyParserFile(file: MultipartFile | unknown): file is MultipartFile {
return !!(file && typeof file === 'object' && 'isMultipartFile' in file);
}
export async function getEnabledExtensions() {
const enabledExtensions = await MimeType.query().select('file_extension').where('enabled', true).exec();
const extensions = enabledExtensions
.map((extension) => {
return extension.file_extension.split('|');
})
.flat();
return extensions;
/**
* Cache for enabled extensions to reduce database queries
*/
let extensionsCache: string[] | null = null;
let cacheTimestamp = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
/**
* Get enabled extensions with caching
*/
export async function getEnabledExtensions(): Promise<string[]> {
const now = Date.now();
if (extensionsCache && now - cacheTimestamp < CACHE_DURATION) {
return extensionsCache;
}
try {
const enabledExtensions = await MimeType.query().select('file_extension').where('enabled', true).exec();
const extensions = enabledExtensions
.map((extension) => extension.file_extension.split('|'))
.flat()
.map((ext) => ext.toLowerCase().trim())
.filter((ext) => ext.length > 0);
extensionsCache = [...new Set(extensions)]; // Remove duplicates
cacheTimestamp = now;
return extensionsCache;
} catch (error) {
console.error('Error fetching enabled extensions:', error);
return extensionsCache || [];
}
}
/**
* Clear extensions cache
*/
export function clearExtensionsCache(): void {
extensionsCache = null;
cacheTimestamp = 0;
}
/**
* VineJS validation rule that validates the file to be an
* instance of BodyParser MultipartFile class.
@ -65,6 +100,7 @@ const isMultipartFile = vine.createRule(async (file: MultipartFile | unknown, op
// At this point, you can use type assertion to explicitly tell TypeScript that file is of type MultipartFile
const validatedFile = file as MultipartFile;
const validationOptions = typeof options === 'function' ? options(field) : options;
/**
* Set size when it's defined in the options and missing
* on the file instance
@ -72,30 +108,29 @@ const isMultipartFile = vine.createRule(async (file: MultipartFile | unknown, op
if (validatedFile.sizeLimit === undefined && validationOptions.size) {
validatedFile.sizeLimit = validationOptions.size;
}
/**
* Set extensions when it's defined in the options and missing
* on the file instance
*/
// if (validatedFile.allowedExtensions === undefined && validationOptions.extnames) {
// validatedFile.allowedExtensions = validationOptions.extnames;
// }
if (validatedFile.allowedExtensions === undefined && validationOptions.extnames !== undefined) {
validatedFile.allowedExtensions = validationOptions.extnames; // await getEnabledExtensions();
} else if (validatedFile.allowedExtensions === undefined && validationOptions.extnames === undefined) {
validatedFile.allowedExtensions = await getEnabledExtensions();
if (validatedFile.allowedExtensions === undefined) {
if (validationOptions.extnames !== undefined) {
validatedFile.allowedExtensions = validationOptions.extnames;
} else {
validatedFile.allowedExtensions = await getEnabledExtensions();
}
}
/**
* wieder löschen
* Set extensions when it's defined in the options and missing
* on the file instance
*/
// if (file.clientNameSizeLimit === undefined && validationOptions.clientNameSizeLimit) {
// file.clientNameSizeLimit = validationOptions.clientNameSizeLimit;
// }
/**
* Validate file
*/
validatedFile.validate();
try {
validatedFile.validate();
} catch (error) {
field.report(`File validation failed: ${error.message}`, 'file.validation_error', field, validationOptions);
return;
}
/**
* Report errors
*/
@ -107,36 +142,37 @@ const isMultipartFile = vine.createRule(async (file: MultipartFile | unknown, op
const MULTIPART_FILE: typeof symbols.SUBTYPE = symbols.SUBTYPE;
export class VineMultipartFile extends BaseLiteralType<MultipartFile, MultipartFile, MultipartFile> {
[MULTIPART_FILE]: string;
// constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions) {
// super(options, [isMultipartFile(validationOptions || {})]);
// this.validationOptions = validationOptions;
// this.#private = true;
// }
// clone(): this {
// return new VineMultipartFile(this.validationOptions, this.cloneOptions()) as this;
// }
// #private;
// constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions, validations?: Validation<any>[]);
// clone(): this;
public validationOptions;
public validationOptions?: FileRuleValidationOptions;
// extnames: (18) ['gpkg', 'htm', 'html', 'csv', 'txt', 'asc', 'c', 'cc', 'h', 'srt', 'tiff', 'pdf', 'png', 'zip', 'jpg', 'jpeg', 'jpe', 'xlsx']
// size: '512mb'
// public constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions, validations?: Validation<any>[]) {
public constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions) {
// super(options, validations);
super(options, [isMultipartFile(validationOptions || {})]);
this.validationOptions = validationOptions;
}
public clone(): any {
// return new VineMultipartFile(this.validationOptions, this.cloneOptions(), this.cloneValidations());
return new VineMultipartFile(this.validationOptions, this.cloneOptions());
}
/**
* Set maximum file size
*/
public maxSize(size: string | number): this {
const newOptions = { ...this.validationOptions, size };
return new VineMultipartFile(newOptions, this.cloneOptions()) as this;
}
/**
* Set allowed extensions
*/
public extensions(extnames: string[]): this {
const newOptions = { ...this.validationOptions, extnames };
return new VineMultipartFile(newOptions, this.cloneOptions()) as this;
}
}
export default class VinejsProvider {
@ -155,13 +191,8 @@ export default class VinejsProvider {
/**
* The container bindings have booted
*/
boot(): void {
// VineString.macro('translatedLanguage', function (this: VineString, options: Options) {
// return this.use(translatedLanguageRule(options));
// });
Vine.macro('myfile', function (this: Vine, options) {
Vine.macro('myfile', function (this: Vine, options?: FileRuleValidationOptions) {
return new VineMultipartFile(options);
});
@ -175,6 +206,41 @@ export default class VinejsProvider {
}
return new RequestValidator(this.ctx).validateUsing(...args);
});
// Ensure MIME validation macros are loaded
this.loadMimeValidationMacros();
this.loadFileScanMacros();
this.loadFileLengthMacros();
}
/**
* Load MIME validation macros - called during boot to ensure they're available
*/
private async loadMimeValidationMacros(): Promise<void> {
try {
// Dynamically import the MIME validation rule to ensure macros are registered
await import('#start/rules/allowed_extensions_mimetypes');
} catch (error) {
console.warn('Could not load MIME validation macros:', error);
}
}
private async loadFileScanMacros(): Promise<void> {
try {
// Dynamically import the MIME validation rule to ensure macros are registered
await import('#start/rules/file_scan');
} catch (error) {
console.warn('Could not load MIME validation macros:', error);
}
}
private async loadFileLengthMacros(): Promise<void> {
try {
// Dynamically import the MIME validation rule to ensure macros are registered
await import('#start/rules/file_length');
} catch (error) {
console.warn('Could not load MIME validation macros:', error);
}
}
/**
@ -190,5 +256,7 @@ export default class VinejsProvider {
/**
* Preparing to shutdown the app
*/
async shutdown() {}
async shutdown() {
clearExtensionsCache();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

File diff suppressed because one or more lines are too long

View file

@ -62,15 +62,19 @@
</xsl:choose>
<!--<datacite:creator>-->
<creators>
<xsl:apply-templates select="PersonAuthor" mode="oai_datacite">
<xsl:sort select="@SortOrder"/>
</xsl:apply-templates>
</creators>
<titles>
<xsl:apply-templates select="TitleMain" mode="oai_datacite" />
<xsl:apply-templates select="TitleAdditional" mode="oai_datacite" />
</titles>
<xsl:if test="PersonAuthor[normalize-space(concat(@FirstName, @LastName)) != '']">
<creators>
<xsl:apply-templates select="PersonAuthor" mode="oai_datacite">
<xsl:sort select="@SortOrder"/>
</xsl:apply-templates>
</creators>
</xsl:if>
<xsl:if test="TitleMain[normalize-space(@Value) != ''] or TitleAdditional[normalize-space(@Value) != '']">
<titles>
<xsl:apply-templates select="TitleMain" mode="oai_datacite" />
<xsl:apply-templates select="TitleAdditional" mode="oai_datacite" />
</titles>
</xsl:if>
<publisher>
<!-- <xsl:value-of select="@PublisherName" /> -->
<xsl:value-of select="@CreatingCorporation" />
@ -78,22 +82,26 @@
<publicationYear>
<xsl:value-of select="ServerDatePublished/@Year" />
</publicationYear>
<subjects>
<xsl:apply-templates select="Subject" mode="oai_datacite" />
</subjects>
<xsl:if test="Subject[normalize-space(@Value) != '']">
<subjects>
<xsl:apply-templates select="Subject" mode="oai_datacite" />
</subjects>
</xsl:if>
<language>
<xsl:value-of select="@Language" />
</language>
<xsl:if test="PersonContributor">
<xsl:if test="PersonContributor[normalize-space(concat(@FirstName, @LastName)) != '']">
<contributors>
<xsl:apply-templates select="PersonContributor" mode="oai_datacite">
<xsl:sort select="@SortOrder"/>
</xsl:apply-templates>
</contributors>
</xsl:if>
<dates>
<xsl:call-template name="RdrDate2" />
</dates>
<xsl:if test="(EmbargoDate and ($unixTimestamp &lt; EmbargoDate/@UnixTimestamp)) or CreatedAt">
<dates>
<xsl:call-template name="RdrDate2" />
</dates>
</xsl:if>
<version>
<xsl:choose>
<xsl:when test="@Version">
@ -109,42 +117,46 @@
<!-- <xsl:value-of select="@Type" /> -->
</resourceType>
<alternateIdentifiers>
<xsl:call-template name="AlternateIdentifier" />
</alternateIdentifiers>
<xsl:if test="normalize-space(@landingpage) != ''">
<alternateIdentifiers>
<xsl:call-template name="AlternateIdentifier" />
</alternateIdentifiers>
</xsl:if>
<xsl:if test="Reference">
<xsl:if test="Reference[normalize-space(@Type) != '' and normalize-space(@Relation) != '']">
<relatedIdentifiers>
<xsl:apply-templates select="Reference" mode="oai_datacite" />
</relatedIdentifiers>
</xsl:if>
<rightsList>
<xsl:apply-templates select="Licence" mode="oai_datacite" />
</rightsList>
<sizes>
<size>
<xsl:value-of select="count(File)" />
<xsl:text> datasets</xsl:text>
</size>
</sizes>
<formats>
<xsl:apply-templates select="File/@MimeType" mode="oai_datacite" />
</formats>
<descriptions>
<xsl:apply-templates select="TitleAbstract" mode="oai_datacite" />
<xsl:apply-templates select="TitleAbstractAdditional" mode="oai_datacite" />
</descriptions>
<geoLocations>
<xsl:apply-templates select="Coverage" mode="oai_datacite" />
<!-- <geoLocation>
<geoLocationBox>
<westBoundLongitude>6.58987</westBoundLongitude>
<eastBoundLongitude>6.83639</eastBoundLongitude>
<southBoundLatitude>50.16</southBoundLatitude>
<northBoundLatitude>50.18691</northBoundLatitude>
</geoLocationBox>
</geoLocation> -->
</geoLocations>
<xsl:if test="Licence[normalize-space(@Name) != '' or normalize-space(@Url) != '']">
<rightsList>
<xsl:apply-templates select="Licence" mode="oai_datacite" />
</rightsList>
</xsl:if>
<xsl:if test="File">
<sizes>
<size>
<xsl:value-of select="count(File)" />
<xsl:text> datasets</xsl:text>
</size>
</sizes>
</xsl:if>
<xsl:if test="File[normalize-space(@MimeType) != '']">
<formats>
<xsl:apply-templates select="File/@MimeType" mode="oai_datacite" />
</formats>
</xsl:if>
<xsl:if test="TitleAbstract[normalize-space(@Value) != ''] or TitleAbstractAdditional[normalize-space(@Value) != '']">
<descriptions>
<xsl:apply-templates select="TitleAbstract" mode="oai_datacite" />
<xsl:apply-templates select="TitleAbstractAdditional" mode="oai_datacite" />
</descriptions>
</xsl:if>
<xsl:if test="Coverage[normalize-space(@XMin) != '' and normalize-space(@XMax) != '' and normalize-space(@YMin) != '' and normalize-space(@YMax) != '']">
<geoLocations>
<xsl:apply-templates select="Coverage" mode="oai_datacite" />
</geoLocations>
</xsl:if>
</resource>
</xsl:template>
@ -176,54 +188,54 @@
<xsl:template match="Coverage" mode="oai_datacite"
xmlns="http://datacite.org/schema/kernel-4">
<geoLocation>
<geoLocationBox>
<westBoundLongitude>
<xsl:value-of select="@XMin" />
</westBoundLongitude>
<eastBoundLongitude>
<xsl:value-of select="@XMax" />
</eastBoundLongitude>
<southBoundLatitude>
<xsl:value-of select="@YMin" />
</southBoundLatitude>
<northBoundLatitude>
<xsl:value-of select="@YMax" />
</northBoundLatitude>
</geoLocationBox>
</geoLocation>
<xsl:if test="normalize-space(@XMin) != '' and normalize-space(@XMax) != '' and normalize-space(@YMin) != '' and normalize-space(@YMax) != ''">
<geoLocation>
<geoLocationBox>
<westBoundLongitude><xsl:value-of select="@XMin" /></westBoundLongitude>
<eastBoundLongitude><xsl:value-of select="@XMax" /></eastBoundLongitude>
<southBoundLatitude><xsl:value-of select="@YMin" /></southBoundLatitude>
<northBoundLatitude><xsl:value-of select="@YMax" /></northBoundLatitude>
</geoLocationBox>
</geoLocation>
</xsl:if>
</xsl:template>
<!-- TitleAbstract template -->
<xsl:template match="TitleAbstract" mode="oai_datacite"
xmlns="http://datacite.org/schema/kernel-4">
<description>
<xsl:attribute name="xml:lang">
<xsl:value-of select="@Language" />
</xsl:attribute>
<xsl:if test="@Type != ''">
<xsl:attribute name="descriptionType">
<!-- <xsl:value-of select="@Type" /> -->
<xsl:text>Abstract</xsl:text>
<xsl:if test="normalize-space(@Value) != ''">
<description>
<xsl:attribute name="xml:lang">
<xsl:value-of select="@Language" />
</xsl:attribute>
</xsl:if>
<xsl:value-of select="@Value" />
</description>
<xsl:if test="@Type != ''">
<xsl:attribute name="descriptionType">
<xsl:text>Abstract</xsl:text>
</xsl:attribute>
</xsl:if>
<xsl:value-of select="@Value" />
</description>
</xsl:if>
</xsl:template>
<!-- TitleAbstractAdditional template -->
<xsl:template match="TitleAbstractAdditional" mode="oai_datacite"
xmlns="http://datacite.org/schema/kernel-4">
<description>
<xsl:attribute name="xml:lang">
<xsl:value-of select="@Language" />
</xsl:attribute>
<xsl:if test="@Type != ''">
<xsl:attribute name="descriptionType">
<xsl:call-template name="CamelCaseWord">
<xsl:with-param name="text" select="@Type" />
</xsl:call-template>
<xsl:if test="normalize-space(@Value) != ''">
<description>
<xsl:attribute name="xml:lang">
<xsl:value-of select="@Language" />
</xsl:attribute>
</xsl:if>
<xsl:value-of select="@Value" />
</description>
<xsl:if test="@Type != ''">
<xsl:attribute name="descriptionType">
<xsl:call-template name="CamelCaseWord">
<xsl:with-param name="text" select="@Type" />
</xsl:call-template>
</xsl:attribute>
</xsl:if>
<xsl:value-of select="@Value" />
</description>
</xsl:if>
</xsl:template>
<xsl:template name="CamelCaseWord">
@ -256,6 +268,7 @@
<xsl:template match="TitleMain" mode="oai_datacite"
xmlns="http://datacite.org/schema/kernel-4">
<xsl:if test="normalize-space(@Value) != ''">
<title>
<xsl:if test="@Language != ''">
<xsl:attribute name="xml:lang">
@ -269,9 +282,12 @@
</xsl:if>
<xsl:value-of select="@Value" />
</title>
</xsl:if>
</xsl:template>
<xsl:template match="TitleAdditional" mode="oai_datacite"
xmlns="http://datacite.org/schema/kernel-4">
<xsl:if test="normalize-space(@Value) != ''">
<title>
<xsl:if test="@Language != ''">
<xsl:attribute name="xml:lang">
@ -294,61 +310,70 @@
</xsl:choose>
<xsl:value-of select="@Value" />
</title>
</xsl:if>
</xsl:template>
<xsl:template match="Subject" mode="oai_datacite"
xmlns="http://datacite.org/schema/kernel-4">
<subject>
<xsl:if test="@Language != ''">
<xsl:attribute name="xml:lang">
<xsl:value-of select="@Language" />
</xsl:attribute>
</xsl:if>
<xsl:value-of select="@Value" />
</subject>
<xsl:if test="normalize-space(@Value) != ''">
<subject>
<xsl:if test="@Language != ''">
<xsl:attribute name="xml:lang">
<xsl:value-of select="@Language" />
</xsl:attribute>
</xsl:if>
<xsl:value-of select="@Value" />
</subject>
</xsl:if>
</xsl:template>
<xsl:template name="AlternateIdentifier" match="AlternateIdentifier" mode="oai_datacite"
xmlns="http://datacite.org/schema/kernel-4">
<alternateIdentifier>
<xsl:attribute name="alternateIdentifierType">
<xsl:text>url</xsl:text>
</xsl:attribute>
<!-- <xsl:variable name="identifier" select="concat($repURL, '/dataset/', @Id)" /> -->
<xsl:value-of select="@landingpage" />
</alternateIdentifier>
<xsl:if test="normalize-space(@landingpage) != ''">
<alternateIdentifier>
<xsl:attribute name="alternateIdentifierType">
<xsl:text>url</xsl:text>
</xsl:attribute>
<xsl:value-of select="@landingpage" />
</alternateIdentifier>
</xsl:if>
</xsl:template>
<xsl:template match="Reference" mode="oai_datacite"
xmlns="http://datacite.org/schema/kernel-4">
<relatedIdentifier>
<xsl:attribute name="relatedIdentifierType">
<xsl:value-of select="@Type" />
</xsl:attribute>
<xsl:attribute name="relationType">
<xsl:value-of select="@Relation" />
</xsl:attribute>
<xsl:value-of select="@Value" />
</relatedIdentifier>
<xsl:if test="normalize-space(@Type) != '' and normalize-space(@Relation) != ''">
<relatedIdentifier>
<xsl:attribute name="relatedIdentifierType">
<xsl:value-of select="@Type" />
</xsl:attribute>
<xsl:attribute name="relationType">
<xsl:value-of select="@Relation" />
</xsl:attribute>
<xsl:value-of select="@Value" />
</relatedIdentifier>
</xsl:if>
</xsl:template>
<!-- PersonContributor template -->
<xsl:template match="PersonContributor" mode="oai_datacite"
xmlns="http://datacite.org/schema/kernel-4">
<contributor>
<xsl:if test="@ContributorType != ''">
<xsl:attribute name="contributorType">
<xsl:value-of select="@ContributorType" />
</xsl:attribute>
</xsl:if>
<contributorName>
<!-- <xsl:if test="@NameType != ''">
<xsl:attribute name="nameType">
<xsl:value-of select="@NameType" />
</xsl:attribute>
</xsl:if> -->
<xsl:value-of select="concat(@FirstName, ' ',@LastName)" />
</contributorName>
</contributor>
<xsl:if test="normalize-space(concat(@FirstName, @LastName)) != ''">
<contributor>
<xsl:if test="@ContributorType != ''">
<xsl:attribute name="contributorType">
<xsl:value-of select="@ContributorType" />
</xsl:attribute>
</xsl:if>
<contributorName>
<!-- <xsl:if test="@NameType != ''">
<xsl:attribute name="nameType">
<xsl:value-of select="@NameType" />
</xsl:attribute>
</xsl:if> -->
<xsl:value-of select="concat(@FirstName, ' ', @LastName)" />
</contributorName>
</contributor>
</xsl:if>
</xsl:template>
<xsl:template match="PersonAuthor" mode="oai_datacite"
@ -403,9 +428,11 @@
<xsl:template match="File/@MimeType" mode="oai_datacite"
xmlns="http://datacite.org/schema/kernel-4">
<format>
<xsl:value-of select="." />
</format>
<xsl:if test="normalize-space(.) != ''">
<format>
<xsl:value-of select="." />
</format>
</xsl:if>
</xsl:template>
<xsl:template match="Licence" mode="oai_datacite"

File diff suppressed because one or more lines are too long

View file

@ -111,7 +111,14 @@
<!--5 server_date_modified -->
<xsl:if test="ServerDateModified/@UnixTimestamp != ''">
<xsl:text>"server_date_modified": "</xsl:text>
<xsl:value-of select="/ServerDateModified/@UnixTimestamp" />
<xsl:value-of select="ServerDateModified/@UnixTimestamp" />
<xsl:text>",</xsl:text>
</xsl:if>
<!--5 embargo_date -->
<xsl:if test="EmbargoDate/@UnixTimestamp != ''">
<xsl:text>"embargo_date": "</xsl:text>
<xsl:value-of select="EmbargoDate/@UnixTimestamp" />
<xsl:text>",</xsl:text>
</xsl:if>
@ -200,7 +207,8 @@
<!--17 +18 uncontrolled subject (swd) -->
<xsl:variable name="subjects">
<xsl:for-each select="Subject[@Type = 'Uncontrolled']">
<!-- <xsl:for-each select="Subject[@Type = 'Uncontrolled']"> -->
<xsl:for-each select="Subject[@Type = 'Uncontrolled' or @Type = 'Geoera']">
<xsl:text>"</xsl:text>
<xsl:value-of select="fn:escapeQuotes(@Value)"/>
<xsl:text>"</xsl:text>

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://sea1.geoinformation.dev/favicon-32x32.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

9
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 952 KiB

1
public/site.webmanifest Normal file
View file

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

174
readme.md
View file

@ -11,6 +11,8 @@ Welcome to the Tethys Research Repository Backend System! This is the backend co
- [Configuration](#configuration)
- [Database](#database)
- [API Documentation](#api-documentation)
- [Commands](#commands)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [License](#license)
@ -29,5 +31,175 @@ Before you begin, ensure you have met the following requirements:
1. Clone this repository:
```bash
git clone https://gitea.geologie.ac.at/geolba/tethys.backend.git
git clone git clone https://gitea.geologie.ac.at/geolba/tethys.backend.git
cd tethys-backend
```
2. Install dependencies:
```bash
npm install
```
3. Configure environment variables (see [Configuration](#configuration))
4. Run database migrations:
```bash
node ace migration:run
```
5. Start the development server:
```bash
npm run dev
```
## Usage
The Tethys Backend provides RESTful APIs for managing research datasets, user authentication, DOI registration, and search functionality.
## Configuration
Copy the `.env.example` file to `.env` and configure the following variables:
### Database Configuration
```bash
DB_CONNECTION=pg
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_username
DB_PASSWORD=your_password
DB_DATABASE=tethys_db
```
### DataCite Configuration
```bash
# DataCite Credentials
DATACITE_USERNAME=your_datacite_username
DATACITE_PASSWORD=your_datacite_password
DATACITE_PREFIX=10.21388
# Environment-specific API endpoints
DATACITE_API_URL=https://api.test.datacite.org # Test environment
DATACITE_SERVICE_URL=https://mds.test.datacite.org # Test MDS
# For production:
# DATACITE_API_URL=https://api.datacite.org
# DATACITE_SERVICE_URL=https://mds.datacite.org
```
### OpenSearch Configuration
```bash
OPENSEARCH_HOST=localhost:9200
```
### Application Configuration
```bash
BASE_DOMAIN=tethys.at
APP_KEY=your_app_key
```
## Database
The system uses PostgreSQL with Lucid ORM. Key models include:
- **Dataset**: Research dataset metadata
- **DatasetIdentifier**: DOI and other identifiers for datasets
- **User**: User management and authentication
- **XmlCache**: Cached XML metadata
Run migrations and seeders:
```bash
# Run migrations
node ace migration:run
# Run seeders (if available)
node ace db:seed
```
## API Documentation
API endpoints are available for:
- Dataset management (`/api/datasets`)
- User authentication (`/api/auth`)
- DOI registration (`/api/doi`)
- Search functionality (`/api/search`)
*Detailed API documentation can be found in the `/docs/api` directory.*
## Commands
The system includes several Ace commands for maintenance and data management:
### Dataset Indexing
```bash
# Index all published datasets to OpenSearch
node ace index:datasets
# Index a specific dataset
node ace index:datasets --publish_id 123
```
### DataCite DOI Management
```bash
# Update DataCite records for modified datasets
node ace update:datacite
# Show detailed statistics for datasets needing updates
node ace update:datacite --stats
# Preview what would be updated (dry run)
node ace update:datacite --dry-run
# Force update all DOI records
node ace update:datacite --force
# Update a specific dataset
node ace update:datacite --publish_id 123
```
*For detailed command documentation, see the [Commands Documentation](docs/commands/)*
## Documentation
Comprehensive documentation is available in the `/docs` directory:
- **[Commands Documentation](docs/commands/)** - Detailed guides for Ace commands
- [DataCite Update Command](docs/commands/update-datacite.md) - DOI synchronization and management
- [Dataset Indexing Command](docs/commands/index-datasets.md) - Search index management
- **[API Documentation](docs/api/)** - REST API endpoints and usage
- **[Deployment Guide](docs/deployment/)** - Production deployment instructions
- **[Configuration Guide](docs/configuration/)** - Environment setup and configuration options
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Development Guidelines
- Follow the existing code style and conventions
- Write tests for new features
- Update documentation for any API changes
- Ensure all commands and migrations work properly
### Testing Commands
```bash
# Run tests
npm test
# Test specific commands
node ace update:datacite --dry-run --publish_id 123
node ace index:datasets --publish_id 123
```
## License
This project is licensed under the [MIT License](LICENSE).

View file

@ -1,7 +1,7 @@
/* @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500&display=swap'); */
/* @import url('https://fonts.googleapis.com/css?family=Roboto:400,400i,600,700'); */
@import '_checkbox-radio-switch.css';
/* @import '_checkbox-radio-switch.css'; */
@import '_progress.css';
@import '_scrollbars.css';
@import '_table.css';

View file

@ -102,7 +102,11 @@ const activeStyle = computed(() => {
const hasRoles = computed(() => {
if (props.item.roles) {
return user.value.roles.some(role => props.item.roles?.includes(role.name));
// Normalize user roles to strings in case roles are objects
const userRoles = (user.value.roles || []).map(r =>
typeof r === 'string' ? r : (r as any).name ?? String(r)
);
return userRoles.some(role => props.item.roles?.includes(role));
// return test;
}
return true

View file

@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
<script lang="ts" setup>
import { computed, PropType } from 'vue';
import { Link } from '@inertiajs/vue3';
// import { Link } from '@inertiajs/inertia-vue3';
import { getButtonColor } from '@/colors';
@ -30,8 +30,8 @@ const props = defineProps({
type: String,
default: null,
},
color: {
type: String,
color: {
type: String as PropType<'white' | 'contrast' | 'light' | 'success' | 'danger' | 'warning' | 'info' | 'modern'>,
default: 'white',
},
as: {
@ -45,11 +45,18 @@ const props = defineProps({
roundedFull: Boolean,
});
const emit = defineEmits(['click']);
const is = computed(() => {
if (props.as) {
return props.as;
}
// If disabled, always render as button or span to prevent navigation
if (props.disabled) {
return props.routeName || props.href ? 'span' : 'button';
}
if (props.routeName) {
return Link;
}
@ -69,47 +76,105 @@ const computedType = computed(() => {
return null;
});
// Only provide href/routeName when not disabled
const computedHref = computed(() => {
if (props.disabled) return null;
return props.routeName || props.href;
});
// Only provide target when not disabled and has href
const computedTarget = computed(() => {
if (props.disabled || !props.href) return null;
return props.target;
});
// Only provide disabled attribute for actual button elements
const computedDisabled = computed(() => {
if (is.value === 'button') {
return props.disabled;
}
return null;
});
const labelClass = computed(() => (props.small && props.icon ? 'px-1' : 'px-2'));
const componentClass = computed(() => {
const base = [
'inline-flex',
'cursor-pointer',
'justify-center',
'items-center',
'whitespace-nowrap',
'focus:outline-none',
'transition-colors',
'focus:ring-2',
'duration-150',
'border',
props.roundedFull ? 'rounded-full' : 'rounded',
props.active ? 'ring ring-black dark:ring-white' : 'ring-blue-700',
getButtonColor(props.color, props.outline, !props.disabled),
];
// Only add focus ring styles when not disabled
if (!props.disabled) {
base.push('focus:ring-2');
base.push(props.active ? 'ring ring-black dark:ring-white' : 'ring-blue-700');
}
// Add button colors
// Add button colors - handle both string and array returns
// const buttonColors = getButtonColor(props.color, props.outline, !props.disabled);
base.push(getButtonColor(props.color, props.outline, !props.disabled));
// if (Array.isArray(buttonColors)) {
// base.push(...buttonColors);
// } else {
// base.push(buttonColors);
// }
// Add size classes
if (props.small) {
base.push('text-sm', props.roundedFull ? 'px-3 py-1' : 'p-1');
} else {
base.push('py-2', props.roundedFull ? 'px-6' : 'px-3');
}
// Add disabled/enabled specific classes
if (props.disabled) {
base.push('cursor-not-allowed', props.outline ? 'opacity-50' : 'opacity-70');
base.push(
'cursor-not-allowed',
'opacity-60',
'pointer-events-none', // This prevents all interactions
);
} else {
base.push('cursor-pointer');
// Add hover effects only when not disabled
if (is.value === 'button' || is.value === 'a' || is.value === Link) {
base.push('hover:opacity-80');
}
}
return base;
});
// Handle click events with disabled check
const handleClick = (event) => {
if (props.disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
emit('click', event);
};
</script>
<template>
<component
:is="is"
:class="componentClass"
:href="routeName ? routeName : href"
:href="computedHref"
:to="props.disabled ? null : props.routeName"
:type="computedType"
:target="target"
:disabled="disabled"
:target="computedTarget"
:disabled="computedDisabled"
:tabindex="props.disabled ? -1 : null"
:aria-disabled="props.disabled ? 'true' : null"
@click="handleClick"
>
<BaseIcon v-if="icon" :path="icon" />
<span v-if="label" :class="labelClass">{{ label }}</span>

View file

@ -67,7 +67,7 @@ const submit = (e) => {
<BaseIcon v-if="icon" :path="icon" class="mr-3" />
{{ title }}
</div>
<button v-if="showHeaderIcon" class="flex items-center py-3 px-4 justify-center ring-blue-700 focus:ring" @click="headerIconClick">
<button v-if="showHeaderIcon" class="flex items-center py-3 px-4 justify-center ring-blue-700 focus:ring" @click.stop="headerIconClick">
<BaseIcon :path="computedHeaderIcon" />
</button>
</header>

View file

@ -39,6 +39,10 @@ const props = defineProps({
type: String,
default: null,
},
allowEmailContact: {
type: Boolean,
default: false,
}
});
const pillType = computed(() => {
@ -81,9 +85,8 @@ const pillType = computed(() => {
<h4 class="text-xl text-ellipsis">
{{ name }}
</h4>
<p class="text-gray-500 dark:text-slate-400">
<!-- {{ date }} @ {{ login }} -->
{{ email }}
<p class="text-gray-500 dark:text-slate-400">
<div v-if="props.allowEmailContact"> {{ email }}</div>
</p>
</div>
</BaseLevel>

View file

@ -61,10 +61,10 @@ const cancel = () => confirmCancel('cancel');
<CardBox
v-show="value"
:title="title"
class="shadow-lg max-h-modal w-11/12 md:w-3/5 lg:w-2/5 xl:w-4/12 z-50"
class="p-4 shadow-lg max-h-modal w-11/12 md:w-3/5 lg:w-2/5 xl:w-4/12 z-50"
:header-icon="mdiClose"
modal
@header-icon-click="cancel"
@header-icon-click="cancel"
>
<div class="space-y-3">
<h1 v-if="largeTitle" class="text-2xl">

View file

@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue';
const props = defineProps({
title: {
type: String,
default: null,
},
icon: {
type: String,
default: null,
},
showHeaderIcon: {
type: Boolean,
default: true,
},
headerIcon: {
type: String,
default: null,
},
rounded: {
type: String,
default: 'rounded-xl',
},
hasFormData: Boolean,
empty: Boolean,
form: Boolean,
hoverable: Boolean,
modal: Boolean,
});
const emit = defineEmits(['header-icon-click', 'submit']);
const is = computed(() => (props.form ? 'form' : 'div'));
const slots = useSlots();
// const footer = computed(() => slots.footer && !!slots.footer());
const componentClass = computed(() => {
const base = [props.rounded, props.modal ? 'dark:bg-slate-900' : 'dark:bg-slate-900/70'];
if (props.hoverable) {
base.push('hover:shadow-lg transition-shadow duration-500');
}
return base;
});
// const headerIconClick = () => {
// emit('header-icon-click');
// };
// const submit = (e) => {
// emit('submit', e);
// };
</script>
<template>
<component :is="is" :class="componentClass" class="bg-white flex flex-col border border-gray-100 dark:border-slate-800 mb-4">
<div v-if="empty" class="text-center py-24 text-gray-500 dark:text-slate-400">
<p>Nothing's here</p>
</div>
<div v-else class="flex-1" :class="[!hasFormData && 'p-6']">
<slot />
</div>
</component>
</template>

View file

@ -1,4 +1,4 @@
<script setup>
<script lang="ts" setup>
import { mdiCog } from '@mdi/js';
import CardBox from '@/Components/CardBox.vue';
import NumberDynamic from '@/Components/NumberDynamic.vue';
@ -49,6 +49,9 @@ defineProps({
<PillTagTrend :trend="trend" :trend-type="trendType" small />
<BaseButton :icon="mdiCog" icon-w="w-4" icon-h="h-4" color="white" small />
</BaseLevel>
<BaseLevel v-else class="mb-3" mobile>
<BaseIcon v-if="icon" :path="icon" size="48" w="w-4" h="h-4" :class="color" />
</BaseLevel>
<BaseLevel mobile>
<div>
<h3 class="text-lg leading-tight text-gray-500 dark:text-slate-400">

View file

@ -1,40 +1,78 @@
<template>
<section aria-label="File Upload Modal"
<section
aria-label="File Upload Modal"
class="relative h-full flex flex-col bg-white dark:bg-slate-900/70 shadow-xl rounded-md"
v-on:dragenter="dragEnterHandler" v-on:dragleave="dragLeaveHandler" v-on:dragover="dragOverHandler"
v-on:drop="dropHandler">
v-on:dragenter="dragEnterHandler"
v-on:dragleave="dragLeaveHandler"
v-on:dragover="dragOverHandler"
v-on:drop="dropHandler"
>
<!-- overlay -->
<div id="overlay" ref="overlay"
class="w-full h-full absolute top-0 left-0 pointer-events-none z-50 flex flex-col items-center justify-center rounded-md">
<div
id="overlay"
ref="overlay"
class="w-full h-full absolute top-0 left-0 pointer-events-none z-50 flex flex-col items-center justify-center rounded-md"
>
<i>
<svg class="fill-current w-12 h-12 mb-3 text-blue-700" xmlns="http://www.w3.org/2000/svg" width="24"
height="24" viewBox="0 0 24 24">
<svg
class="fill-current w-12 h-12 mb-3 text-blue-700"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
d="M19.479 10.092c-.212-3.951-3.473-7.092-7.479-7.092-4.005 0-7.267 3.141-7.479 7.092-2.57.463-4.521 2.706-4.521 5.408 0 3.037 2.463 5.5 5.5 5.5h13c3.037 0 5.5-2.463 5.5-5.5 0-2.702-1.951-4.945-4.521-5.408zm-7.479-1.092l4 4h-3v4h-2v-4h-3l4-4z" />
d="M19.479 10.092c-.212-3.951-3.473-7.092-7.479-7.092-4.005 0-7.267 3.141-7.479 7.092-2.57.463-4.521 2.706-4.521 5.408 0 3.037 2.463 5.5 5.5 5.5h13c3.037 0 5.5-2.463 5.5-5.5 0-2.702-1.951-4.945-4.521-5.408zm-7.479-1.092l4 4h-3v4h-2v-4h-3l4-4z"
/>
</svg>
</i>
<p class="text-lg text-blue-700">Drop files to upload</p>
</div>
<!-- Loading Spinner when processing big files -->
<div v-if="isLoading" class="absolute inset-0 z-60 flex items-center justify-center bg-gray-500 bg-opacity-50">
<svg class="animate-spin h-12 w-12 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M12 2a10 10 0 0110 10h-4a6 6 0 00-6-6V2z"></path>
</svg>
</div>
<!-- scroll area -->
<div class="h-full p-8 w-full h-full flex flex-col">
<header class="flex items-center justify-center w-full">
<label for="dropzone-file"
class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600">
<label
for="dropzone-file"
class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600"
>
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<svg aria-hidden="true" class="w-10 h-10 mb-3 text-gray-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12">
</path>
<svg
aria-hidden="true"
class="w-10 h-10 mb-3 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
></path>
</svg>
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400">
<span class="font-semibold">Click to upload</span> or drag and drop
</p>
<!-- <p class="text-xs text-gray-500 dark:text-gray-400">SVG, PNG, JPG or GIF (MAX. 800x400px)</p> -->
</div>
<input id="dropzone-file" type="file" class="hidden" @change="onChangeFile" multiple="true" />
<input
id="dropzone-file"
type="file"
class="hidden"
@click="showSpinner"
@change="onChangeFile"
@cancel="cancelSpinner"
multiple="true"
/>
</label>
</header>
@ -98,17 +136,16 @@
</section>
</article> -->
<!-- :class="errors && errors[`files.${index}`] ? 'bg-red-400' : 'bg-gray-100'" -->
<article tabindex="0"
class="bg-gray-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm">
<section
class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
<article tabindex="0" class="bg-gray-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm">
<section class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
<h1 class="flex-1 text-gray-700 group-hover:text-blue-800">{{ element.name }}</h1>
<div class="flex">
<p class="p-1 size text-xs text-gray-700">{{ getFileSize(element) }}</p>
<p class="p-1 size text-xs text-gray-700">sort: {{ element.sort_order }}</p>
<button
class="delete ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
@click.prevent="removeFile(index)">
@click.prevent="removeFile(index)"
>
<DeleteIcon></DeleteIcon>
</button>
</div>
@ -122,11 +159,13 @@
<!--<ul id="deletetFiles"></ul> -->
<div>
<h1 v-if="deletetFiles.length > 0" class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">Files To
Delete</h1>
<h1 v-if="deletetFiles.length > 0" class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">Files To Delete</h1>
<ul id="deletetFiles" tag="ul" class="flex flex-1 flex-wrap -m-1">
<li v-for="(element, index) in deletetFiles" :key="index"
class="block p-1 w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/6 xl:w-1/8 h-24">
<li
v-for="(element, index) in deletetFiles"
:key="index"
class="block p-1 w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/6 xl:w-1/8 h-24"
>
<!-- <article
v-if="element.type.match('image.*')"
tabindex="0"
@ -151,17 +190,16 @@
</div>
</section>
</article> -->
<article tabindex="0"
class="bg-red-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm">
<section
class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
<article tabindex="0" class="bg-red-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm">
<section class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
<h1 class="flex-1 text-gray-700 group-hover:text-blue-800">{{ element.name }}</h1>
<div class="flex">
<!-- <p class="p-1 size text-xs text-gray-700">{{ getFileSize(element) }}</p> -->
<p class="p-1 size text-xs text-gray-700">{{ element.sort_order }}</p>
<button
class="delete ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
@click.prevent="reactivateFile(index)">
@click.prevent="reactivateFile(index)"
>
<RefreshIcon></RefreshIcon>
</button>
</div>
@ -174,17 +212,19 @@
<div v-if="fileErrors" class="flex flex-col mt-6 animate-fade-in" v-for="fileError in fileErrors">
<div class="bg-yellow-500 border-l-4 border-orange-400 text-white p-4" role="alert">
<p class="font-bold">Be Warned</p>
<p>{{ fileError.join(', ') }}</p>
<p>{{ formatError(fileError) }}</p>
</div>
</div>
</div>
<!-- sticky footer -->
<footer class="flex justify-end px-8 pb-8 pt-4">
<button id="cancel"
<button
v-if="showClearButton"
id="cancel"
class="ml-3 rounded-sm px-3 py-1 hover:bg-gray-300 focus:shadow-outline focus:outline-none"
@click="clearAllFiles">
@click="clearAllFiles"
>
Clear
</button>
</footer>
@ -238,9 +278,9 @@ interface InteriaPage {
},
})
class FileUploadComponent extends Vue {
@Ref('overlay') overlay: HTMLDivElement;
public isLoading: boolean = false;
private counter: number = 0;
// @Prop() files: Array<TestFile>;
@ -250,13 +290,18 @@ class FileUploadComponent extends Vue {
})
files: Array<TethysFile | File>;
@Prop({
type: Array<File>,
default: [],
})
filesToDelete: Array<TethysFile>;
@Prop({
type: Boolean,
default: true,
})
showClearButton: boolean;
// // deletetFiles: Array<TethysFile> = [];
get deletetFiles(): Array<TethysFile> {
return this.filesToDelete;
@ -264,7 +309,7 @@ class FileUploadComponent extends Vue {
set deletetFiles(values: Array<TethysFile>) {
// this.modelValue = value;
this.filesToDelete.length = 0;
this.filesToDelete.push(...values);
this.filesToDelete.push(...values);
}
get items(): Array<TethysFile | File> {
@ -318,6 +363,11 @@ class FileUploadComponent extends Vue {
++this.counter && this.overlay.classList.add('draggedover');
}
public formatError(error: string | string[] | undefined) {
if (!error) return '';
return Array.isArray(error) ? error.join(', ') : error;
}
public dragLeaveHandler() {
1 > --this.counter && this.overlay.classList.remove('draggedover');
}
@ -342,10 +392,10 @@ class FileUploadComponent extends Vue {
}
// reset counter and append file to gallery when file is dropped
public dropHandler(event: DragEvent): void {
event.preventDefault();
const dataTransfer = event.dataTransfer;
// let bigFileFound = false;
if (dataTransfer) {
for (const file of event.dataTransfer?.files) {
// let fileName = String(file.name.replace(/\.[^/.]+$/, ''));
@ -353,28 +403,72 @@ class FileUploadComponent extends Vue {
// if (file.type.match('image.*')) {
// this.generateURL(file);
// }
// if (file.size > 62914560) { // 60 MB in bytes
// bigFileFound = true;
// }
this._addFile(file);
}
this.overlay.classList.remove('draggedover');
this.counter = 0;
}
// if (bigFileFound) {
// this.isLoading = true;
// // Assume file processing delay; adjust timeout as needed or rely on async processing completion.
// setTimeout(() => {
// this.isLoading = false;
// }, 1500);
// }
}
public showSpinner() {
// event.preventDefault();
this.isLoading = true;
}
public cancelSpinner() {
// const target = event.target as HTMLInputElement;
// // If no files were selected, remove spinner
// if (!target.files || target.files.length === 0) {
// this.isLoading = false;
// }
this.isLoading = false;
}
public onChangeFile(event: Event) {
event.preventDefault();
let target = event.target as HTMLInputElement;
// let uploadedFile = event.target.files[0];
// let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, ''));
for (const file of event.target.files) {
// let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, ''));
// file.label = fileName;
// if (file.type.match('image.*')) {
// this.generateURL(file);
// }
this._addFile(file);
if (target && target.files) {
for (const file of event.target.files) {
// let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, ''));
// file.label = fileName;
// if (file.type.match('image.*')) {
// this.generateURL(file);
// }
// Immediately set spinner if any file is large (over 100 MB)
// for (const file of target.files) {
// if (file.size > 62914560) { // 100 MB
// bigFileFound = true;
// break;
// }
// }
// if (bigFileFound) {
// this.isLoading = true;
// }
this._addFile(file);
}
}
// if (bigFileFound) {
// this.isLoading = true;
// setTimeout(() => {
// this.isLoading = false;
// }, 1500);
// }
// this.overlay.classList.remove('draggedover');
this.counter = 0;
this.isLoading = false;
}
get errors(): IDictionary {
@ -396,7 +490,9 @@ class FileUploadComponent extends Vue {
public clearAllFiles(event: Event) {
event.preventDefault();
this.items.splice(0);
if (this.showClearButton == true) {
this.items.splice(0);
}
}
public removeFile(key: number) {
@ -445,7 +541,7 @@ class FileUploadComponent extends Vue {
let localUrl: string = '';
if (file instanceof File) {
localUrl = URL.createObjectURL(file as Blob);
}
}
// else if (file.fileData) {
// // const blob = new Blob([file.fileData]);
// // localUrl = URL.createObjectURL(blob);
@ -465,17 +561,6 @@ class FileUploadComponent extends Vue {
return localUrl;
}
// private async downloadFile(id: number): Promise<string> {
// const response = await axios.get<Blob>(`/api/download/${id}`, {
// responseType: 'blob',
// });
// const url = URL.createObjectURL(response.data);
// setTimeout(() => {
// URL.revokeObjectURL(url);
// }, 1000);
// return url;
// }
public getFileSize(file: File) {
if (file.size > 1024) {
if (file.size > 1048576) {
@ -488,17 +573,6 @@ class FileUploadComponent extends Vue {
}
}
// private _addFile(file) {
// // const isImage = file.type.match('image.*');
// // const objectURL = URL.createObjectURL(file);
// // this.files[objectURL] = file;
// // let test: TethysFile = { upload: file, label: "dfdsfs", sorting: 0 };
// // file.sorting = this.files.length;
// file.sort_order = (this.items.length + 1),
// this.files.push(file);
// }
private _addFile(file: File) {
// const reader = new FileReader();
// reader.onload = (event) => {
@ -530,14 +604,11 @@ class FileUploadComponent extends Vue {
// this.items.push(test);
this.items[this.items.length] = test;
} else {
file.sort_order = this.items.length + 1;
this.items.push(file);
}
}
// use to check if a file is being dragged
// private _hasFiles({ types = [] as Array<string> }) {
// return types.indexOf('Files') > -1;
// }
private _hasFiles(dataTransfer: DataTransfer | null): boolean {
return dataTransfer ? dataTransfer.items.length > 0 : false;
}

View file

@ -15,9 +15,10 @@ const year = computed(() => new Date().getFullYear());
<!-- Get more with <a href="https://tailwind-vue.justboil.me/" target="_blank" class="text-blue-600">Premium
version</a> -->
</div>
<div class="md:py-3">
<div class="md:py-1">
<a href="https://www.tethys.at" target="_blank">
<JustboilLogo class="w-auto h-8 md:h-6" />
<!-- <JustboilLogo class="w-auto h-8 md:h-6" /> -->
<JustboilLogo class="w-auto h-12 md:h-10 dark:invert" />
</a>
</div>
</BaseLevel>

View file

@ -1,43 +1,165 @@
<script setup>
import { computed } from 'vue';
<script setup lang="ts">
import { computed, watch, ref } from 'vue';
const props = defineProps({
name: {
type: String,
required: true,
},
type: {
type: String,
default: 'checkbox',
validator: (value) => ['checkbox', 'radio', 'switch'].includes(value),
},
label: {
type: String,
default: null,
},
modelValue: {
type: [Array, String, Number, Boolean],
default: null,
},
inputValue: {
type: [String, Number, Boolean],
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
interface Props {
name: string;
type?: 'checkbox' | 'radio' | 'switch';
label?: string | null;
modelValue: Array<any> | string | number | boolean | null;
inputValue: string | number | boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{ (e: 'update:modelValue', value: Props['modelValue']): void }>();
// const computedValue = computed({
// get: () => props.modelValue,
// set: (value) => {
// emit('update:modelValue', props.type === 'radio' ? [value] : value);
// },
// });
const computedValue = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value);
get: () => {
if (props.type === 'radio') {
// For radio buttons, return boolean indicating if this option is selected
if (Array.isArray(props.modelValue)) {
return props.modelValue;
}
return [props.modelValue];
} else {
// For checkboxes, return boolean indicating if this option is included
if (Array.isArray(props.modelValue)) {
return props.modelValue.includes(props.inputValue);
}
return props.modelValue == props.inputValue;
}
},
set: (value: boolean) => {
if (props.type === 'radio') {
// When radio is selected, emit the new value as array
emit('update:modelValue', [value]);
} else {
// Handle checkboxes
let updatedValue = Array.isArray(props.modelValue) ? [...props.modelValue] : [];
if (value) {
if (!updatedValue.includes(props.inputValue)) {
updatedValue.push(props.inputValue);
}
} else {
updatedValue = updatedValue.filter(item => item != props.inputValue);
}
emit('update:modelValue', updatedValue);
}
}
});
const inputType = computed(() => (props.type === 'radio' ? 'radio' : 'checkbox'));
// Define isChecked for radio inputs: it's true when the current modelValue equals the inputValue
// const isChecked = computed(() => {
// if (Array.isArray(computedValue.value) && computedValue.value.length > 0) {
// return props.type === 'radio'
// ? computedValue.value[0] === props.inputValue
// : computedValue.value.includes(props.inputValue);
// }
// return computedValue.value === props.inputValue;
// });
// const isChecked = computed(() => {
// return computedValue.value[0] === props.inputValue;
// });
// Fix the isChecked computation with proper type handling
// const isChecked = computed(() => {
// if (props.type === 'radio') {
// // Use loose equality to handle string/number conversion
// return computedValue.value == props.inputValue;
// }
// return computedValue.value === true;
// });
// const isChecked = computed(() => {
// if (props.type === 'radio') {
// if (Array.isArray(props.modelValue)) {
// return props.modelValue.length > 0 && props.modelValue[0] == props.inputValue;
// }
// return props.modelValue == props.inputValue;
// }
// // For checkboxes
// if (Array.isArray(props.modelValue)) {
// return props.modelValue.includes(props.inputValue);
// }
// return props.modelValue == props.inputValue;
// });
// Use a ref for isChecked and update it with a watcher
const isChecked = ref(false);
// Calculate initial isChecked value
const calculateIsChecked = () => {
if (props.type === 'radio') {
if (Array.isArray(props.modelValue)) {
return props.modelValue.length > 0 && props.modelValue[0] == props.inputValue;
}
return props.modelValue == props.inputValue;
}
// For checkboxes
if (Array.isArray(props.modelValue)) {
return props.modelValue.includes(props.inputValue);
}
return props.modelValue == props.inputValue;
};
// Set initial value
isChecked.value = calculateIsChecked();
// Watch for changes in modelValue and recalculate isChecked
watch(
() => props.modelValue,
(newValue) => {
console.log('modelValue changed:', {
newValue,
inputValue: props.inputValue,
type: props.type
});
isChecked.value = calculateIsChecked();
},
{ immediate: true, deep: true }
);
// Also watch inputValue in case it changes
watch(
() => props.inputValue,
() => {
isChecked.value = calculateIsChecked();
}
);
</script>
<template>
<label :class="type" class="mr-6 mb-3 last:mr-0">
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue" />
<span class="check" />
<span class="pl-2">{{ label }}</span>
<label v-if="type === 'radio'" :class="[type]"
class="mr-6 mb-3 last:mr-0 inline-flex items-center cursor-pointer relative">
<input
v-model="computedValue"
:type="inputType"
:name="name"
:value="inputValue"
class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0" />
<span class="check border transition-colors duration-200 dark:bg-slate-800 block w-5 h-5 rounded-full" :class="{
'border-gray-700': !isChecked,
'bg-radio-checked bg-no-repeat bg-center bg-lime-600 border-lime-600 border-4': isChecked
}" />
<span class="pl-2 control-label">{{ label }}</span>
</label>
<label v-else-if="type === 'checkbox'" :class="[type]"
class="mr-6 mb-3 last:mr-0 inline-flex items-center cursor-pointer relative">
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue"
class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0" />
<span class="check border transition-colors duration-200 dark:bg-slate-800 block w-5 h-5 rounded" :class="{
'border-gray-700': !isChecked,
'bg-checkbox-checked bg-no-repeat bg-center bg-lime-600 border-lime-600 border-4': isChecked
}" />
<span class="pl-2 control-label">{{ label }}</span>
</label>
</template>

View file

@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, ref, PropType } from 'vue';
import FormCheckRadio from '@/Components/FormCheckRadio.vue';
import BaseButton from '@/Components/BaseButton.vue';
import FormControl from '@/Components/FormControl.vue';
import { mdiPlusCircle } from '@mdi/js';
// import BaseButton from '@/Components/BaseButton.vue';
// import FormControl from '@/Components/FormControl.vue';
const props = defineProps({
options: {
type: Object,
@ -23,7 +23,7 @@ const props = defineProps({
required: true,
},
type: {
type: String,
type: String as PropType<'checkbox' | 'radio' | 'switch'>,
default: 'checkbox',
validator: (value: string) => ['checkbox', 'radio', 'switch'].includes(value),
},
@ -38,32 +38,82 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:modelValue']);
const computedValue = computed({
// get: () => props.modelValue,
get: () => {
// const ids = props.modelValue.map((obj) => obj.id);
// return ids;
if (Array.isArray(props.modelValue)) {
if (props.modelValue.every((item) => typeof item === 'number')) {
return props.modelValue;
} else if (props.modelValue.every((item) => hasIdAttribute(item))) {
const ids = props.modelValue.map((obj) => obj.id.toString());
return ids;
}
return props.modelValue;
}
// return props.modelValue;
},
set: (value) => {
emit('update:modelValue', value);
},
});
// const computedValue = computed({
// // get: () => props.modelValue,
// get: () => {
// // const ids = props.modelValue.map((obj) => obj.id);
// // return ids;
// if (Array.isArray(props.modelValue)) {
// if (props.modelValue.every((item) => typeof item === 'number')) {
// return props.modelValue;
// } else if (props.modelValue.every((item) => hasIdAttribute(item))) {
// const ids = props.modelValue.map((obj) => obj.id);
// return ids;
// }
// return props.modelValue;
// }
// // return props.modelValue;
// },
// set: (value) => {
// emit('update:modelValue', value);
// },
// });
// Define a type guard to check if an object has an 'id' attribute
// function hasIdAttribute(obj: any): obj is { id: any } {
// return typeof obj === 'object' && 'id' in obj;
// }
const computedValue = computed({
get: () => {
if (!props.modelValue) return props.modelValue;
if (Array.isArray(props.modelValue)) {
// Handle empty array
if (props.modelValue.length === 0) return [];
// If all items are objects with id property
if (props.modelValue.every((item) => hasIdAttribute(item))) {
return props.modelValue.map((obj) => {
// Ensure we return the correct type based on the options keys
const id = obj.id;
// Check if options keys are numbers or strings
const optionKeys = Object.keys(props.options);
if (optionKeys.length > 0) {
// If option keys are numeric strings, return number
if (optionKeys.every(key => !isNaN(Number(key)))) {
return Number(id);
}
}
return String(id);
});
}
// If all items are numbers
if (props.modelValue.every((item) => typeof item === 'number')) {
return props.modelValue;
}
// If all items are strings that represent numbers
if (props.modelValue.every((item) => typeof item === 'string' && !isNaN(Number(item)))) {
// Convert to numbers if options keys are numeric
const optionKeys = Object.keys(props.options);
if (optionKeys.length > 0 && optionKeys.every(key => !isNaN(Number(key)))) {
return props.modelValue.map(item => Number(item));
}
return props.modelValue;
}
// Return as-is for other cases
return props.modelValue;
}
return props.modelValue;
},
set: (value) => {
emit('update:modelValue', value);
},
});
const hasIdAttribute = (obj: any): obj is { id: any } => {
return typeof obj === 'object' && 'id' in obj;
};
@ -78,11 +128,11 @@ const addOption = () => {
const inputElClass = computed(() => {
const base = [
'px-3 py-2 max-w-full focus:ring focus:outline-none border-gray-700 rounded w-full',
'px-3 py-2 max-w-full border-gray-700 rounded w-full',
'dark:placeholder-gray-400',
'h-12',
'border',
'bg-transparent'
'bg-transparent'
// props.isReadOnly ? 'bg-gray-50 dark:bg-slate-600' : 'bg-white dark:bg-slate-800',
];
// if (props.icon) {
@ -108,7 +158,9 @@ const inputElClass = computed(() => {
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-6H5v-2h6V5h2v6h6v2h-6v6z" />
</svg>
</div>
<FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type" :name="name"
:input-value="key" :label="value" :class="componentClass" />
<!-- <FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
:name="name" :input-value="key" :label="value" :class="componentClass" /> -->
<FormCheckRadio v-for="(value, key) in options" key="`${name}-${key}-${JSON.stringify(computedValue)}`" v-model="computedValue" :type="type"
:name="name" :input-value="isNaN(Number(key)) ? key : Number(key)" :label="value" :class="componentClass" />
</div>
</template>

View file

@ -67,15 +67,28 @@ const computedValue = computed({
emit('update:modelValue', value);
},
});
// focus:ring focus:outline-none border-gray-700
const inputElClass = computed(() => {
const base = [
'px-3 py-2 max-w-full focus:ring focus:outline-none border-gray-700 rounded w-full',
'px-3 py-2 max-w-full rounded w-full',
'dark:placeholder-gray-400',
props.extraHigh ? 'h-80' : (computedType.value === 'textarea' ? 'h-44' : 'h-12'),
props.borderless ? 'border-0' : 'border',
// props.transparent && !props.isReadOnly ? 'bg-transparent' : 'bg-white dark:bg-slate-800',
props.isReadOnly ? 'bg-gray-50 dark:bg-slate-600' : 'bg-white dark:bg-slate-800',
// // props.transparent && !props.isReadOnly ? 'bg-transparent' : 'bg-white dark:bg-slate-800',
// props.isReadOnly ? 'bg-gray-50 dark:bg-slate-600' : 'bg-white dark:bg-slate-800',
];
// Apply styles based on read-only state.
if (props.isReadOnly) {
// Read-only: no focus ring, grayed-out text and border, and disabled cursor.
base.push('bg-gray-50', 'dark:bg-slate-600', 'border', 'border-gray-300', 'dark:border-slate-600', 'text-gray-500', 'cursor-not-allowed', 'focus:outline-none' ,'focus:ring-0', 'focus:border-gray-300');
} else {
// Actionable field: focus ring, white/dark background, and darker border.
base.push('bg-white dark:bg-slate-800', 'focus:ring focus:outline-none', 'border', 'border-gray-700');
}
if (props.icon) {
base.push('pl-10', 'pr-10');
}

View file

@ -1,53 +1,75 @@
<script setup>
import { computed, useSlots } from 'vue';
const props = defineProps({
label: {
type: String,
default: null,
},
labelFor: {
type: String,
default: null,
},
help: {
type: String,
default: null,
},
// class: {
// type: Object,
// default: {},
// },
label: { type: String, default: null },
labelFor: { type: String, default: null },
help: { type: String, default: null },
// Handles Inertia.js string errors or standard array errors
errors: { type: [String, Array], default: null },
});
const slots = useSlots();
// Normalize errors to an array for consistent rendering
const errorList = computed(() => {
if (!props.errors) return [];
return Array.isArray(props.errors) ? props.errors : [props.errors];
});
const hasErrors = computed(() => errorList.value.length > 0);
const wrapperClass = computed(() => {
const base = [];
const slotsLength = slots.default().length;
if (slotsLength > 1) {
base.push('grid grid-cols-1 gap-3');
}
if (slotsLength === 2) {
base.push('md:grid-cols-2');
}
return base;
const base = [];
const children = slots.default?.().filter(node => node.type.toString() !== 'Symbol(v-cmt)') || [];
// Apply grid logic only if there are multiple child controls
if (children.length > 1) {
base.push('grid grid-cols-1 gap-3');
if (children.length === 2) base.push('md:grid-cols-2');
}
return base;
});
</script>
<template>
<div :class="['last:mb-0', 'mb-6']">
<!-- <label v-if="label" :for="labelFor" class="block font-bold mb-2">{{ label }}</label> -->
<label v-if="label" :for="labelFor" class="font-bold h-6 mt-3 text-xs leading-8 uppercase">{{ label }}</label>
<div v-bind:class="wrapperClass">
<slot />
</div>
<div v-if="help" class="text-xs text-gray-500 dark:text-slate-400 mt-1">
{{ help }}
</div>
<div class="mb-6 last:mb-0">
<label
v-if="label"
:for="labelFor"
class="block font-bold text-xs uppercase tracking-wide mb-2 transition-colors duration-200"
:class="hasErrors ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-slate-300'"
>
{{ label }}
</label>
<div :class="wrapperClass">
<slot />
</div>
</template>
<div class="mt-1 min-h-[1.25rem]">
<transition-group
enter-active-class="transition duration-150 ease-out"
enter-from-class="transform -translate-y-1 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
>
<p
v-for="(error, index) in errorList"
:key="`err-${index}`"
class="text-xs text-red-600 dark:text-red-400 italic font-medium"
>
{{ error }}
</p>
<p
v-if="!hasErrors && help"
key="help-text"
class="text-xs text-gray-500 dark:text-slate-400"
>
{{ help }}
</p>
</transition-group>
</div>
</div>
</template>

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue';
import { mdiLicense } from '@mdi/js';
const props = defineProps({
path: {
type: String,
required: true
},
size: {
type: Number,
default: 24
},
viewBox: {
type: String,
default: '0 0 24 24'
},
color: {
type: String,
default: 'currentColor'
},
className: {
type: String,
default: ''
}
});
// Define all the SVG paths we need
const svgPaths = {
// Document/File icons
document: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
documentPlus: 'M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
// Communication icons
email: 'M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z',
// Identity/User icons
idCard: '10 2a1 1 0 00-1 1v1a1 1 0 002 0V3a1 1 0 00-1-1zM4 4h3a3 3 0 006 0h3a2 2 0 012 2v9a2 2 0 01-2 2H4a2 2 0 01-2-2V6a2 2 0 012-2zm2.5 7a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm2.45 4a2.5 2.5 0 10-4.9 0h4.9zM12 9a1 1 0 100 2h3a1 1 0 100-2h-3zm-1 4a1 1 0 011-1h2a1 1 0 110 2h-2a1 1 0 01-1-1z',
// Language/Translation icons
// language: 'M7 2a1 1 0 011 1v1h3a1 1 0 110 2H9.578a18.87 18.87 0 01-1.724 4.78c.29.354.596.696.914 1.026a1 1 0 11-1.44 1.389c-.188-.196-.373-.396-.554-.6a19.098 19.098 0 01-3.107 3.567 1 1 0 01-1.334-1.49 17.087 17.087 0 003.13-3.733 18.992 18.992 0 01-1.487-2.494 1 1 0 111.79-.89c.234.47.489.928.764 1.372.417-.934.752-1.913.997-2.927H3a1 1 0 110-2h3V3a1 1 0 011-1zm6 6a1 1 0 01.894.553l2.991 5.982a.869.869 0 01.02.037l.99 1.98a1 1 0 11-1.79.895L15.383 16h-4.764l-.724 1.447a1 1 0 11-1.788-.894l.99-1.98.019-.038 2.99-5.982A1 1 0 0113 8zm-1.382 6h2.764L13 11.236 11.618 14z',
language: 'M12 2a10 10 0 1 0 0 20a10 10 0 1 0 0-20zm0 0c2.5 0 4.5 4.5 4.5 10s-2 10-4.5 10-4.5-4.5-4.5-10 2-10 4.5-10zm0 0a10 10 0 0 1 0 20a10 10 0 0 1 0-20z',
// License/Legal icons
// license: 'M10 2a1 1 0 00-1 1v1.323l-3.954 1.582A1 1 0 004 6.32V16a1 1 0 001.555.832l3-1.2a1 1 0 01.8 0l3 1.2a1 1 0 001.555-.832V6.32a1 1 0 00-1.046-.894L9 4.877V3a1 1 0 00-1-1zm0 14.5a.5.5 0 01-.5-.5v-4a.5.5 0 011 0v4a.5.5 0 01-.5.5zm1.5-10.5a.5.5 0 11-1 0 .5.5 0 011 0z',
license: mdiLicense,
// Building/Organization icons
building: 'M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 110 2h-3a1 1 0 01-1-1v-2a1 1 0 00-1-1H9a1 1 0 00-1 1v2a1 1 0 01-1 1H4a1 1 0 110-2V4zm3 1h2v2H7V5zm2 4H7v2h2V9zm2-4h2v2h-2V5zm2 4h-2v2h2V9z',
// Book/Publication icons
book: 'M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z',
// Download icon
download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4'
};
const pathData = computed(() => {
return svgPaths[props.path] || props.path;
});
const sizeStyle = computed(() => {
return {
width: `${props.size}px`,
height: `${props.size}px`
};
});
</script>
<template>
<svg :style="sizeStyle" :class="className" :viewBox="viewBox" xmlns="http://www.w3.org/2000/svg" fill="none"
:stroke="color" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="pathData" />
</svg>
</template>

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more