Compare commits

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

29 commits

Author SHA1 Message Date
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
0d259b6464 feat(checkReferenceType): add check reference type feature
All checks were successful
CI / container-job (push) Successful in 39s
Update npm packages and related dependencies
Adapt tailwind.config.js with new utilities and configuration adjustments
Implement categorizeUpdate() method in Submitter/DatasetController.ts for synchronizing dataset collections
Apply style updates in Category.vue for improved drag-and-drop experience and visual cues
Add new route in start/routes.ts for dataset categorization flow
2025-03-17 17:26:29 +01:00
c350e9c373 hotfix: update edit mode of dataset ('ver fogotten to adapt edit mode)
Update DatasetController.ts: use correct moveToDisk method in update method
2025-03-17 12:54:26 +01:00
51a5673a3d hotfix: update @types/leaflet and adjust map styling
Update package.json to bump @types/leaflet
Define leaflet map z-index directly in the main CSS via apps.cc for consistent component use
Scope all SearchMap.vue styles locally
2025-03-17 12:17:47 +01:00
b540547e4c feat: update API controllers, validations, and Vue components
All checks were successful
CI / container-job (push) Successful in 49s
- Modified Api/Authors.Controller.ts to use only personal types and sort by dataset_count.
- Completely rewritten AvatarController.ts.
- Added new Api/CollectionsController.ts for querying collections and collection_roles.
- Modified Api/DatasetController.ts to preload titles, identifier and order by server_date_published.
- Modified FileController.ts to serve files from /storage/app/data/ instead of /storage/app/public.
- Added new Api/UserController for requesting submitters (getSubmitters).
- Improved OaiController.ts with performant DB queries for better ResumptionToken handling.
- Modified Submitter/DatasetController.ts by adding a categorize method for library classification.
- Rewritten ResumptionToken.ts.
- Improved TokenWorkerService.ts to utilize browser fingerprint.
- Edited dataset.ts by adding the doiIdentifier property.
- Enhanced person.ts to improve the fullName property.
- Completely rewritten AsideMenuItem.vue component.
- Updated CarBoxClient.vue to use TypeScript.
- Added new CardBoxDataset.vue for displaying recent datasets on the dashboard.
- Completely rewritten TableSampleClients.vue for the dashboard.
- Completely rewritten UserAvatar.vue.
- Made small layout changes in Dashboard.vue.
- Added new Category.vue for browsing scientific collections.
- Adapted the pinia store in main.ts.
- Added additional routes in start/routes.ts and start/api/routes.ts.
- Improved referenceValidation.ts for better ISBN existence checking.
- NPM dependency updates.
2025-03-14 17:39:58 +01:00
36cd7a757b feat: Integrate official drive_provider, update user profile features & UI improvements
All checks were successful
CI / container-job (push) Successful in 41s
- adonisrc.ts: Load official drive_provider and unload custom driver_provider.
- packages.json: Add @headlessui/vue dependency for tab components.
- AvatarController.ts: Rewrite avatar generation logic to always return the same avatar per user.
- auth/UserController.ts: Add profile and profileUpdate methods to support user profile editing.
- Submitter/datasetController.ts & app/models/file.ts: Adapt code to use the official drive_provider.
- app/models/user.ts: Introduce “isAdmin” getter.
- config/drive.ts: Create new configuration for the official drive_provider.
- providers/vinejs_provider.ts: Adapt allowedExtensions control to use provided options or database enabled extensions.
- resource/js/app.ts: Load default Head and Link components.
- resources/js/menu.ts: Add settings-profile.edit menu point.
- resources/js/Components/action-message.vue: Add new component for improved user feedback after form submissions.
- New avatar-input.vue component: Enable profile picture selection.
- Components/CardBox.vue: Alter layout to optionally show HeaderIcon in title bar.
- FormControl.vue: Define a readonly prop for textareas.
- Improve overall UI with updates to NavBar.vue, UserAvatar.vue, UserAvatarCurrentUser.vue, and add v-model support to password-meter.vue.
- Remove profile editing logic from AccountInfo.vue and introduce new profile components (show.vue, update-password-form.vue, update-profile-information.vue).
- app.edge: Modify page (add @inertiaHead tag) for better meta management.
- routes.ts: Add new routes for editing user profiles.
- General npm updates.
2025-02-27 16:24:25 +01:00
a41b091214 feat: Adjust z-index values for map components, enhance ISBN validation message, and add dynamic placeholders for reference inputs, add additional mimetypes
All checks were successful
CI / container-job (push) Successful in 38s
2025-02-17 16:08:36 +01:00
a3031169ca feat: Add alternate mimetype support, enhance validation for alternate mimetypes, and improve script loading performance
All checks were successful
CI / container-job (push) Successful in 36s
- mime_type.ts: Added a new column `public alternate_mimetype: string;`
- MimetypeController.ts: Extended validation and storage logic to accommodate the new `alternate_mimetype` attribute
- adonisrc.ts: Integrated new validation rule to validate user-provided mimetypes
- vite.ts: Set `defer: true` for script attributes to improve loading performance
- update_1_to_mime_types.ts: Added migration for the new `alternate_mimetype` column in the database
- UI improvements: Updated components such as AsideMenuLayer.vue, FormCheckRadioGroup.vue, MimeTypeInput.vue, NavBar.vue (lime-green background), NavBarMenu.vue, SectionBannerStarOnGitea.vue, Admin/mimetype/Create.vue, Admin/mimetype/Delete.vue, Admin/mimetype/Index.vue
- allowed_extensions_mimetype.ts: Enhanced rule to also check for alternate mimetypes
- referenceValidation.ts: Improved validation to allow only ISBNs with a '-' delimiter
- package-lock.json: Updated npm dependencie
2025-02-13 15:49:09 +01:00
4c5a8f5a42 feat: update to vite.js, Refactor configuration files, remove unused assets, and clean up commented code:
All checks were successful
CI / container-job (push) Successful in 43s
- ace.js: use ts-node-maintained
- adonisrc.ts: load vite_provider, sett assetBundler to false, addd hooks property
- Dockerfile: change to node version 22
- package.json: remove babel depencies; add @swc/wasm, add vitejs/plugin-vue, add hot-hook, add vite,  update eslint-config-prettier, tailwindcss, ts-node-maintained
- new vite.config.js and config/vite.ts
- inertia.js
- improved own vinejs_provider.ts
- adapted app.css needed for vitejs
- adapted app.ts: new resolve method neede for vitejs
relocated resources/js/logo.svg
- remove Buffer import into FileUpload.vue
- Create.vue: improved submit needed for @inertiajs/vue3 form helper
- Edit.vue: mproved submit needed for @inertiajs/vue3 form helper
- kernel.ts: load vite_middleware
- formated rotes.ts file
- rewritten allowed_extensions_mimetypes.ts file (removed typescript errors)
2025-02-07 10:14:57 +01:00
8d47a58d29 feat: Update .gitignore and refine TypeScript configuration; clean up commented code and enhance dataset validation; npm updates
Some checks failed
CI / container-job (push) Failing after 35s
- Updated .gitignore to include new patterns
- Refined TypeScript configuration for better performance and readability
- Cleaned up commented code in several files
- Enhanced dataset validation logic
- Updated npm dependencies to the latest versions
2025-01-29 11:26:21 +01:00
a5e0a36327 feat: Update CI workflow for reference validation tests and add environment variable configurations
All checks were successful
CI / container-job (push) Successful in 41s
2025-01-27 12:20:49 +01:00
c0496be51b - workflow adaptions for new tests
Some checks failed
CI / test (push) Failing after 1m12s
2025-01-24 17:45:58 +01:00
2c4f51be68 feat: Enhance reference validation and add support for Handle URLs
Some checks failed
CI Pipeline / japa-tests (push) Failing after 51s
- Updated reference validation to handle various identifier types including DOI, ISBN, ISSN, URN, and Handle.
- Improved regex patterns for DOI and Handle validation to correctly extract and validate identifiers from URLs.
- Added asynchronous checks to verify the existence of DOI and Handle URLs.
- Added asynchronous checks to verify the existence of  ISBNs
- Included detailed comments explaining the regex patterns and validation logic.
- Adjusted the validation logic to handle any URL prefix for Handle identifiers.
- Ensured that the Handle format `handle/20.500.12854/36478` is correctly validated.
- Updated the CI workflow to trigger on push and pull request events.
2025-01-24 17:11:10 +01:00
166 changed files with 9895 additions and 11329 deletions

View file

@ -18,3 +18,5 @@ REDIS_PASSWORD=
SMTP_HOST= SMTP_HOST=
SMTP_PORT= SMTP_PORT=
RESEND_API_KEY= RESEND_API_KEY=
OPENSEARCH_HOST=http://localhost
OPENSEARCH_CORE=tethys-records

View file

@ -0,0 +1,78 @@
# This is a Gitea Actions workflow configuration file for running CI tests on the `feat/checkReferenceType` branch.
# The workflow is named "CI" and runs on the latest Ubuntu environment using a Node.js 20 Docker container.
# It sets up a PostgreSQL service with specified environment variables and health checks.
# The workflow includes the following steps:
# 1. Checkout the repository using the actions/checkout@v3 action.
# 2. Install Node.js dependencies using `npm ci`.
# 3. Create a `.env.test` file by copying from `.env.example`.
# 4. Set up environment variables in the `.env.test` file, including database connection details and other app-specific settings.
# 5. Run functional tests using the `node ace test functional --groups "ReferenceValidation"` command.
name: CI
run-name: Running tests for checkReferenceType branch
on:
push:
branches:
- feat/checkReferenceType
jobs:
container-job:
runs-on: ubuntu-latest
# Docker Hub image that `container-job` executes in
container: node:20-bullseye
services:
# Label used to access the service container
postgres:
image: postgres:latest
env:
POSTGRES_USER: alice
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: tethys_dev
# ports:
# - 5432:5432
options: |
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v3
# - name: Set up Node.js
# uses: actions/setup-node@v2
# with:
# node-version: '20'
- name: Install dependencies
run: npm ci
- name: Create .env.test file
run: cp .env.example .env.test
- name: Set up environment variables
run: |
echo "DB_CONNECTION=pg" >> .env.test
echo "PG_HOST=postgres" >> .env.test
echo "PG_PORT=5432" >> .env.test
echo "PG_USER=alice" >> .env.test
echo "PG_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env.test
echo "PG_DB_NAME=tethys_dev" >> .env.test
echo "NODE_ENV=test" >> .env.test
echo "ASSETS_DRIVER=fake" >> .env.test
echo "SESSION_DRIVER=memory" >> .env.test
echo "HASH_DRIVER=bcrypt" >> .env.test
echo "HOST=127.0.0.1" >> .env.test
echo "PORT=3333" >> .env.test
echo "APP_NAME=TethysCloud" >> .env.test
echo "APP_URL=http://${HOST}:${PORT}" >> .env.test
echo "CACHE_VIEWS=false" >> .env.test
echo "APP_KEY=pfi5N2ACN4tMJ5d8d8BPHfh3FEuvleej" >> .env.test
echo "DRIVE_DISK=local" >> .env.test
echo "OAI_LIST_SIZE=200" >> .env.test
echo "OPENSEARCH_HOST=${{ secrets.OPENSEARCH_HOST }}" >> .env.test
echo "OPENSEARCH_CORE=tethys-records" >> .env.test
- name: Run tests
run: node ace test functional --groups "ReferenceValidation"

View file

@ -4,7 +4,13 @@
name: CI Pipeline name: CI Pipeline
run-name: ${{ github.actor }} is running CI pipeline run-name: ${{ github.actor }} is running CI pipeline
# trigger build when pushing, or when creating a pull request # trigger build when pushing, or when creating a pull request
on: [push, pull_request] on:
push:
branches:
- master
pull_request:
branches:
- master
jobs: jobs:
# Label of the container job # Label of the container job
@ -12,7 +18,7 @@ jobs:
# run build on latest ubuntu # run build on latest ubuntu
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: node:18-bullseye container: node:20-bullseye
services: services:
mydb: mydb:
@ -70,6 +76,7 @@ jobs:
&& echo "CACHE_VIEWS=false" >> .env.test && echo "CACHE_VIEWS=false" >> .env.test
&& echo "APP_KEY=pfi5N2ACN4tMJ5d8d8BPHfh3FEuvleej" >> .env.test && echo "APP_KEY=pfi5N2ACN4tMJ5d8d8BPHfh3FEuvleej" >> .env.test
&& echo "DRIVE_DISK=local" >> .env.test && echo "DRIVE_DISK=local" >> .env.test
&& echo "OAI_LIST_SIZE=200" >> .env.test
# finally run the tests # finally run the tests
# - run: npm test # - run: npm test
@ -95,3 +102,4 @@ jobs:
# uses: coverallsapp/github-action@master # uses: coverallsapp/github-action@master
# with: # with:
# github-token: ${{ secrets.GITHUB_TOKEN }} # github-token: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ coverage
tmp tmp
docker-compose.yml docker-compose.yml
.env.test .env.test
public/assets

View file

@ -1,7 +1,7 @@
################## First Stage - Creating base ######################### ################## First Stage - Creating base #########################
# Created a variable to hold our node base image # Created a variable to hold our node base image
ARG NODE_IMAGE=node:20-bookworm-slim ARG NODE_IMAGE=node:22-bookworm-slim
FROM $NODE_IMAGE AS base FROM $NODE_IMAGE AS base
# Install dumb-init and ClamAV, and perform ClamAV database update # Install dumb-init and ClamAV, and perform ClamAV database update

7
ace.js
View file

@ -15,10 +15,11 @@
/** /**
* Register hook to process TypeScript files using ts-node * Register hook to process TypeScript files using ts-node
*/ */
import { register } from 'node:module' // import { register } from 'node:module';
register('ts-node/esm', import.meta.url) // register('ts-node/esm', import.meta.url);
import 'ts-node-maintained/register/esm';
/** /**
* Import ace console entrypoint * Import ace console entrypoint
*/ */
await import('./bin/console.js') await import('./bin/console.js');

View file

@ -1,4 +1,4 @@
import { defineConfig } from '@adonisjs/core/app' import { defineConfig } from '@adonisjs/core/app';
export default defineConfig({ export default defineConfig({
/* /*
@ -13,8 +13,7 @@ export default defineConfig({
commands: [ commands: [
() => import('@adonisjs/core/commands'), () => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'), () => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/mail/commands') () => import('@adonisjs/mail/commands')],
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Preloads | Preloads
@ -33,7 +32,10 @@ export default defineConfig({
() => import('#start/rules/file_length'), () => import('#start/rules/file_length'),
() => import('#start/rules/file_scan'), () => import('#start/rules/file_scan'),
() => import('#start/rules/allowed_extensions_mimetypes'), () => import('#start/rules/allowed_extensions_mimetypes'),
() => import('#start/rules/dependent_array_min_length') () => import('#start/rules/dependent_array_min_length'),
() => import('#start/rules/referenceValidation'),
() => import('#start/rules/valid_mimetype'),
() => import('#start/rules/array_contains_types'),
], ],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -63,17 +65,18 @@ export default defineConfig({
() => import('@adonisjs/auth/auth_provider'), () => import('@adonisjs/auth/auth_provider'),
// () => import('@eidellev/adonis-stardust'), // () => import('@eidellev/adonis-stardust'),
() => import('@adonisjs/redis/redis_provider'), () => import('@adonisjs/redis/redis_provider'),
() => import('@adonisjs/encore/encore_provider'), // () => import('@adonisjs/encore/encore_provider'),
() => import('@adonisjs/static/static_provider'), () => import('@adonisjs/static/static_provider'),
() => import('#providers/stardust_provider'), () => import('#providers/stardust_provider'),
() => import('#providers/query_builder_provider'), () => import('#providers/query_builder_provider'),
() => import('#providers/token_worker_provider'), () => import('#providers/token_worker_provider'),
// () => import('#providers/validator_provider'), // () => import('#providers/validator_provider'),
() => import('#providers/drive/provider/drive_provider'), // () => import('#providers/drive/provider/drive_provider'),
() => import('@adonisjs/drive/drive_provider'),
// () => import('@adonisjs/core/providers/vinejs_provider'), // () => import('@adonisjs/core/providers/vinejs_provider'),
() => import('#providers/vinejs_provider'), () => import('#providers/vinejs_provider'),
() => import('@adonisjs/mail/mail_provider') () => import('@adonisjs/mail/mail_provider'),
// () => import('#providers/mail_provider'), () => import('@adonisjs/vite/vite_provider'),
], ],
metaFiles: [ metaFiles: [
{ {
@ -109,7 +112,9 @@ export default defineConfig({
], ],
forceExit: false, forceExit: false,
}, },
assetsBundler: false,
hooks: {
onBuildStarting: [() => import('@adonisjs/vite/build_hook')],
}) },
// assetsBundler: false
});

View file

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

View file

@ -25,6 +25,7 @@ export default class MimetypeController {
const newDatasetSchema = vine.object({ const newDatasetSchema = vine.object({
name: vine.string().trim().isUnique({ table: 'mime_types', column: 'name' }), name: vine.string().trim().isUnique({ table: 'mime_types', column: 'name' }),
file_extension: vine.array(vine.string()).minLength(1), // define at least one extension for the new mimetype file_extension: vine.array(vine.string()).minLength(1), // define at least one extension for the new mimetype
alternate_mimetype: vine.array(vine.string().isValidMimetype()).distinct().optional(), // define alias mimetypes
enabled: vine.boolean(), enabled: vine.boolean(),
}); });
// await request.validate({ schema: newDatasetSchema, messages: this.messages }); // await request.validate({ schema: newDatasetSchema, messages: this.messages });
@ -33,17 +34,21 @@ export default class MimetypeController {
// await request.validate({ schema: newDatasetSchema, messages: this.messages }); // await request.validate({ schema: newDatasetSchema, messages: this.messages });
const validator = vine.compile(newDatasetSchema); const validator = vine.compile(newDatasetSchema);
validator.messagesProvider = new SimpleMessagesProvider(this.messages); validator.messagesProvider = new SimpleMessagesProvider(this.messages);
await request.validateUsing(validator); await request.validateUsing(validator, { messagesProvider: new SimpleMessagesProvider(this.messages) });
} catch (error) { } catch (error) {
// Step 3 - Handle errors // Step 3 - Handle errors
// return response.badRequest(error.messages); // return response.badRequest(error.messages);
throw error; throw error;
} }
const input = request.only(['name', 'enabled', 'file_extension']); const input = request.only(['name', 'enabled', 'file_extension', 'alternate_mimetype']);
// Concatenate the file_extensions array into a string with '|' as the separator // Concatenate the file_extensions array into a string with '|' as the separator
if (Array.isArray(input.file_extension)) { if (Array.isArray(input.file_extension)) {
input.file_extension = input.file_extension.join('|'); input.file_extension = input.file_extension.join('|');
} }
// Concatenate the alias_mimetype array into a string with '|' as the separator
if (Array.isArray(input.alternate_mimetype)) {
input.alternate_mimetype = input.alternate_mimetype.join('|');
}
await MimeType.create(input); await MimeType.create(input);
// if (request.input('roles')) { // if (request.input('roles')) {
// const roles: Array<number> = request.input('roles'); // const roles: Array<number> = request.input('roles');

View file

@ -9,12 +9,15 @@ export default class AuthorsController {
// where exists (select * from gba.documents inner join gba.link_documents_persons on "documents"."id" = "link_documents_persons"."document_id" // 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")); // where ("link_documents_persons"."role" = 'author') and ("persons"."id" = "link_documents_persons"."person_id"));
const authors = await Person.query() const authors = await Person.query()
.preload('datasets')
.where('name_type', 'Personal')
.whereHas('datasets', (dQuery) => { .whereHas('datasets', (dQuery) => {
dQuery.wherePivot('role', 'author'); dQuery.wherePivot('role', 'author');
}) })
.withCount('datasets', (query) => { .withCount('datasets', (query) => {
query.as('datasets_count'); query.as('datasets_count');
}); })
.orderBy('datasets_count', 'desc');
return authors; return authors;
} }
@ -25,7 +28,10 @@ export default class AuthorsController {
if (request.input('filter')) { if (request.input('filter')) {
// users = users.whereRaw('name like %?%', [request.input('search')]) // users = users.whereRaw('name like %?%', [request.input('search')])
const searchTerm = request.input('filter'); 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}%`); // .orWhere('email', 'like', `%${searchTerm}%`);
} }

View file

@ -1,65 +1,135 @@
import type { HttpContext } from '@adonisjs/core/http'; import type { HttpContext } from '@adonisjs/core/http';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
// import * as fs from 'fs'; import redis from '@adonisjs/redis/services/main';
// import * as path from 'path';
const prefixes = ['von', 'van']; const PREFIXES = ['von', 'van'];
const DEFAULT_SIZE = 50;
const FONT_SIZE_RATIO = 0.4;
const COLOR_LIGHTENING_PERCENT = 60;
const COLOR_DARKENING_FACTOR = 0.6;
// node ace make:controller Author
export default class AvatarController { export default class AvatarController {
public async generateAvatar({ request, response }: HttpContext) { public async generateAvatar({ request, response }: HttpContext) {
try { try {
const { name, background, textColor, size } = request.only(['name', 'background', 'textColor', 'size']); const { name, size = DEFAULT_SIZE } = request.only(['name', 'size']);
if (!name) {
return response.status(StatusCodes.BAD_REQUEST).json({ error: 'Name is required' });
}
// 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);
}
// Generate initials
// const initials = name
// .split(' ')
// .map((part) => part.charAt(0).toUpperCase())
// .join('');
const initials = this.getInitials(name); const initials = this.getInitials(name);
const colors = this.generateColors(name);
const svgContent = this.createSvg(size, colors, initials);
// Define SVG content with dynamic values for initials, background color, text color, and size // // Cache the generated avatar for future use, e.g. 1 hour expiry
const svgContent = ` await redis.setex(cacheKey, 3600, svgContent);
<svg width="${size || 50}" height="${size || 50}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#${background || '7F9CF5'}"/> this.setResponseHeaders(response);
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-weight="bold" font-family="Arial, sans-serif" font-size="${ return response.send(svgContent);
(size / 100) * 40 || 25 } catch (error) {
}" fill="#${textColor || 'ffffff'}">${initials}</text> return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: error.message });
}
}
private getInitials(name: string): string {
const parts = name
.trim()
.split(' ')
.filter((part) => part.length > 0);
if (parts.length === 0) {
return 'NA';
}
if (parts.length >= 2) {
return this.getMultiWordInitials(parts);
}
return parts[0].substring(0, 2).toUpperCase();
}
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();
if (PREFIXES.includes(lastName.toLowerCase()) && lastName === lastName.toUpperCase()) {
return firstInitial + lastName.charAt(1).toUpperCase();
}
return firstInitial + lastInitial;
}
private generateColors(name: string): { background: string; text: string } {
const baseColor = this.getColorFromName(name);
return {
background: this.lightenColor(baseColor, COLOR_LIGHTENING_PERCENT),
text: this.darkenColor(baseColor),
};
}
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> </svg>
`; `;
}
// Set response headers for SVG content private setResponseHeaders(response: HttpContext['response']): void {
response.header('Content-type', 'image/svg+xml'); response.header('Content-type', 'image/svg+xml');
response.header('Cache-Control', 'no-cache'); response.header('Cache-Control', 'no-cache');
response.header('Pragma', 'no-cache'); response.header('Pragma', 'no-cache');
response.header('Expires', '0'); response.header('Expires', '0');
return response.send(svgContent);
} catch (error) {
return response.status(StatusCodes.OK).json({ error: error.message });
}
} }
private getInitials(name: string) { private getColorFromName(name: string): string {
const parts = name.split(' '); let hash = 0;
let initials = ''; for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
if (parts.length >= 2) {
const firstName = parts[0];
const lastName = parts[parts.length - 1];
const firstInitial = firstName.charAt(0).toUpperCase();
const lastInitial = lastName.charAt(0).toUpperCase();
if (prefixes.includes(lastName.toLowerCase()) && lastName === lastName.toUpperCase()) {
initials = firstInitial + lastName.charAt(1).toUpperCase();
} else {
initials = firstInitial + lastInitial;
}
} else if (parts.length === 1) {
initials = parts[0].substring(0, 2).toUpperCase();
} }
return initials; const colorParts = [];
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
colorParts.push(value.toString(16).padStart(2, '0'));
}
return colorParts.join('');
}
private lightenColor(hexColor: string, percent: number): string {
const r = parseInt(hexColor.substring(0, 2), 16);
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 newR = lightenValue(r);
const newG = lightenValue(g);
const newB = lightenValue(b);
return ((newR << 16) | (newG << 8) | newB).toString(16).padStart(6, '0');
}
private darkenColor(hexColor: string): string {
const r = parseInt(hexColor.slice(0, 2), 16);
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 darkerR = darkenValue(r);
const darkerG = darkenValue(g);
const darkerB = darkenValue(b);
return ((darkerR << 16) + (darkerG << 8) + darkerB).toString(16).padStart(6, '0');
} }
} }

View file

@ -6,10 +6,15 @@ import { StatusCodes } from 'http-status-codes';
// node ace make:controller Author // node ace make:controller Author
export default class DatasetController { export default class DatasetController {
public async index({}: HttpContext) { public async index({}: HttpContext) {
// select * from gba.persons // Select datasets with server_state 'published' or 'deleted' and sort by the last published date
// where exists (select * from gba.documents inner join gba.link_documents_persons on "documents"."id" = "link_documents_persons"."document_id" const datasets = await Dataset.query()
// where ("link_documents_persons"."role" = 'author') and ("persons"."id" = "link_documents_persons"."person_id")); .where(function (query) {
const datasets = await Dataset.query().where('server_state', 'published').orWhere('server_state', 'deleted'); query.where('server_state', 'published')
.orWhere('server_state', 'deleted');
})
.preload('titles')
.preload('identifier')
.orderBy('server_date_published', 'desc');
return datasets; return datasets;
} }

View file

@ -14,7 +14,7 @@ export default class FileController {
// where: { id: id }, // where: { id: id },
// }); // });
if (file) { if (file) {
const filePath = '/storage/app/public/' + file.pathName; const filePath = '/storage/app/data/' + file.pathName;
const ext = path.extname(filePath); const ext = path.extname(filePath);
const fileName = file.label + ext; const fileName = file.label + ext;
try { try {

View file

@ -17,7 +17,8 @@ export default class HomeController {
// .preload('authors') // .preload('authors')
// .orderBy('server_date_published'); // .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`)]) .select(['publish_id', 'server_date_published', db.raw(`date_part('year', server_date_published) as pub_year`)])
.where('server_state', serverState) .where('server_state', serverState)
.innerJoin('link_documents_persons as ba', 'doc.id', 'ba.document_id') .innerJoin('link_documents_persons as ba', 'doc.id', 'ba.document_id')
@ -59,7 +60,6 @@ export default class HomeController {
// const year = params.year; // const year = params.year;
// const from = parseInt(year); // const from = parseInt(year);
try { try {
// const datasets = await Database.from('documents as doc') // 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')]) // .select([Database.raw(`date_part('month', server_date_published) as pub_month`), Database.raw('COUNT(*) as count')])
// .where('server_state', serverState) // .where('server_state', serverState)
@ -68,9 +68,12 @@ export default class HomeController {
// .groupBy('pub_month'); // .groupBy('pub_month');
// // .orderBy('server_date_published'); // // .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([ .select([
db.raw(`date_part('year', server_date_published) as pub_year`), db.raw(`date_part('year', server_date_published) as pub_year`),
db.raw(`date_part('month', server_date_published) as pub_month`), db.raw(`date_part('month', server_date_published) as pub_month`),
@ -100,14 +103,14 @@ export default class HomeController {
acc[pub_year].data[pub_month - 1] = parseInt(count); acc[pub_year].data[pub_month - 1] = parseInt(count);
return acc ; return acc;
}, {}); }, {});
const outputDatasets = Object.entries(inputDatasets).map(([year, data]) => ({ const outputDatasets = Object.entries(inputDatasets).map(([year, data]) => ({
data: data.data, data: data.data,
label: year, label: year,
borderColor: data.borderColor, borderColor: data.borderColor,
fill: data.fill fill: data.fill,
})); }));
const data = { const data = {
@ -139,5 +142,4 @@ interface ChartDataset {
label: string; label: string;
borderColor: string; borderColor: string;
fill: boolean; fill: boolean;
} }

View file

@ -9,6 +9,24 @@ import BackupCode from '#models/backup_code';
// Here we are generating secret and recovery codes for the user thats enabling 2FA and storing them to our database. // Here we are generating secret and recovery codes for the user thats enabling 2FA and storing them to our database.
export default class UserController { export default class UserController {
public async getSubmitters({ response }: HttpContext) {
try {
const submitters = await User.query()
.preload('roles', (query) => {
query.where('name', 'submitter')
})
.whereHas('roles', (query) => {
query.where('name', 'submitter')
})
.exec();
return submitters;
} catch (error) {
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
message: 'Invalid TOTP state',
});
}
}
public async enable({ auth, response, request }: HttpContext) { public async enable({ auth, response, request }: HttpContext) {
const user = (await User.find(auth.user?.id)) as User; const user = (await User.find(auth.user?.id)) as User;
// await user.load('totp_secret'); // await user.load('totp_secret');

View file

@ -0,0 +1,36 @@
import type { HttpContext } from '@adonisjs/core/http';
import Collection from '#models/collection';
export default class CollectionsController {
public async show({ params, response }: HttpContext) {
// Get the collection id from route parameters
const collectionId = params.id;
// Find the selected collection by id
const collection = await Collection.find(collectionId);
if (!collection) {
return response.status(404).json({ message: 'Collection not found' });
}
// Query for narrower concepts: collections whose parent_id equals the selected collection's id
const narrowerCollections = await Collection.query().where('parent_id', collection.id) || [];
// For broader concept, if the selected collection has a parent_id fetch that record (otherwise null)
const broaderCollection: Collection[] | never[] | null = await (async () => {
if (collection.parent_id) {
// Try to fetch the parent...
const parent = await Collection.find(collection.parent_id)
// If found, return it wrapped in an array; if not found, return null (or empty array if you prefer)
return parent ? [parent] : null
}
return []
})()
// Return the selected collection along with its narrower and broader concepts in JSON format
return response.json({
selectedCollection: collection,
narrowerCollections,
broaderCollection,
});
}
}

View file

@ -5,7 +5,7 @@ import BackupCode from '#models/backup_code';
// import InvalidCredentialException from 'App/Exceptions/InvalidCredentialException'; // import InvalidCredentialException from 'App/Exceptions/InvalidCredentialException';
import { authValidator } from '#validators/auth'; import { authValidator } from '#validators/auth';
import hash from '@adonisjs/core/services/hash'; import hash from '@adonisjs/core/services/hash';
import db from '@adonisjs/lucid/services/db';
import TwoFactorAuthProvider from '#app/services/TwoFactorAuthProvider'; import TwoFactorAuthProvider from '#app/services/TwoFactorAuthProvider';
// import { Authenticator } from '@adonisjs/auth'; // import { Authenticator } from '@adonisjs/auth';
// import { LoginState } from 'Contracts/enums'; // import { LoginState } from 'Contracts/enums';
@ -29,6 +29,10 @@ export default class AuthController {
const { email, password } = request.only(['email', 'password']); const { email, password } = request.only(['email', 'password']);
try { try {
await db.connection().rawQuery('SELECT 1')
// // attempt to verify credential and login user // // attempt to verify credential and login user
// await auth.use('web').attempt(email, plainPassword); // await auth.use('web').attempt(email, plainPassword);
@ -51,6 +55,9 @@ export default class AuthController {
await auth.use('web').login(user); await auth.use('web').login(user);
} catch (error) { } catch (error) {
if (error.code === 'ECONNREFUSED') {
throw error
}
// if login fails, return vague form message and redirect back // if login fails, return vague form message and redirect back
session.flash('message', 'Your username, email, or password is incorrect'); session.flash('message', 'Your username, email, or password is incorrect');
return response.redirect().back(); return response.redirect().back();

View file

@ -6,6 +6,11 @@ import hash from '@adonisjs/core/services/hash';
// import { schema, rules } from '@adonisjs/validator'; // import { schema, rules } from '@adonisjs/validator';
import vine from '@vinejs/vine'; import vine from '@vinejs/vine';
import BackupCodeStorage, { SecureRandom } from '#services/backup_code_storage'; import BackupCodeStorage, { SecureRandom } from '#services/backup_code_storage';
import path from 'path';
import crypto from 'crypto';
// import drive from '#services/drive';
import drive from '@adonisjs/drive/services/main';
import logger from '@adonisjs/core/services/logger';
// Here we are generating secret and recovery codes for the user thats enabling 2FA and storing them to our database. // Here we are generating secret and recovery codes for the user thats enabling 2FA and storing them to our database.
export default class UserController { export default class UserController {
@ -40,10 +45,8 @@ export default class UserController {
// }); // });
const passwordSchema = vine.object({ const passwordSchema = vine.object({
// first step // first step
old_password: vine old_password: vine.string().trim(),
.string() // .regex(/^[a-zA-Z0-9]+$/),
.trim()
.regex(/^[a-zA-Z0-9]+$/),
new_password: vine.string().confirmed({ confirmationField: 'confirm_password' }).trim().minLength(8).maxLength(255), new_password: vine.string().confirmed({ confirmationField: 'confirm_password' }).trim().minLength(8).maxLength(255),
}); });
try { try {
@ -56,7 +59,7 @@ export default class UserController {
} }
try { try {
const user = await auth.user as User; const user = (await auth.user) as User;
const { old_password, new_password } = request.only(['old_password', 'new_password']); const { old_password, new_password } = request.only(['old_password', 'new_password']);
// if (!(old_password && new_password && confirm_password)) { // if (!(old_password && new_password && confirm_password)) {
@ -82,6 +85,171 @@ export default class UserController {
} }
} }
public async profile({ inertia, auth }: HttpContext) {
const user = await User.find(auth.user?.id);
// let test = await drive.use().getUrl(user?.avatar);
// user?.preload('roles');
const avatarFullPathUrl = user?.avatar ? await drive.use('public').getUrl(user.avatar) : null;
return inertia.render('profile/show', {
user: user,
defaultUrl: avatarFullPathUrl,
});
}
/**
* Update the user's profile information.
*
* @param {HttpContext} ctx - The HTTP context object.
* @returns {Promise<void>}
*/
public async profileUpdate({ auth, request, response, session }: HttpContext) {
if (!auth.user) {
session.flash('error', 'You must be logged in to update your profile.');
return response.redirect().toRoute('login');
}
const updateProfileValidator = vine.withMetaData<{ userId: number }>().compile(
vine.object({
first_name: vine.string().trim().minLength(4).maxLength(255),
last_name: vine.string().trim().minLength(4).maxLength(255),
login: vine.string().trim().minLength(4).maxLength(255),
email: vine
.string()
.trim()
.maxLength(255)
.email()
.normalizeEmail()
.isUnique({ table: 'accounts', column: 'email', whereNot: (field) => field.meta.userId }),
avatar: vine
.myfile({
size: '2mb',
extnames: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
})
// .allowedMimetypeExtensions({
// allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
// })
.optional(),
}),
);
const user = await User.find(auth.user.id);
if (!user) {
session.flash('error', 'User not found.');
return response.redirect().toRoute('login');
}
try {
// validate update form
await request.validateUsing(updateProfileValidator, {
meta: {
userId: user.id,
},
});
const { login, email, first_name, last_name } = request.only(['login', 'email', 'first_name', 'last_name']);
const sanitizedData: { [key: string]: any } = {
login: login?.trim(),
email: email?.toLowerCase().trim(),
first_name: first_name?.trim(),
last_name: last_name?.trim(),
// avatar: "",
};
const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
const hasInputChanges = Object.keys(sanitizedData).some((key) => {
const camelKey = toCamelCase(key);
return sanitizedData[key] !== (user.$attributes as { [key: string]: any })[camelKey];
});
let hasAvatarChanged = false;
const avatar = request.file('avatar');
if (avatar) {
const fileHash = crypto
.createHash('sha256')
.update(avatar.clientName + avatar.size)
.digest('hex');
const fileName = `avatar-${fileHash}.${avatar.extname}`;
const avatarFullPath = path.join('/uploads', `${user.login}`, fileName);
if (user.avatar != avatarFullPath) {
if (user.avatar) {
await drive.use('public').delete(user.avatar);
}
hasAvatarChanged = user.avatar !== avatarFullPath;
await avatar.moveToDisk(avatarFullPath, 'public', {
name: fileName,
overwrite: true, // overwrite in case of conflict
disk: 'public',
});
sanitizedData.avatar = avatarFullPath;
}
}
if (!hasInputChanges && !hasAvatarChanged) {
session.flash('message', 'No changes were made.');
return response.redirect().back();
}
await user.merge(sanitizedData).save();
session.flash('message', 'User has been updated successfully');
return response.redirect().toRoute('settings.profile.edit');
} catch (error) {
logger.error('Profile update failed:', error);
// session.flash('errors', 'Profile update failed. Please try again.');
// return response.redirect().back();
throw error;
}
}
public async passwordUpdate({ auth, request, response, session }: HttpContext) {
// const passwordSchema = schema.create({
// old_password: schema.string({ trim: true }, [rules.required()]),
// new_password: schema.string({ trim: true }, [rules.minLength(8), rules.maxLength(255), rules.confirmed('confirm_password')]),
// confirm_password: schema.string({ trim: true }, [rules.required()]),
// });
const passwordSchema = vine.object({
// first step
old_password: vine.string().trim(),
// .regex(/^[a-zA-Z0-9]+$/),
new_password: vine.string().confirmed({ confirmationField: 'confirm_password' }).trim().minLength(8).maxLength(255),
});
try {
// await request.validate({ schema: passwordSchema });
const validator = vine.compile(passwordSchema);
await request.validateUsing(validator);
} catch (error) {
// return response.badRequest(error.messages);
throw error;
}
try {
const user = (await auth.user) as User;
const { old_password, new_password } = request.only(['old_password', 'new_password']);
// if (!(old_password && new_password && confirm_password)) {
// return response.status(400).send({ warning: 'Old password and new password are required.' });
// }
// Verify if the provided old password matches the user's current password
const isSame = await hash.verify(user.password, old_password);
if (!isSame) {
session.flash('warning', 'Old password is incorrect.');
return response.redirect().back();
// return response.flash('warning', 'Old password is incorrect.').redirect().back();
}
// Hash the new password before updating the user's password
user.password = new_password;
await user.save();
// return response.status(200).send({ message: 'Password updated successfully.' });
session.flash({ message: 'Password updated successfully.' });
return response.redirect().toRoute('settings.profile.edit');
} catch (error) {
// return response.status(500).send({ message: 'Internal server error.' });
return response.flash('warning', `Invalid server state. Internal server error.`).redirect().back();
}
}
public async enableTwoFactorAuthentication({ auth, response, session }: HttpContext): Promise<void> { public async enableTwoFactorAuthentication({ auth, response, session }: HttpContext): Promise<void> {
// const user: User | undefined = auth?.user; // const user: User | undefined = auth?.user;
const user = (await User.find(auth.user?.id)) as User; const user = (await User.find(auth.user?.id)) as User;

View file

@ -18,9 +18,33 @@ import { HttpException } from 'node-exceptions';
import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'; import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model';
import vine, { SimpleMessagesProvider } from '@vinejs/vine'; import vine, { SimpleMessagesProvider } from '@vinejs/vine';
import mail from '@adonisjs/mail/services/main'; import mail from '@adonisjs/mail/services/main';
// import { resolveMx } from 'dns/promises';
// import * as net from 'net';
import { validate } from 'deep-email-validator'; import { validate } from 'deep-email-validator';
import {
TitleTypes,
DescriptionTypes,
ContributorTypes,
PersonNameTypes,
ReferenceIdentifierTypes,
RelationTypes,
SubjectTypes,
DatasetTypes,
} from '#contracts/enums';
import { TransactionClientContract } from '@adonisjs/lucid/types/database';
import db from '@adonisjs/lucid/services/db';
import Project from '#models/project';
import License from '#models/license';
import Language from '#models/language';
import File from '#models/file';
import Coverage from '#models/coverage';
import Title from '#models/title';
import Description from '#models/description';
import Subject from '#models/subject';
import DatasetReference from '#models/dataset_reference';
import Collection from '#models/collection';
import CollectionRole from '#models/collection_role';
import { updateEditorDatasetValidator } from '#validators/dataset';
import { savePersons } from '#app/utils/utility-functions';
// Create a new instance of the client // Create a new instance of the client
const client = new Client({ node: 'http://localhost:9200' }); // replace with your OpenSearch endpoint const client = new Client({ node: 'http://localhost:9200' }); // replace with your OpenSearch endpoint
@ -63,8 +87,15 @@ export default class DatasetsController {
} }
datasets.orderBy(attribute, sortOrder); datasets.orderBy(attribute, sortOrder);
} else { } 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_reviewer' THEN 0
ELSE 1
END ASC,
id ASC
`);
} }
// const users = await User.query().orderBy('login').paginate(page, limit); // const users = await User.query().orderBy('login').paginate(page, limit);
@ -255,71 +286,6 @@ export default class DatasetsController {
}); });
} }
// private async checkEmailDomain(email: string): Promise<boolean> {
// const domain = email.split('@')[1];
// try {
// // Step 1: Check MX records for the domain
// const mxRecords = await resolveMx(domain);
// if (mxRecords.length === 0) {
// return false; // No MX records, can't send email
// }
// // Sort MX records by priority
// mxRecords.sort((a, b) => a.priority - b.priority);
// // Step 2: Attempt SMTP connection to the first available mail server
// const smtpServer = mxRecords[0].exchange;
// return await this.checkMailboxExists(smtpServer, email);
// } catch (error) {
// console.error('Error during MX lookup or SMTP validation:', error);
// return false;
// }
// }
//// Helper function to check if the mailbox exists using SMTP
// private async checkMailboxExists(smtpServer: string, email: string): Promise<boolean> {
// return new Promise((resolve, reject) => {
// const socket = net.createConnection(25, smtpServer);
// socket.on('connect', () => {
// socket.write(`HELO ${smtpServer}\r\n`);
// socket.write(`MAIL FROM: <test@example.com>\r\n`);
// socket.write(`RCPT TO: <${email}>\r\n`);
// });
// socket.on('data', (data) => {
// const response = data.toString();
// if (response.includes('250')) {
// // 250 is an SMTP success code
// socket.end();
// resolve(true); // Email exists
// } else if (response.includes('550')) {
// // 550 means the mailbox doesn't exist
// socket.end();
// resolve(false); // Email doesn't exist
// }
// });
// socket.on('error', (error) => {
// console.error('SMTP connection error:', error);
// socket.end();
// resolve(false);
// });
// socket.on('end', () => {
// // SMTP connection closed
// });
// socket.setTimeout(5000, () => {
// // Timeout after 5 seconds
// socket.end();
// resolve(false); // Assume email doesn't exist if no response
// });
// });
// }
public async rejectUpdate({ request, response, auth }: HttpContext) { public async rejectUpdate({ request, response, auth }: HttpContext) {
const authUser = auth.user!; const authUser = auth.user!;
@ -353,7 +319,7 @@ export default class DatasetsController {
return response return response
.flash( .flash(
`Invalid server state. Dataset with id ${id} cannot be rejected. Datset has server state ${dataset.server_state}.`, `Invalid server state. Dataset with id ${id} cannot be rejected. Datset has server state ${dataset.server_state}.`,
'warning' 'warning',
) )
.redirect() .redirect()
.toRoute('editor.dataset.list'); .toRoute('editor.dataset.list');
@ -388,7 +354,9 @@ export default class DatasetsController {
emailStatusMessage = ` A rejection email was successfully sent to ${dataset.user.email}.`; emailStatusMessage = ` A rejection email was successfully sent to ${dataset.user.email}.`;
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return response.flash('Dataset has not been rejected due to an email error: ' + error.message, 'error').toRoute('editor.dataset.list'); return response
.flash('Dataset has not been rejected due to an email error: ' + error.message, 'error')
.toRoute('editor.dataset.list');
} }
} else { } else {
emailStatusMessage = ` However, the email could not be sent because the submitter's email address (${dataset.user.email}) is not valid.`; emailStatusMessage = ` However, the email could not be sent because the submitter's email address (${dataset.user.email}) is not valid.`;
@ -536,10 +504,375 @@ export default class DatasetsController {
public async show({}: HttpContext) {} public async show({}: HttpContext) {}
public async edit({}: HttpContext) {} public async edit({ request, inertia, response }: 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('files', (query) => {
query.orderBy('sort_order', 'asc'); // Sort by sort_order column
});
const dataset = await datasetQuery.firstOrFail();
const validStates = ['editor_accepted', 'rejected_reviewer'];
if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!');
return response
.flash(
`Invalid server state. Dataset with id ${id} cannot be edited. Datset has server state ${dataset.server_state}.`,
'warning',
)
.toRoute('editor.dataset.list');
}
const titleTypes = Object.entries(TitleTypes)
.filter(([value]) => value !== 'Main')
.map(([key, value]) => ({ value: key, label: value }));
const descriptionTypes = Object.entries(DescriptionTypes)
.filter(([value]) => value !== 'Abstract')
.map(([key, value]) => ({ value: key, label: value }));
const languages = await Language.query().where('active', true).pluck('part1', 'part1');
// const contributorTypes = Config.get('enums.contributor_types');
const contributorTypes = Object.entries(ContributorTypes).map(([key, value]) => ({ value: key, label: value }));
// const nameTypes = Config.get('enums.name_types');
const nameTypes = Object.entries(PersonNameTypes).map(([key, value]) => ({ value: key, label: value }));
// const messages = await Database.table('messages')
// .pluck('help_text', 'metadata_element');
const projects = await Project.query().pluck('label', 'id');
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
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('Editor/Dataset/Edit', {
dataset,
titletypes: titleTypes,
descriptiontypes: descriptionTypes,
contributorTypes,
nameTypes,
languages,
// messages,
projects,
licenses,
// datasetHasLicenses: Object.keys(datasetHasLicenses).map((key) => datasetHasLicenses[key]), //convert object to array with license ids
// checkeds,
years,
// languages,
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: DatasetTypes,
});
}
public async update({ request, response, session }: HttpContext) {
// Get the dataset id from the route parameter
const datasetId = request.param('id');
// Retrieve the dataset and load its existing files
const dataset = await Dataset.findOrFail(datasetId);
await dataset.load('files');
let trx: TransactionClientContract | null = null;
try {
await request.validateUsing(updateEditorDatasetValidator);
trx = await db.transaction();
// const user = (await User.find(auth.user?.id)) as User;
// await this.createDatasetAndAssociations(user, request, trx);
const dataset = await Dataset.findOrFail(datasetId);
// save the licenses
const licenses: number[] = request.input('licenses', []);
// await dataset.useTransaction(trx).related('licenses').sync(licenses);
await dataset.useTransaction(trx).related('licenses').sync(licenses);
// save authors and contributors
await dataset.useTransaction(trx).related('authors').sync([]);
await dataset.useTransaction(trx).related('contributors').sync([]);
await savePersons(dataset, request.input('authors', []), 'author', trx);
await savePersons(dataset, request.input('contributors', []), 'contributor', trx);
//save the titles:
const titles = request.input('titles', []);
// const savedTitles:Array<Title> = [];
for (const titleData of titles) {
if (titleData.id) {
const title = await Title.findOrFail(titleData.id);
title.value = titleData.value;
title.language = titleData.language;
title.type = titleData.type;
if (title.$isDirty) {
await title.useTransaction(trx).save();
// await dataset.useTransaction(trx).related('titles').save(title);
// savedTitles.push(title);
}
} else {
const title = new Title();
title.fill(titleData);
// savedTitles.push(title);
await dataset.useTransaction(trx).related('titles').save(title);
}
}
// save the abstracts
const descriptions = request.input('descriptions', []);
// const savedTitles:Array<Title> = [];
for (const descriptionData of descriptions) {
if (descriptionData.id) {
const description = await Description.findOrFail(descriptionData.id);
description.value = descriptionData.value;
description.language = descriptionData.language;
description.type = descriptionData.type;
if (description.$isDirty) {
await description.useTransaction(trx).save();
// await dataset.useTransaction(trx).related('titles').save(title);
// savedTitles.push(title);
}
} else {
const description = new Description();
description.fill(descriptionData);
// savedTitles.push(title);
await dataset.useTransaction(trx).related('descriptions').save(description);
}
}
// Process all subjects/keywords from the request
const subjects = request.input('subjects');
for (const subjectData of subjects) {
// Case 1: Subject already exists in the database (has an ID)
if (subjectData.id) {
// Retrieve the existing subject
const existingSubject = await Subject.findOrFail(subjectData.id);
// Update subject properties from the request data
existingSubject.value = subjectData.value;
existingSubject.type = subjectData.type;
existingSubject.external_key = subjectData.external_key;
// Only save if there are actual changes
if (existingSubject.$isDirty) {
await existingSubject.save();
}
// Note: The relationship between dataset and subject is already established,
// so we don't need to attach it again
}
// Case 2: New subject being added (no ID)
else {
// Check if a subject with the same value and type already exists in the database
const subject = await Subject.firstOrNew({ value: subjectData.value, type: subjectData.type }, subjectData);
if (subject.$isNew === true) {
// If it's a completely new subject, create and associate it with the dataset
await dataset.useTransaction(trx).related('subjects').save(subject);
} else {
// If the subject already exists, just create the relationship
await dataset.useTransaction(trx).related('subjects').attach([subject.id]);
}
}
}
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();
// Check if the subject is used by multiple datasets
if (subject.$extras.datasets_count > 1) {
// If used by multiple datasets, just detach it from the current dataset
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
} else {
// If only used by this dataset, delete the subject completely
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
await subject.useTransaction(trx).delete();
}
}
}
// 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 {
// 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();
}
}
// save coverage
const coverageData = request.input('coverage');
if (coverageData) {
if (coverageData.id) {
const coverage = await Coverage.findOrFail(coverageData.id);
coverage.merge(coverageData);
if (coverage.$isDirty) {
await coverage.useTransaction(trx).save();
}
}
}
const input = request.only(['project_id', 'embargo_date', 'language', 'type', 'creating_corporation']);
// dataset.type = request.input('type');
dataset.merge(input);
// let test: boolean = dataset.$isDirty;
await dataset.useTransaction(trx).save();
await trx.commit();
// 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('editor.dataset.edit', [dataset.id]);
} catch (error) {
if (trx !== null) {
await trx.rollback();
}
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;
}
}
public async categorize({ inertia, request, response }: HttpContext) {
const id = request.param('id');
// Preload dataset and its "collections" relation
const dataset = await Dataset.query().where('id', id).preload('collections').firstOrFail();
const validStates = ['editor_accepted', 'rejected_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 edited. Datset has server state ${dataset.server_state}.`,
)
.redirect()
.toRoute('editor.dataset.list');
}
const collectionRoles = await CollectionRole.query()
.preload('collections', (coll: Collection) => {
// preloa only top level collection with noparent_id
coll.whereNull('parent_id').orderBy('number', 'asc');
})
.exec();
return inertia.render('Editor/Dataset/Category', {
collectionRoles: collectionRoles,
dataset: dataset,
relatedCollections: dataset.collections,
});
}
public async categorizeUpdate({ request, response, session }: HttpContext) {
// Get the dataset id from the route parameter
const id = request.param('id');
const dataset = await Dataset.query().preload('files').where('id', id).firstOrFail();
const validStates = ['editor_accepted', 'rejected_reviewer'];
if (!validStates.includes(dataset.server_state)) {
return response
.flash(
'warning',
`Invalid server state. Dataset with id ${id} cannot be categorized. Dataset has server state ${dataset.server_state}.`,
)
.redirect()
.toRoute('editor.dataset.list');
}
let trx: TransactionClientContract | null = null;
try {
trx = await db.transaction();
// const user = (await User.find(auth.user?.id)) as User;
// await this.createDatasetAndAssociations(user, request, trx);
// Retrieve the selected collections from the request.
// This should be an array of collection ids.
const collections: number[] = request.input('collections', []);
// Synchronize the dataset collections using the transaction.
await dataset.useTransaction(trx).related('collections').sync(collections);
// Commit the transaction.await trx.commit()
await trx.commit();
// Redirect with a success flash message.
// return response.flash('success', 'Dataset collections updated successfully!').redirect().toRoute('dataset.list');
session.flash('message', 'Dataset collections updated successfully!');
return response.redirect().toRoute('editor.dataset.list');
} catch (error) {
if (trx !== null) {
await trx.rollback();
}
console.error('Failed tocatgorize dataset collections:', error);
// throw new ValidationException(true, { 'upload error': `failed to create dataset and related models. ${error}` });
throw error;
}
}
// public async update({}: HttpContextContract) {} // public async update({}: HttpContextContract) {}
public async update({ response }: HttpContext) { public async updateOpensearch({ response }: HttpContext) {
const id = 273; //request.param('id'); const id = 273; //request.param('id');
const dataset = await Dataset.query().preload('xmlCache').where('id', id).firstOrFail(); const dataset = await Dataset.query().preload('xmlCache').where('id', id).firstOrFail();
// add xml elements // add xml elements
@ -655,6 +988,19 @@ export default class DatasetsController {
} }
} }
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 destroy({}: HttpContext) {} public async destroy({}: HttpContext) {}
private async createXmlRecord(dataset: Dataset, datasetNode: XMLBuilder) { private async createXmlRecord(dataset: Dataset, datasetNode: XMLBuilder) {

View file

@ -19,14 +19,13 @@ import XmlModel from '#app/Library/XmlModel';
import logger from '@adonisjs/core/services/logger'; import logger from '@adonisjs/core/services/logger';
import ResumptionToken from '#app/Library/Oai/ResumptionToken'; import ResumptionToken from '#app/Library/Oai/ResumptionToken';
// import Config from '@ioc:Adonis/Core/Config'; // import Config from '@ioc:Adonis/Core/Config';
import config from '@adonisjs/core/services/config' import config from '@adonisjs/core/services/config';
// import { inject } from '@adonisjs/fold'; // import { inject } from '@adonisjs/fold';
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core';
// import { TokenWorkerContract } from "MyApp/Models/TokenWorker"; // import { TokenWorkerContract } from "MyApp/Models/TokenWorker";
import TokenWorkerContract from '#library/Oai/TokenWorkerContract'; import TokenWorkerContract from '#library/Oai/TokenWorkerContract';
import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'; import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model';
interface XslTParameter { interface XslTParameter {
[key: string]: any; [key: string]: any;
} }
@ -35,12 +34,14 @@ interface Dictionary {
[index: string]: string; [index: string]: string;
} }
interface ListParameter { interface PagingParameter {
cursor: number; cursor: number;
totalIds: number; totalLength: number;
start: number; start: number;
reldocIds: (number | null)[]; nextDocIds: number[];
activeWorkIds: number[];
metadataPrefix: string; metadataPrefix: string;
queryParams: Object;
} }
@inject() @inject()
@ -49,6 +50,7 @@ export default class OaiController {
private sampleRegEx = /^[A-Za-zäüÄÜß0-9\-_.!~]+$/; private sampleRegEx = /^[A-Za-zäüÄÜß0-9\-_.!~]+$/;
private xsltParameter: XslTParameter; private xsltParameter: XslTParameter;
private firstPublishedDataset: Dataset | null;
/** /**
* Holds xml representation of document information to be processed. * Holds xml representation of document information to be processed.
* *
@ -57,7 +59,6 @@ export default class OaiController {
private xml: XMLBuilder; private xml: XMLBuilder;
private proc; private proc;
constructor(public tokenWorker: TokenWorkerContract) { constructor(public tokenWorker: TokenWorkerContract) {
// Load the XSLT file // Load the XSLT file
this.proc = readFileSync('public/assets2/datasetxml2oai.sef.json'); this.proc = readFileSync('public/assets2/datasetxml2oai.sef.json');
@ -85,9 +86,9 @@ export default class OaiController {
let earliestDateFromDb; let earliestDateFromDb;
// const oaiRequest: OaiParameter = request.body; // const oaiRequest: OaiParameter = request.body;
try { try {
const firstPublishedDataset: Dataset | null = await Dataset.earliestPublicationDate(); this.firstPublishedDataset = await Dataset.earliestPublicationDate();
firstPublishedDataset != null && this.firstPublishedDataset != null &&
(earliestDateFromDb = firstPublishedDataset.server_date_published.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")); (earliestDateFromDb = this.firstPublishedDataset.server_date_published.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"));
this.xsltParameter['earliestDatestamp'] = earliestDateFromDb; this.xsltParameter['earliestDatestamp'] = earliestDateFromDb;
// start the request // start the request
await this.handleRequest(oaiRequest, request); await this.handleRequest(oaiRequest, request);
@ -162,22 +163,19 @@ export default class OaiController {
} else if (verb == 'GetRecord') { } else if (verb == 'GetRecord') {
await this.handleGetRecord(oaiRequest); await this.handleGetRecord(oaiRequest);
} else if (verb == 'ListRecords') { } else if (verb == 'ListRecords') {
await this.handleListRecords(oaiRequest); // Get browser fingerprint from the request:
const browserFingerprint = this.getBrowserFingerprint(request);
await this.handleListRecords(oaiRequest, browserFingerprint);
} else if (verb == 'ListIdentifiers') { } else if (verb == 'ListIdentifiers') {
await this.handleListIdentifiers(oaiRequest); // Get browser fingerprint from the request:
const browserFingerprint = this.getBrowserFingerprint(request);
await this.handleListIdentifiers(oaiRequest, browserFingerprint);
} else if (verb == 'ListSets') { } else if (verb == 'ListSets') {
await this.handleListSets(); await this.handleListSets();
} else { } else {
this.handleIllegalVerb(); this.handleIllegalVerb();
} }
} else { } else {
// // try {
// // console.log("Async code example.")
// const err = new PageNotFoundException("verb not found");
// throw err;
// // } catch (error) { // manually catching
// // next(error); // passing to default middleware error handler
// // }
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
'The verb provided in the request is illegal.', 'The verb provided in the request is illegal.',
@ -187,11 +185,11 @@ export default class OaiController {
} }
protected handleIdentify() { protected handleIdentify() {
const email = process.env.OAI_EMAIL || 'repository@geosphere.at'; // Get configuration values from environment or a dedicated configuration service
const repositoryName = 'Tethys RDR'; const email = process.env.OAI_EMAIL ?? 'repository@geosphere.at';
const repIdentifier = 'tethys.at'; const repositoryName = process.env.OAI_REPOSITORY_NAME ?? 'Tethys RDR';
const sampleIdentifier = 'oai:' + repIdentifier + ':1'; //$this->_configuration->getSampleIdentifier(); const repIdentifier = process.env.OAI_REP_IDENTIFIER ?? 'tethys.at';
const sampleIdentifier = `oai:${repIdentifier}:1`;
// Dataset::earliestPublicationDate()->server_date_published->format('Y-m-d\TH:i:s\Z') : null; // Dataset::earliestPublicationDate()->server_date_published->format('Y-m-d\TH:i:s\Z') : null;
// earliestDateFromDb!= null && (this.xsltParameter['earliestDatestamp'] = earliestDateFromDb?.server_date_published); // earliestDateFromDb!= null && (this.xsltParameter['earliestDatestamp'] = earliestDateFromDb?.server_date_published);
@ -216,7 +214,7 @@ export default class OaiController {
const sets: { [key: string]: string } = { const sets: { [key: string]: string } = {
'open_access': 'Set for open access licenses', 'open_access': 'Set for open access licenses',
'openaire_data': "OpenAIRE", 'openaire_data': 'OpenAIRE',
'doc-type:ResearchData': 'Set for document type ResearchData', 'doc-type:ResearchData': 'Set for document type ResearchData',
...(await this.getSetsForDatasetTypes()), ...(await this.getSetsForDatasetTypes()),
...(await this.getSetsForCollections()), ...(await this.getSetsForCollections()),
@ -234,7 +232,15 @@ export default class OaiController {
const repIdentifier = 'tethys.at'; const repIdentifier = 'tethys.at';
this.xsltParameter['repIdentifier'] = repIdentifier; this.xsltParameter['repIdentifier'] = repIdentifier;
// Validate that required parameter exists early
if (!('identifier' in oaiRequest)) {
throw new BadOaiModelException('The prefix of the identifier argument is unknown.');
}
// Validate and extract the dataset identifier from the request
const dataId = this.validateAndGetIdentifier(oaiRequest); const dataId = this.validateAndGetIdentifier(oaiRequest);
// Retrieve dataset with associated XML cache and collection roles
const dataset = await Dataset.query() const dataset = await Dataset.query()
.where('publish_id', dataId) .where('publish_id', dataId)
.preload('xmlCache') .preload('xmlCache')
@ -251,59 +257,61 @@ export default class OaiController {
); );
} }
// Validate and set the metadata prefix parameter
const metadataPrefix = this.validateAndGetMetadataPrefix(oaiRequest); const metadataPrefix = this.validateAndGetMetadataPrefix(oaiRequest);
this.xsltParameter['oai_metadataPrefix'] = metadataPrefix; this.xsltParameter['oai_metadataPrefix'] = metadataPrefix;
// do not deliver datasets which are restricted by document state defined in deliveringStates
// Ensure that the dataset is in an exportable state
this.validateDatasetState(dataset); this.validateDatasetState(dataset);
// add xml elements // Build the XML for the dataset record and add it to the root node
const datasetNode = this.xml.root().ele('Datasets'); const datasetNode = this.xml.root().ele('Datasets');
await this.createXmlRecord(dataset, datasetNode); await this.createXmlRecord(dataset, datasetNode);
} }
protected async handleListIdentifiers(oaiRequest: Dictionary) { protected async handleListIdentifiers(oaiRequest: Dictionary, browserFingerprint: string) {
!this.tokenWorker.isConnected && (await this.tokenWorker.connect()); if (!this.tokenWorker.isConnected) {
await this.tokenWorker.connect();
}
const maxIdentifier: number = config.get('oai.max.listidentifiers', 100); const maxIdentifier: number = config.get('oai.max.listidentifiers', 100);
await this.handleLists(oaiRequest, maxIdentifier); await this.handleLists(oaiRequest, maxIdentifier, browserFingerprint);
} }
protected async handleListRecords(oaiRequest: Dictionary) { protected async handleListRecords(oaiRequest: Dictionary, browserFingerprint: string) {
!this.tokenWorker.isConnected && (await this.tokenWorker.connect()); if (!this.tokenWorker.isConnected) {
await this.tokenWorker.connect();
}
const maxRecords: number = config.get('oai.max.listrecords', 100); const maxRecords: number = config.get('oai.max.listrecords', 100);
await this.handleLists(oaiRequest, maxRecords); await this.handleLists(oaiRequest, maxRecords, browserFingerprint);
} }
private async handleLists(oaiRequest: Dictionary, maxRecords: number) { private async handleLists(oaiRequest: Dictionary, maxRecords: number, browserFingerprint: string) {
maxRecords = maxRecords || 100;
const repIdentifier = 'tethys.at'; const repIdentifier = 'tethys.at';
this.xsltParameter['repIdentifier'] = repIdentifier; this.xsltParameter['repIdentifier'] = repIdentifier;
const datasetNode = this.xml.root().ele('Datasets'); const datasetNode = this.xml.root().ele('Datasets');
// list initialisation const paginationParams: PagingParameter ={
const numWrapper: ListParameter = {
cursor: 0, cursor: 0,
totalIds: 0, totalLength: 0,
start: maxRecords + 1, start: maxRecords + 1,
reldocIds: [], nextDocIds: [],
activeWorkIds: [],
metadataPrefix: '', metadataPrefix: '',
queryParams: {},
}; };
// resumptionToken is defined
if ('resumptionToken' in oaiRequest) { if ('resumptionToken' in oaiRequest) {
await this.handleResumptionToken(oaiRequest, maxRecords, numWrapper); await this.handleResumptionToken(oaiRequest, maxRecords, paginationParams);
} else { } else {
// no resumptionToken is given await this.handleNoResumptionToken(oaiRequest, paginationParams, maxRecords);
await this.handleNoResumptionToken(oaiRequest, numWrapper);
} }
// handling of document ids const nextIds: number[] = paginationParams.nextDocIds;
const restIds = numWrapper.reldocIds as number[]; const workIds: number[] = paginationParams.activeWorkIds;
const workIds = restIds.splice(0, maxRecords) as number[]; // array_splice(restIds, 0, maxRecords);
// no records returned if (workIds.length === 0) {
if (workIds.length == 0) {
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
'The combination of the given values results in an empty list.', 'The combination of the given values results in an empty list.',
@ -311,169 +319,218 @@ export default class OaiController {
); );
} }
const datasets: Dataset[] = await Dataset.query() const datasets = await Dataset.query()
.whereIn('publish_id', workIds) .whereIn('publish_id', workIds)
.preload('xmlCache') .preload('xmlCache')
.preload('collections', (builder) => { .preload('collections', (builder) => {
builder.preload('collectionRole'); builder.preload('collectionRole');
}) })
.orderBy('publish_id'); .orderBy('publish_id');
for (const dataset of datasets) { for (const dataset of datasets) {
await this.createXmlRecord(dataset, datasetNode); await this.createXmlRecord(dataset, datasetNode);
} }
await this.setResumptionToken(nextIds, paginationParams, browserFingerprint);
// store the further Ids in a resumption-file
const countRestIds = restIds.length; //84
if (countRestIds > 0) {
const token = new ResumptionToken();
token.startPosition = numWrapper.start; //101
token.totalIds = numWrapper.totalIds; //184
token.documentIds = restIds; //101 -184
token.metadataPrefix = numWrapper.metadataPrefix;
// $tokenWorker->storeResumptionToken($token);
const res: string = await this.tokenWorker.set(token);
// set parameters for the resumptionToken-node
// const res = token.ResumptionId;
this.setParamResumption(res, numWrapper.cursor, numWrapper.totalIds);
}
} }
private async handleResumptionToken(oaiRequest: Dictionary, maxRecords: number, numWrapper: ListParameter) { private async handleNoResumptionToken(oaiRequest: Dictionary, paginationParams: PagingParameter, maxRecords: number) {
const resParam = oaiRequest['resumptionToken']; //e.g. "158886496600000" this.validateMetadataPrefix(oaiRequest, paginationParams);
const finder: ModelQueryBuilderContract<typeof Dataset, Dataset> = Dataset.query().whereIn(
'server_state',
this.deliveringDocumentStates,
);
this.applySetFilter(finder, oaiRequest);
this.applyDateFilters(finder, oaiRequest);
await this.fetchAndSetResults(finder, paginationParams, oaiRequest, maxRecords);
}
private async fetchAndSetResults(
finder: ModelQueryBuilderContract<typeof Dataset, Dataset>,
paginationParams: PagingParameter,
oaiRequest: Dictionary,
maxRecords: number
) {
const totalResult = await finder
.clone()
.count('* as total')
.first()
.then((res) => res?.$extras.total);
paginationParams.totalLength = Number(totalResult);
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));
// No resumption token was used set queryParams from the current oaiRequest
paginationParams.queryParams = {
...oaiRequest,
deliveringStates: this.deliveringDocumentStates,
};
// paginationParams.totalLength = 230;
}
private async handleResumptionToken(oaiRequest: Dictionary, maxRecords: number, paginationParams: PagingParameter) {
const resParam = oaiRequest['resumptionToken'];
const token = await this.tokenWorker.get(resParam); const token = await this.tokenWorker.get(resParam);
if (!token) { if (!token) {
throw new OaiModelException(StatusCodes.INTERNAL_SERVER_ERROR, 'cache is outdated.', OaiErrorCodes.BADRESUMPTIONTOKEN); throw new OaiModelException(StatusCodes.INTERNAL_SERVER_ERROR, 'cache is outdated.', OaiErrorCodes.BADRESUMPTIONTOKEN);
} }
numWrapper.cursor = token.startPosition - 1; //startet dann bei Index 10 // this.setResumptionParameters(token, maxRecords, paginationParams);
numWrapper.start = token.startPosition + maxRecords; paginationParams.cursor = token.startPosition - 1;
numWrapper.totalIds = token.totalIds; paginationParams.start = token.startPosition + maxRecords;
numWrapper.reldocIds = token.documentIds; paginationParams.totalLength = token.totalIds;
numWrapper.metadataPrefix = token.metadataPrefix; paginationParams.activeWorkIds = token.documentIds;
paginationParams.metadataPrefix = token.metadataPrefix;
paginationParams.queryParams = token.queryParams;
this.xsltParameter['oai_metadataPrefix'] = token.metadataPrefix;
this.xsltParameter['oai_metadataPrefix'] = numWrapper.metadataPrefix; const finder = this.buildDatasetQueryViaToken(token);
const nextRecords: Dataset[] = await this.fetchNextRecords(finder, token, maxRecords);
paginationParams.nextDocIds = nextRecords.map((dat) => Number(dat.publish_id));
} }
private async handleNoResumptionToken(oaiRequest: Dictionary, numWrapper: ListParameter) { private async setResumptionToken(nextIds: number[], paginationParams: PagingParameter, browserFingerprint: string) {
// no resumptionToken is given const countRestIds = nextIds.length;
if ('metadataPrefix' in oaiRequest) { if (countRestIds > 0) {
numWrapper.metadataPrefix = oaiRequest['metadataPrefix']; // const token = this.createResumptionToken(paginationParams, nextIds);
} else { const token = new ResumptionToken();
token.startPosition = paginationParams.start;
token.totalIds = paginationParams.totalLength;
token.documentIds = nextIds;
token.metadataPrefix = paginationParams.metadataPrefix;
token.queryParams = paginationParams.queryParams;
const res: string = await this.tokenWorker.set(token, browserFingerprint);
this.setParamResumption(res, paginationParams.cursor, paginationParams.totalLength);
}
}
private buildDatasetQueryViaToken(token: ResumptionToken) {
const finder = Dataset.query();
const originalQuery = token.queryParams || {};
const deliveringStates = originalQuery.deliveringStates || this.deliveringDocumentStates;
finder.whereIn('server_state', deliveringStates);
this.applySetFilter(finder, originalQuery);
this.applyDateFilters(finder, originalQuery);
return finder;
}
private async fetchNextRecords(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, token: ResumptionToken, maxRecords: number) {
return finder
.select('publish_id')
.orderBy('publish_id')
.offset(token.startPosition - 1 + maxRecords)
.limit(100);
}
private validateMetadataPrefix(oaiRequest: Dictionary, paginationParams: PagingParameter) {
if (!('metadataPrefix' in oaiRequest)) {
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
'The prefix of the metadata argument is unknown.', 'The prefix of the metadata argument is unknown.',
OaiErrorCodes.BADARGUMENT, OaiErrorCodes.BADARGUMENT,
); );
} }
this.xsltParameter['oai_metadataPrefix'] = numWrapper.metadataPrefix; paginationParams.metadataPrefix = oaiRequest['metadataPrefix'];
this.xsltParameter['oai_metadataPrefix'] = paginationParams.metadataPrefix;
let finder: ModelQueryBuilderContract<typeof Dataset, Dataset> = Dataset.query();
// add server state restrictions
finder.whereIn('server_state', this.deliveringDocumentStates);
if ('set' in oaiRequest) {
const set = oaiRequest['set'] as string;
const setArray = set.split(':');
if (setArray[0] == 'data-type') {
if (setArray.length == 2 && setArray[1]) {
finder.where('type', setArray[1]);
} }
} else if (setArray[0] == 'open_access') {
const openAccessLicences = ['CC-BY-4.0', 'CC-BY-SA-4.0']; private applySetFilter(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, queryParams: any) {
if ('set' in queryParams) {
const [setType, setValue] = queryParams['set'].split(':');
switch (setType) {
case 'data-type':
setValue && finder.where('type', setValue);
break;
case 'open_access':
finder.andWhereHas('licenses', (query) => { finder.andWhereHas('licenses', (query) => {
query.whereIn('name', openAccessLicences); query.whereIn('name', ['CC-BY-4.0', 'CC-BY-SA-4.0']);
}); });
} else if (setArray[0] == 'ddc') { break;
if (setArray.length == 2 && setArray[1] != '') { case 'ddc':
setValue &&
finder.andWhereHas('collections', (query) => { finder.andWhereHas('collections', (query) => {
query.where('number', setArray[1]); query.where('number', setValue);
}); });
break;
} }
} }
} }
// const timeZone = "Europe/Vienna"; // Canonical time zone name private applyDateFilters(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, queryParams: any) {
// &from=2020-09-03&until2020-09-03 const { from, until } = queryParams;
// &from=2020-09-11&until=2021-05-11
if ('from' in oaiRequest && 'until' in oaiRequest) {
const from = oaiRequest['from'] as string;
let fromDate = dayjs(from); //.tz(timeZone);
const until = oaiRequest['until'] as string;
let untilDate = dayjs(until); //.tz(timeZone);
if (!fromDate.isValid() || !untilDate.isValid()) {
throw new OaiModelException(StatusCodes.INTERNAL_SERVER_ERROR, 'Date Parameter is not valid.', OaiErrorCodes.BADARGUMENT);
}
fromDate = dayjs.tz(from, 'Europe/Vienna');
untilDate = dayjs.tz(until, 'Europe/Vienna');
if (from.length != until.length) { if (from && until) {
this.handleFromUntilFilter(finder, from, until);
} else if (from) {
this.handleFromFilter(finder, from);
} else if (until) {
this.handleUntilFilter(finder, until);
}
}
private handleFromUntilFilter(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, from: string, until: string) {
const fromDate = this.parseDateWithValidation(from, 'From');
const untilDate = this.parseDateWithValidation(until, 'Until');
if (from.length !== until.length) {
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
'The request has different granularities for the from and until parameters.', 'The request has different granularities for the from and until parameters.',
OaiErrorCodes.BADARGUMENT, OaiErrorCodes.BADARGUMENT,
); );
} }
fromDate.hour() == 0 && (fromDate = fromDate.startOf('day'));
untilDate.hour() == 0 && (untilDate = untilDate.endOf('day'));
finder.whereBetween('server_date_published', [fromDate.format('YYYY-MM-DD HH:mm:ss'), untilDate.format('YYYY-MM-DD HH:mm:ss')]); finder.whereBetween('server_date_published', [fromDate.format('YYYY-MM-DD HH:mm:ss'), untilDate.format('YYYY-MM-DD HH:mm:ss')]);
} else if ('from' in oaiRequest && !('until' in oaiRequest)) {
const from = oaiRequest['from'] as string;
let fromDate = dayjs(from);
if (!fromDate.isValid()) {
throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR,
'From date parameter is not valid.',
OaiErrorCodes.BADARGUMENT,
);
} }
fromDate = dayjs.tz(from, 'Europe/Vienna');
fromDate.hour() == 0 && (fromDate = fromDate.startOf('day'));
private handleFromFilter(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, from: string) {
const fromDate = this.parseDateWithValidation(from, 'From');
const now = dayjs(); const now = dayjs();
if (fromDate.isAfter(now)) { if (fromDate.isAfter(now)) {
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
'Given from date is greater than now. The given values results in an empty list.', 'Given from date is greater than now. The given values results in an empty list.',
OaiErrorCodes.NORECORDSMATCH, OaiErrorCodes.NORECORDSMATCH,
); );
} else { }
finder.andWhere('server_date_published', '>=', fromDate.format('YYYY-MM-DD HH:mm:ss')); finder.andWhere('server_date_published', '>=', fromDate.format('YYYY-MM-DD HH:mm:ss'));
} }
} else if (!('from' in oaiRequest) && 'until' in oaiRequest) {
const until = oaiRequest['until'] as string;
let untilDate = dayjs(until);
if (!untilDate.isValid()) {
throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR,
'Until date parameter is not valid.',
OaiErrorCodes.BADARGUMENT,
);
}
untilDate = dayjs.tz(until, 'Europe/Vienna');
untilDate.hour() == 0 && (untilDate = untilDate.endOf('day'));
const firstPublishedDataset: Dataset = (await Dataset.earliestPublicationDate()) as Dataset; private handleUntilFilter(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, until: string) {
const earliestPublicationDate = dayjs(firstPublishedDataset.server_date_published.toISO()); //format("YYYY-MM-DDThh:mm:ss[Z]")); const untilDate = this.parseDateWithValidation(until, 'Until');
const earliestPublicationDate = dayjs(this.firstPublishedDataset?.server_date_published.toISO());
if (earliestPublicationDate.isAfter(untilDate)) { if (earliestPublicationDate.isAfter(untilDate)) {
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
`earliestDatestamp is greater than given until date. 'earliestDatestamp is greater than given until date. The given values results in an empty list.',
The given values results in an empty list.`,
OaiErrorCodes.NORECORDSMATCH, OaiErrorCodes.NORECORDSMATCH,
); );
} else {
finder.andWhere('server_date_published', '<=', untilDate.format('YYYY-MM-DD HH:mm:ss'));
}
} }
let reldocIdsDocs = await finder.select('publish_id').orderBy('publish_id'); finder.andWhere('server_date_published', '<=', untilDate.format('YYYY-MM-DD HH:mm:ss'));
numWrapper.reldocIds = reldocIdsDocs.map((dat) => dat.publish_id); }
numWrapper.totalIds = numWrapper.reldocIds.length; //212
private parseDateWithValidation(dateStr: string, label: string) {
let date = dayjs(dateStr);
if (!date.isValid()) {
throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR,
`${label} date parameter is not valid.`,
OaiErrorCodes.BADARGUMENT,
);
}
date = dayjs.tz(dateStr, 'Europe/Vienna');
return date.hour() === 0 ? (label === 'From' ? date.startOf('day') : date.endOf('day')) : date;
} }
private setParamResumption(res: string, cursor: number, totalIds: number) { private setParamResumption(res: string, cursor: number, totalIds: number) {
@ -641,4 +698,30 @@ export default class OaiController {
this.xsltParameter['oai_error_code'] = 'badVerb'; this.xsltParameter['oai_error_code'] = 'badVerb';
this.xsltParameter['oai_error_message'] = 'The verb provided in the request is illegal.'; this.xsltParameter['oai_error_message'] = 'The verb provided in the request is illegal.';
} }
/**
* Helper method to build a browser fingerprint by combining:
* - User-Agent header,
* - the IP address,
* - Accept-Language header,
* - current timestamp rounded to the hour.
*
* Every new hour, this will return a different fingerprint.
*/
private getBrowserFingerprint(request: Request): string {
const userAgent = request.header('user-agent') || 'unknown';
// Check for X-Forwarded-For header to use the client IP from the proxy if available.
const xForwardedFor = request.header('x-forwarded-for');
let ip = request.ip();
// console.log(ip);
if (xForwardedFor) {
// X-Forwarded-For may contain a comma-separated list of IPs; the first one is the client IP.
ip = xForwardedFor.split(',')[0].trim();
// console.log('xforwardedfor ip' + ip);
}
const locale = request.header('accept-language') || 'default';
// Round the current time to the start of the hour.
const timestampHour = dayjs().startOf('hour').format('YYYY-MM-DDTHH');
return `${userAgent}-${ip}-${locale}-${timestampHour}`;
}
} }

View file

@ -62,7 +62,7 @@ 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 id = request.param('id');
const dataset = await Dataset.query() const dataset = await Dataset.query()
.where('id', id) .where('id', id)
@ -158,6 +158,10 @@ export default class DatasetsController {
return inertia.render('Reviewer/Dataset/Review', { return inertia.render('Reviewer/Dataset/Review', {
dataset, dataset,
fields: fields, fields: fields,
can: {
review: await auth.user?.can(['dataset-review']),
reject: await auth.user?.can(['dataset-review-reject']),
},
}); });
} }
@ -276,7 +280,7 @@ export default class DatasetsController {
validateSMTP: false, validateSMTP: false,
}); });
const validRecipientEmail: boolean = validationResult.valid; const validRecipientEmail: boolean = validationResult.valid;
let emailStatusMessage = ''; // let emailStatusMessage = '';
if (sendMail == true) { if (sendMail == true) {
if (dataset.editor.email && validRecipientEmail) { if (dataset.editor.email && validRecipientEmail) {
@ -289,7 +293,7 @@ export default class DatasetsController {
<p>Best regards,<br>Your Tethys reviewer: ${authUser.login}</p> <p>Best regards,<br>Your Tethys reviewer: ${authUser.login}</p>
`); `);
}); });
emailStatusMessage = ` A rejection email was successfully sent to ${dataset.editor.email}.`; // emailStatusMessage = ` A rejection email was successfully sent to ${dataset.editor.email}.`;
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return response return response
@ -297,7 +301,7 @@ export default class DatasetsController {
.toRoute('reviewer.dataset.list'); .toRoute('reviewer.dataset.list');
} }
} else { } else {
emailStatusMessage = ` However, the email could not be sent because the editor's email address (${dataset.editor.email}) is not valid.`; // emailStatusMessage = ` However, the email could not be sent because the editor's email address (${dataset.editor.email}) is not valid.`;
} }
} }

View file

@ -8,6 +8,7 @@ import Description from '#models/description';
import Language from '#models/language'; import Language from '#models/language';
import Coverage from '#models/coverage'; import Coverage from '#models/coverage';
import Collection from '#models/collection'; import Collection from '#models/collection';
import CollectionRole from '#models/collection_role';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import Person from '#models/person'; import Person from '#models/person';
import db from '@adonisjs/lucid/services/db'; import db from '@adonisjs/lucid/services/db';
@ -28,21 +29,30 @@ import {
} from '#contracts/enums'; } from '#contracts/enums';
import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'; import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model';
import DatasetReference from '#models/dataset_reference'; import DatasetReference from '#models/dataset_reference';
import { cuid } from '@adonisjs/core/helpers';
import File from '#models/file'; import File from '#models/file';
import ClamScan from 'clamscan'; import ClamScan from 'clamscan';
// import { ValidationException } from '@adonisjs/validator'; import drive from '@adonisjs/drive/services/main';
// import Drive from '@ioc:Adonis/Core/Drive'; import path from 'path';
import drive from '#services/drive';
import { Exception } from '@adonisjs/core/exceptions'; import { Exception } from '@adonisjs/core/exceptions';
import { MultipartFile } from '@adonisjs/core/types/bodyparser'; import { MultipartFile } from '@adonisjs/core/types/bodyparser';
import * as crypto from 'crypto'; 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 } from '#app/utils/utility-functions';
interface Dictionary { interface Dictionary {
[index: string]: string; [index: string]: string;
} }
import vine, { SimpleMessagesProvider, errors } from '@vinejs/vine'; import vine, { SimpleMessagesProvider, errors } from '@vinejs/vine';
export default class DatasetController { export default class DatasetController {
/**
* Bodyparser config
*/
// config: BodyParserConfig = config.get('bodyparser');
public async index({ auth, request, inertia }: HttpContext) { public async index({ auth, request, inertia }: HttpContext) {
const user = (await User.find(auth.user?.id)) as User; const user = (await User.find(auth.user?.id)) as User;
const page = request.input('page', 1); const page = request.input('page', 1);
@ -66,8 +76,16 @@ export default class DatasetController {
} }
datasets.orderBy(attribute, sortOrder); datasets.orderBy(attribute, sortOrder);
} else { } 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 // const results = await Database
@ -188,7 +206,8 @@ export default class DatasetController {
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }), .translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}), }),
) )
.minLength(1), // .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine descriptions: vine
.array( .array(
vine.object({ vine.object({
@ -202,7 +221,8 @@ export default class DatasetController {
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }), .translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}), }),
) )
.minLength(1), // .minLength(1),
.arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine authors: vine
.array( .array(
vine.object({ vine.object({
@ -277,7 +297,8 @@ export default class DatasetController {
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }), .translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}), }),
) )
.minLength(1), // .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine descriptions: vine
.array( .array(
vine.object({ vine.object({
@ -291,7 +312,8 @@ export default class DatasetController {
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }), .translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}), }),
) )
.minLength(1), // .minLength(1),
.arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine authors: vine
.array( .array(
vine.object({ vine.object({
@ -363,7 +385,8 @@ export default class DatasetController {
references: vine references: vine
.array( .array(
vine.object({ vine.object({
value: vine.string().trim().minLength(3).maxLength(255), // value: vine.string().trim().minLength(3).maxLength(255),
value: vine.string().trim().minLength(3).maxLength(255).validateReference({ typeField: 'type' }),
type: vine.enum(Object.values(ReferenceIdentifierTypes)), type: vine.enum(Object.values(ReferenceIdentifierTypes)),
relation: vine.enum(Object.values(RelationTypes)), relation: vine.enum(Object.values(RelationTypes)),
label: vine.string().trim().minLength(2).maxLength(255), label: vine.string().trim().minLength(2).maxLength(255),
@ -398,21 +421,99 @@ export default class DatasetController {
} }
public async store({ auth, request, response, session }: HttpContext) { public async store({ auth, request, response, session }: HttpContext) {
// node ace make:validator CreateDataset // At the top of the store() method, declare an array to hold temporary file paths
const uploadedTmpFiles: string[] = [];
// Aggregated limit example (adjust as needed)
const multipartConfig = getConfigFor('multipart');
const aggregatedLimit = multipartConfig.limit ? parseBytesSize(multipartConfig.limit) : 100 * 1024 * 1024;
// const aggregatedLimit = 200 * 1024 * 1024;
let totalUploadedSize = 0;
// // Helper function to format bytes as human-readable text
// 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];
// }
// const enabledExtensions = await this.getEnabledExtensions();
const multipart: Multipart = request.multipart;
multipart.onFile('files', { deferValidations: true }, async (part) => {
// Attach an individual file size accumulator if needed
let fileUploadedSize = 0;
// Simply accumulate the size in on('data') without performing the expensive check per chunk
part.on('data', (chunk) => {
// reporter(chunk);
// Increase counters using the chunk length
fileUploadedSize += chunk.length;
});
// After the file is completely read, update the global counter and check 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);
}
if (totalUploadedSize > aggregatedLimit) {
// Clean up all temporary files if aggregate limit is exceeded
uploadedTmpFiles.forEach((tmpPath) => {
try { try {
// Step 2 - Validate request body against the schema fs.unlinkSync(tmpPath);
// await request.validate({ schema: newDatasetSchema, messages: this.messages }); } catch (cleanupError) {
// await request.validate(CreateDatasetValidator); console.error('Error cleaning up temporary file:', cleanupError);
await request.validateUsing(createDatasetValidator); }
// console.log({ payload }); });
const error = new errors.E_VALIDATION_ERROR({
'upload error': `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) => {
// fileUploadError = error;
request.multipart.abort(error);
});
// await pipeline(part, createWriteStream(filePath));
// return { filePath };
// 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('.', '');
// Attach the extracted extension to the file object for later use
part.file.extname = ext;
// part.file.sortOrder = part.file.sortOrder;
const tmpPath = getTmpPath(multipartConfig);
(part.file as any).tmpPath = tmpPath;
const writeStream = createWriteStream(tmpPath);
await pipeline(part, writeStream);
} catch (error) { } catch (error) {
// Step 3 - Handle errors request.multipart.abort(new errors.E_VALIDATION_ERROR({ 'upload error': error.message }));
// return response.badRequest(error.messages); }
throw error; });
try {
await multipart.process();
// // Instead of letting an error abort the controller, check if any error occurred
} catch (error) {
// This is where you'd expect to catch any errors.
session.flash('errors', error.messages);
return response.redirect().back();
} }
let trx: TransactionClientContract | null = null; let trx: TransactionClientContract | null = null;
try { try {
await request.validateUsing(createDatasetValidator);
trx = await db.transaction(); trx = await db.transaction();
const user = (await User.find(auth.user?.id)) as User; const user = (await User.find(auth.user?.id)) as User;
@ -421,6 +522,14 @@ export default class DatasetController {
await trx.commit(); await trx.commit();
console.log('Dataset and related models created successfully'); console.log('Dataset and related models created successfully');
} catch (error) { } 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) { if (trx !== null) {
await trx.rollback(); await trx.rollback();
} }
@ -433,14 +542,19 @@ export default class DatasetController {
return response.redirect().toRoute('dataset.list'); return response.redirect().toRoute('dataset.list');
// return response.redirect().back(); // return response.redirect().back();
} }
private async createDatasetAndAssociations(
private async createDatasetAndAssociations(user: User, request: HttpContext['request'], trx: TransactionClientContract) { user: User,
request: HttpContext['request'],
trx: TransactionClientContract,
// uploadedFiles: Array<MultipartFile>,
) {
// Create a new instance of the Dataset model: // Create a new instance of the Dataset model:
const dataset = new Dataset(); const dataset = new Dataset();
dataset.type = request.input('type'); dataset.type = request.input('type');
dataset.creating_corporation = request.input('creating_corporation'); dataset.creating_corporation = request.input('creating_corporation');
dataset.language = request.input('language'); dataset.language = request.input('language');
dataset.embargo_date = request.input('embargo_date'); dataset.embargo_date = request.input('embargo_date');
dataset.project_id = request.input('project_id');
//await dataset.related('user').associate(user); // speichert schon ab //await dataset.related('user').associate(user); // speichert schon ab
// Dataset.$getRelation('user').boot(); // Dataset.$getRelation('user').boot();
// Dataset.$getRelation('user').setRelated(dataset, user); // Dataset.$getRelation('user').setRelated(dataset, user);
@ -498,7 +612,7 @@ export default class DatasetController {
} }
// save collection // save collection
const collection: Collection | null = await Collection.query().where('id', 21).first(); const collection: Collection | null = await Collection.query().where('id', 594).first();
collection && (await dataset.useTransaction(trx).related('collections').attach([collection.id])); collection && (await dataset.useTransaction(trx).related('collections').attach([collection.id]));
// save coverage // save coverage
@ -531,18 +645,25 @@ export default class DatasetController {
const fileName = this.generateFilename(file.extname as string); const fileName = this.generateFilename(file.extname as string);
const mimeType = file.headers['content-type'] || 'application/octet-stream'; // Fallback to a default MIME type const mimeType = file.headers['content-type'] || 'application/octet-stream'; // Fallback to a default MIME type
const datasetFolder = `files/${dataset.id}`; const datasetFolder = `files/${dataset.id}`;
const datasetFullPath = path.join(`${datasetFolder}`, fileName);
// const size = file.size; // const size = file.size;
await file.move(drive.makePath(datasetFolder), { // await file.move(drive.makePath(datasetFolder), {
// name: fileName,
// overwrite: true, // overwrite in case of conflict
// });
await file.moveToDisk(datasetFullPath, 'local', {
name: fileName, name: fileName,
overwrite: true, // overwrite in case of conflict overwrite: true, // overwrite in case of conflict
disk: 'local',
}); });
// save file metadata into db // save file metadata into db
const newFile = new File(); const newFile = new File();
newFile.pathName = `${datasetFolder}/${fileName}`; newFile.pathName = `${datasetFolder}/${fileName}`;
newFile.fileSize = file.size; newFile.fileSize = file.size;
newFile.mimeType = mimeType; newFile.mimeType = mimeType;
newFile.label = file.clientName; newFile.label = file.clientName;
newFile.sortOrder = index; newFile.sortOrder = index + 1;
newFile.visibleInFrontdoor = true; newFile.visibleInFrontdoor = true;
newFile.visibleInOai = true; newFile.visibleInOai = true;
// let path = coverImage.filePath; // let path = coverImage.filePath;
@ -693,6 +814,8 @@ export default class DatasetController {
'files.array.minLength': 'At least {{ min }} file upload is required.', 'files.array.minLength': 'At least {{ min }} file upload is required.',
'files.*.size': 'file size is to big', 'files.*.size': 'file size is to big',
'files.*.extnames': 'file extension is not supported', '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({ params, view }) {
@ -803,7 +926,7 @@ export default class DatasetController {
// throw new GeneralException(trans('exceptions.publish.release.update_error')); // 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 id = request.param('id');
const datasetQuery = Dataset.query().where('id', id); const datasetQuery = Dataset.query().where('id', id);
datasetQuery datasetQuery
@ -811,8 +934,8 @@ export default class DatasetController {
.preload('descriptions', (query) => query.orderBy('id', 'asc')) .preload('descriptions', (query) => query.orderBy('id', 'asc'))
.preload('coverage') .preload('coverage')
.preload('licenses') .preload('licenses')
.preload('authors') .preload('authors', (query) => query.orderBy('pivot_sort_order', 'asc'))
.preload('contributors') .preload('contributors', (query) => query.orderBy('pivot_sort_order', 'asc'))
// .preload('subjects') // .preload('subjects')
.preload('subjects', (builder) => { .preload('subjects', (builder) => {
builder.orderBy('id', 'asc').withCount('datasets'); builder.orderBy('id', 'asc').withCount('datasets');
@ -828,10 +951,9 @@ export default class DatasetController {
// session.flash('errors', 'Invalid server state!'); // session.flash('errors', 'Invalid server state!');
return response return response
.flash( .flash(
'warning',
`Invalid server state. Dataset with id ${id} cannot be edited. Datset has server state ${dataset.server_state}.`, `Invalid server state. Dataset with id ${id} cannot be edited. Datset has server state ${dataset.server_state}.`,
'warning',
) )
.redirect()
.toRoute('dataset.list'); .toRoute('dataset.list');
} }
@ -865,15 +987,15 @@ export default class DatasetController {
// const datasetHasLicenses = await dataset.related('licenses').query().pluck('id'); // const datasetHasLicenses = await dataset.related('licenses').query().pluck('id');
// const checkeds = dataset.licenses.first().id; // const checkeds = dataset.licenses.first().id;
const doctypes = { // const doctypes = {
analysisdata: { label: 'Analysis', value: 'analysisdata' }, // analysisdata: { label: 'Analysis', value: 'analysisdata' },
measurementdata: { label: 'Measurements', value: 'measurementdata' }, // measurementdata: { label: 'Measurements', value: 'measurementdata' },
monitoring: 'Monitoring', // monitoring: 'Monitoring',
remotesensing: 'Remote Sensing', // remotesensing: 'Remote Sensing',
gis: 'GIS', // gis: 'GIS',
models: 'Models', // models: 'Models',
mixedtype: 'Mixed Type', // mixedtype: 'Mixed Type',
}; // };
return inertia.render('Submitter/Dataset/Edit', { return inertia.render('Submitter/Dataset/Edit', {
dataset, dataset,
@ -892,25 +1014,95 @@ export default class DatasetController {
subjectTypes: SubjectTypes, subjectTypes: SubjectTypes,
referenceIdentifierTypes: Object.entries(ReferenceIdentifierTypes).map(([key, value]) => ({ value: key, label: value })), referenceIdentifierTypes: Object.entries(ReferenceIdentifierTypes).map(([key, value]) => ({ value: key, label: value })),
relationTypes: Object.entries(RelationTypes).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) { public async update({ request, response, session }: HttpContext) {
try { // Get the dataset id from the route parameter
// await request.validate(UpdateDatasetValidator); const datasetId = request.param('id');
await request.validateUsing(updateDatasetValidator); // Retrieve the dataset and load its existing files
} catch (error) { const dataset = await Dataset.findOrFail(datasetId);
// - Handle errors await dataset.load('files');
// return response.badRequest(error.messages); // Accumulate the size of the already related files
throw error; // const preExistingFileSize = dataset.files.reduce((acc, file) => acc + file.fileSize, 0);
// return response.badRequest(error.messages); let preExistingFileSize = 0;
for (const file of dataset.files) {
preExistingFileSize += Number(file.fileSize);
} }
// await request.validate(UpdateDatasetValidator);
const id = request.param('id');
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({
'upload error': `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; let trx: TransactionClientContract | null = null;
try { try {
await request.validateUsing(updateDatasetValidator);
trx = await db.transaction(); trx = await db.transaction();
// const user = (await User.find(auth.user?.id)) as User; // const user = (await User.find(auth.user?.id)) as User;
// await this.createDatasetAndAssociations(user, request, trx); // await this.createDatasetAndAssociations(user, request, trx);
@ -971,22 +1163,97 @@ export default class DatasetController {
} }
} }
// await dataset.useTransaction(trx).related('subjects').sync([]); // Process all subjects/keywords from the request
const keywords = request.input('subjects'); const subjects = request.input('subjects');
for (const keywordData of keywords) { for (const subjectData of subjects) {
if (keywordData.id) { // Case 1: Subject already exists in the database (has an ID)
const subject = await Subject.findOrFail(keywordData.id); if (subjectData.id) {
// await dataset.useTransaction(trx).related('subjects').attach([keywordData.id]); // Retrieve the existing subject
subject.value = keywordData.value; const existingSubject = await Subject.findOrFail(subjectData.id);
subject.type = keywordData.type;
subject.external_key = keywordData.external_key; // Update subject properties from the request data
if (subject.$isDirty) { existingSubject.value = subjectData.value;
await subject.save(); existingSubject.type = subjectData.type;
existingSubject.external_key = subjectData.external_key;
// Only save if there are actual changes
if (existingSubject.$isDirty) {
await existingSubject.save();
}
// Note: The relationship between dataset and subject is already established,
// so we don't need to attach it again
}
// Case 2: New subject being added (no ID)
else {
// Check if a subject with the same value and type already exists in the database
const subject = await Subject.firstOrNew({ value: subjectData.value, type: subjectData.type }, subjectData);
if (subject.$isNew === true) {
// If it's a completely new subject, create and associate it with the dataset
await dataset.useTransaction(trx).related('subjects').save(subject);
} else {
// If the subject already exists, just create the relationship
await dataset.useTransaction(trx).related('subjects').attach([subject.id]);
}
}
}
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();
// Check if the subject is used by multiple datasets
if (subject.$extras.datasets_count > 1) {
// If used by multiple datasets, just detach it from the current dataset
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
} else {
// If only used by this dataset, delete the subject completely
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
await subject.useTransaction(trx).delete();
}
}
}
// 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 { } else {
const keyword = new Subject(); // Create new reference
keyword.fill(keywordData); const dataReference = new DatasetReference();
await dataset.useTransaction(trx).related('subjects').save(keyword, false); 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();
} }
} }
@ -1018,9 +1285,9 @@ export default class DatasetController {
// handle new uploaded files: // handle new uploaded files:
const uploadedFiles: MultipartFile[] = request.files('files'); const uploadedFiles: MultipartFile[] = request.files('files');
if (Array.isArray(uploadedFiles) && uploadedFiles.length > 0) { if (Array.isArray(uploadedFiles) && uploadedFiles.length > 0) {
for (const [index, fileData] of uploadedFiles.entries()) { for (const [index, file] of uploadedFiles.entries()) {
try { try {
await this.scanFileForViruses(fileData.tmpPath); //, 'gitea.lan', 3310); await this.scanFileForViruses(file.tmpPath); //, 'gitea.lan', 3310);
// await this.scanFileForViruses("/tmp/testfile.txt"); // await this.scanFileForViruses("/tmp/testfile.txt");
} catch (error) { } catch (error) {
// If the file is infected or there's an error scanning the file, throw a validation exception // If the file is infected or there's an error scanning the file, throw a validation exception
@ -1028,23 +1295,29 @@ export default class DatasetController {
} }
// move to disk: // 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 datasetFolder = `files/${dataset.id}`; // 'files/307'
// await fileData.moveToDisk(datasetFolder, { name: fileName, overwrite: true }, 'local'); const datasetFullPath = path.join(`${datasetFolder}`, fileName);
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 file.moveToDisk(datasetFullPath, 'local', {
name: fileName, name: fileName,
overwrite: true, // overwrite in case of conflict overwrite: true, // overwrite in case of conflict
disk: 'local',
}); });
//save to db: //save to db:
const { clientFileName, sortOrder } = this.extractVariableNameAndSortOrder(fileData.clientName); const { clientFileName, sortOrder } = this.extractVariableNameAndSortOrder(file.clientName);
const mimeType = fileData.headers['content-type'] || 'application/octet-stream'; // Fallback to a default MIME type const mimeType = file.headers['content-type'] || 'application/octet-stream'; // Fallback to a default MIME type
const newFile = await dataset const newFile = await dataset
.useTransaction(trx) .useTransaction(trx)
.related('files') .related('files')
.create({ .create({
pathName: `${datasetFolder}/${fileName}`, pathName: `${datasetFolder}/${fileName}`,
fileSize: fileData.size, fileSize: file.size,
mimeType, mimeType,
label: clientFileName, label: clientFileName,
sortOrder: sortOrder || index, sortOrder: sortOrder || index,
@ -1084,16 +1357,24 @@ export default class DatasetController {
await dataset.useTransaction(trx).save(); await dataset.useTransaction(trx).save();
await trx.commit(); 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'); session.flash('message', 'Dataset has been updated successfully');
// return response.redirect().toRoute('user.index'); // return response.redirect().toRoute('user.index');
return response.redirect().toRoute('dataset.edit', [dataset.id]); return response.redirect().toRoute('dataset.edit', [dataset.id]);
} catch (error) { } 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) { if (trx !== null) {
await trx.rollback(); 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 new ValidationException(true, { 'upload error': `failed to create dataset and related models. ${error}` });
throw error; throw error;
} }
@ -1160,31 +1441,32 @@ export default class DatasetController {
if (validStates.includes(dataset.server_state)) { if (validStates.includes(dataset.server_state)) {
if (dataset.files && dataset.files.length > 0) { if (dataset.files && dataset.files.length > 0) {
for (const file of dataset.files) { for (const file of dataset.files) {
// overwritten delete method also delets file on filespace // overwritten delete method also delets file on filespace and db object
await file.delete(); await file.delete();
} }
} }
const datasetFolder = `files/${params.id}`; const datasetFolder = `files/${params.id}`;
const folderExists = await drive.exists(datasetFolder); // const folderExists = await drive.use('local').exists(datasetFolder);
if (folderExists) { // if (folderExists) {
const dirListing = drive.list(datasetFolder); // const dirListing = drive.list(datasetFolder);
const folderContents = await dirListing.toArray(); // const folderContents = await dirListing.toArray();
if (folderContents.length === 0) { // if (folderContents.length === 0) {
await drive.delete(datasetFolder); // await drive.delete(datasetFolder);
} // }
await drive.use('local').deleteAll(datasetFolder);
// delete dataset wirh relation in db // delete dataset wirh relation in db
await dataset.delete(); await dataset.delete();
session.flash({ message: 'You have deleted 1 dataset!' }); session.flash({ message: 'You have deleted 1 dataset!' });
return response.redirect().toRoute('dataset.list'); return response.redirect().toRoute('dataset.list');
} else { // } else {
// session.flash({ // // session.flash({
// warning: `You cannot delete this dataset! Invalid server_state: "${dataset.server_state}"!`, // // warning: `You cannot delete this dataset! Invalid server_state: "${dataset.server_state}"!`,
// }); // // });
return response // return response
.flash({ warning: `You cannot delete this dataset! Dataset folder "${datasetFolder}" doesn't exist!` }) // .flash({ warning: `You cannot delete this dataset! Dataset folder "${datasetFolder}" doesn't exist!` })
.redirect() // .redirect()
.back(); // .back();
} // }
} }
} catch (error) { } catch (error) {
if (error instanceof errors.E_VALIDATION_ERROR) { if (error instanceof errors.E_VALIDATION_ERROR) {
@ -1192,11 +1474,89 @@ export default class DatasetController {
throw error; throw error;
} else if (error instanceof Exception) { } else if (error instanceof Exception) {
// General exception handling // General exception handling
return response.flash('errors', { error: error.message }).redirect().back(); session.flash({ error: error.message });
return response.redirect().back();
} else { } else {
session.flash({ error: 'An error occurred while deleting the dataset.' }); session.flash({ error: 'An error occurred while deleting the dataset.' });
return response.redirect().back(); return response.redirect().back();
} }
} }
} }
public async categorize({ inertia, request, response }: HttpContext) {
const id = request.param('id');
// Preload dataset and its "collections" relation
const dataset = await Dataset.query().where('id', id).preload('collections').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}.`,
)
.redirect()
.toRoute('dataset.list');
}
const collectionRoles = await CollectionRole.query()
.preload('collections', (coll: Collection) => {
// preloa only top level collection with noparent_id
coll.whereNull('parent_id').orderBy('number', 'asc');
})
.exec();
return inertia.render('Submitter/Dataset/Category', {
collectionRoles: collectionRoles,
dataset: dataset,
relatedCollections: dataset.collections,
});
}
public async categorizeUpdate({ request, response, session }: HttpContext) {
// Get the dataset id from the route parameter
const id = request.param('id');
const dataset = await Dataset.query().preload('files').where('id', id).firstOrFail();
const validStates = ['inprogress', 'rejected_editor'];
if (!validStates.includes(dataset.server_state)) {
return response
.flash(
'warning',
`Invalid server state. Dataset with id ${id} cannot be categorized. Dataset has server state ${dataset.server_state}.`,
)
.redirect()
.toRoute('dataset.list');
}
let trx: TransactionClientContract | null = null;
try {
trx = await db.transaction();
// const user = (await User.find(auth.user?.id)) as User;
// await this.createDatasetAndAssociations(user, request, trx);
// Retrieve the selected collections from the request.
// This should be an array of collection ids.
const collections: number[] = request.input('collections', []);
// Synchronize the dataset collections using the transaction.
await dataset.useTransaction(trx).related('collections').sync(collections);
// Commit the transaction.await trx.commit()
await trx.commit();
// Redirect with a success flash message.
// return response.flash('success', 'Dataset collections updated successfully!').redirect().toRoute('dataset.list');
session.flash('message', 'Dataset collections updated successfully!');
return response.redirect().toRoute('dataset.list');
} catch (error) {
if (trx !== null) {
await trx.rollback();
}
console.error('Failed tocatgorize dataset collections:', error);
// throw new ValidationException(true, { 'upload error': `failed to create dataset and related models. ${error}` });
throw error;
}
}
} }

View file

@ -6,7 +6,7 @@ import DoiClientException from '#app/exceptions/DoiClientException';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import logger from '@adonisjs/core/services/logger'; import logger from '@adonisjs/core/services/logger';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import axios from 'axios'; import { default as axios } from 'axios';
export class DoiClient implements DoiClientContract { export class DoiClient implements DoiClientContract {
public username: string; public username: string;
@ -50,7 +50,7 @@ export class DoiClient implements DoiClientContract {
'Content-Type': 'application/xml;charset=UTF-8', 'Content-Type': 'application/xml;charset=UTF-8',
}; };
try { try {
const metadataResponse = await axios.default.put(`${this.serviceUrl}/metadata/${doiValue}`, xmlMeta, { auth, headers }); const metadataResponse = await axios.put(`${this.serviceUrl}/metadata/${doiValue}`, xmlMeta, { auth, headers });
// Response Codes // Response Codes
// 201 Created: operation successful // 201 Created: operation successful
@ -65,7 +65,7 @@ export class DoiClient implements DoiClientContract {
throw new DoiClientException(metadataResponse.status, message); throw new DoiClientException(metadataResponse.status, message);
} }
const doiResponse = await axios.default.put(`${this.serviceUrl}/doi/${doiValue}`, `doi=${doiValue}\nurl=${landingPageUrl}`, { const doiResponse = await axios.put(`${this.serviceUrl}/doi/${doiValue}`, `doi=${doiValue}\nurl=${landingPageUrl}`, {
auth, auth,
headers, headers,
}); });

View file

@ -4,6 +4,7 @@ export default class ResumptionToken {
private _resumptionId = ''; private _resumptionId = '';
private _startPosition = 0; private _startPosition = 0;
private _totalIds = 0; private _totalIds = 0;
private _queryParams: Record<string, any> = {};
get key(): string { get key(): string {
return this.metadataPrefix + this.startPosition + this.totalIds; return this.metadataPrefix + this.startPosition + this.totalIds;
@ -48,4 +49,12 @@ export default class ResumptionToken {
set totalIds(totalIds: number) { set totalIds(totalIds: number) {
this._totalIds = totalIds; this._totalIds = totalIds;
} }
get queryParams(): Record<string, any> {
return this._queryParams;
}
set queryParams(params: Record<string, any>) {
this._queryParams = params;
}
} }

View file

@ -6,6 +6,6 @@ export default abstract class TokenWorkerContract {
abstract connect(): void; abstract connect(): void;
abstract close(): void; abstract close(): void;
abstract get(key: string): Promise<ResumptionToken | null>; abstract get(key: string): Promise<ResumptionToken | null>;
abstract set(token: ResumptionToken): Promise<string>; abstract set(token: ResumptionToken, browserFingerprint: string): Promise<string>;
} }

View file

@ -40,14 +40,64 @@ export default class TokenWorkerService implements TokenWorkerContract {
return result !== undefined && result !== null; return result !== undefined && result !== null;
} }
public async set(token: ResumptionToken): Promise<string> { /**
const uniqueName = await this.generateUniqueName(); * Simplified set method that stores the token using a browser fingerprint key.
* If the token for that fingerprint already exists and its documentIds match the new token,
* then the fingerprint key is simply returned.
*/
public async set(token: ResumptionToken, browserFingerprint: string): Promise<string> {
// Generate a 15-digit unique number string based on the fingerprint
const uniqueNumberKey = this.createUniqueNumberFromFingerprint(browserFingerprint, token.documentIds, token.totalIds);
// Optionally, you could prefix it if desired, e.g. 'rs_' + uniqueNumberKey
const fingerprintKey = uniqueNumberKey;
// const fingerprintKey = `rs_fp_${browserFingerprint}`;
const existingTokenString = await this.cache.get(fingerprintKey);
if (existingTokenString) {
const existingToken = this.parseToken(existingTokenString);
if (this.arraysAreEqual(existingToken.documentIds, token.documentIds)) {
return fingerprintKey;
}
}
const serialToken = JSON.stringify(token); const serialToken = JSON.stringify(token);
await this.cache.setEx(uniqueName, this.ttl, serialToken); await this.cache.setEx(fingerprintKey, this.ttl, serialToken);
return uniqueName; return fingerprintKey;
} }
// Updated helper method to generate a unique key based on fingerprint and documentIds
private createUniqueNumberFromFingerprint(browserFingerprint: string, documentIds: number[], totalIds: number): string {
// Combine the fingerprint, document IDs and totalIds to produce the input string
const combined = browserFingerprint + ':' + documentIds.join('-') + ':' + totalIds;
// Simple hash algorithm
let hash = 0;
for (let i = 0; i < combined.length; i++) {
hash = (hash << 5) - hash + combined.charCodeAt(i);
hash |= 0; // Convert to 32-bit integer
}
// Ensure positive number and limit it to at most 15 digits
const positiveHash = Math.abs(hash) % 1000000000000000;
// Pad with trailing zeros to ensure a 15-digit string
return positiveHash.toString().padEnd(15, '0');
}
// Add a helper function to compare two arrays of numbers with identical order
private arraysAreEqual(arr1: number[], arr2: number[]): boolean {
if (arr1.length !== arr2.length) {
return false;
}
return arr1.every((num, index) => num === arr2[index]);
}
// public async set(token: ResumptionToken): Promise<string> {
// const uniqueName = await this.generateUniqueName();
// const serialToken = JSON.stringify(token);
// await this.cache.setEx(uniqueName, this.ttl, serialToken);
// return uniqueName;
// }
private async generateUniqueName(): Promise<string> { private async generateUniqueName(): Promise<string> {
let fc = 0; let fc = 0;
const uniqueId = dayjs().unix().toString(); const uniqueId = dayjs().unix().toString();

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

@ -46,6 +46,7 @@ export default class HttpExceptionHandler extends ExceptionHandler {
// return view.render('./errors/server-error', { error }); // return view.render('./errors/server-error', { error });
// }, // },
// }; // };
protected statusPages: Record<StatusPageRange, StatusPageRenderer> = { protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {
'404': (error, { inertia }) => { '404': (error, { inertia }) => {
return inertia.render('Errors/ServerError', { return inertia.render('Errors/ServerError', {
@ -60,7 +61,45 @@ export default class HttpExceptionHandler extends ExceptionHandler {
code: error.status, code: error.status,
}); });
}, },
'500..599': (error, { inertia }) => inertia.render('Errors/ServerError', { error: error.message, code: error.status }), // '500': (error, { inertia }) => {
// return inertia.render('Errors/postgres_error', {
// status: 'error',
// message: 'PostgreSQL database connection failed. Please ensure the database service is running.',
// details: {
// code: error.code,
// type: error.status,
// ports: error.errors.map((err: any) => ({
// port: err.port,
// address: err.address,
// })),
// },
// });
// },
'500..599': (error, { inertia }) => {
if (error.code === 'ECONNREFUSED') {
const dbErrors = error.errors.some((err: any) => err.code === 'ECONNREFUSED' && err.port === 5432);
if (dbErrors) {
return inertia.render('Errors/postgres_error', {
status: 'error',
message: 'PostgreSQL database connection failed. Please ensure the database service is running.',
details: {
code: error.code,
type: error.status,
ports: error.errors.map((err: any) => ({
port: err.port,
address: err.address,
})),
},
});
}
} else {
return inertia.render('Errors/ServerError', {
error: error.message,
code: error.status,
});
}
},
}; };
// constructor() { // constructor() {
@ -68,7 +107,7 @@ export default class HttpExceptionHandler extends ExceptionHandler {
// } // }
public async handle(error: any, ctx: HttpContext) { public async handle(error: any, ctx: HttpContext) {
const { response, request, session } = ctx; const { response, request, session, inertia } = ctx;
/** /**
* Handle failed authentication attempt * Handle failed authentication attempt
@ -82,6 +121,47 @@ export default class HttpExceptionHandler extends ExceptionHandler {
// return response.redirect('/dashboard'); // return response.redirect('/dashboard');
// } // }
// Handle Axios errors
if (error.code === 'ECONNREFUSED') {
const dbErrors = error.errors.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.status,
// ports: error.errors.map((err: any) => ({
// port: err.port,
// address: err.address,
// })),
// },
// });
// return inertia.render('Errors/postgres_error', {
// status: 'error',
// message: 'PostgreSQL database connection failed. Please ensure the database service is running.',
// details: {
// code: error.code,
// type: error.status,
// ports: error.errors.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,
// });
// }
// https://github.com/inertiajs/inertia-laravel/issues/56 // https://github.com/inertiajs/inertia-laravel/issues/56
// let test = response.getStatus(); //200 // let test = response.getStatus(); //200
// let header = request.header('X-Inertia'); // true // let header = request.header('X-Inertia'); // true
@ -98,12 +178,21 @@ export default class HttpExceptionHandler extends ExceptionHandler {
// ->toResponse($request) // ->toResponse($request)
// ->setStatusCode($response->status()); // ->setStatusCode($response->status());
} }
// 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,
// });
// }
// Dynamically change the error templates based on the absence of X-Inertia header // Dynamically change the error templates based on the absence of X-Inertia header
// if (!ctx.request.header('X-Inertia')) { // if (!ctx.request.header('X-Inertia')) {
// this.statusPages = { // this.statusPages = {
// '401..403': (error, { view }) => view.render('./errors/unauthorized', { error }), // '401..403': (error, { inertia }) => inertia.render('Errors/ServerError', { error: error.message, code: error.status }),
// '404': (error, { view }) => view.render('./errors/not-found', { error }), // '404': (error, { inertia }) => inertia.render('Errors/ServerError', { error: error.message, code: error.status }),
// '500..599': (error, { view }) => view.render('./errors/server-error', { error }), // '500..599': (error, { inertia }) => inertia.render('Errors/ServerError', { error: error.message, code: error.status }),
// }; // };
// } // }

View file

@ -209,6 +209,15 @@ export default class Dataset extends DatasetExtension {
return mainTitle ? mainTitle.value : null; return mainTitle ? mainTitle.value : null;
} }
@computed({
serializeAs: 'doi_identifier',
})
public get doiIdentifier() {
// return `${this.firstName} ${this.lastName}`;
const identifier: DatasetIdentifier = this.identifier;
return identifier ? identifier.value : null;
}
@manyToMany(() => Person, { @manyToMany(() => Person, {
pivotForeignKey: 'document_id', pivotForeignKey: 'document_id',
pivotRelatedForeignKey: 'person_id', pivotRelatedForeignKey: 'person_id',

View file

@ -3,12 +3,12 @@ import { column, hasMany, belongsTo, SnakeCaseNamingStrategy, computed } from '@
import HashValue from './hash_value.js'; import HashValue from './hash_value.js';
import Dataset from './dataset.js'; import Dataset from './dataset.js';
import BaseModel from './base_model.js'; import BaseModel from './base_model.js';
// import { Buffer } from 'buffer';
import * as fs from 'fs'; import * as fs from 'fs';
import crypto from 'crypto'; import crypto from 'crypto';
// import Drive from '@ioc:Adonis/Core/Drive'; // import Drive from '@ioc:Adonis/Core/Drive';
// import Drive from '@adonisjs/drive'; // import Drive from '@adonisjs/drive';
import drive from '#services/drive'; // import drive from '#services/drive';
import drive from '@adonisjs/drive/services/main';
import type { HasMany } from "@adonisjs/lucid/types/relations"; import type { HasMany } from "@adonisjs/lucid/types/relations";
import type { BelongsTo } from "@adonisjs/lucid/types/relations"; import type { BelongsTo } from "@adonisjs/lucid/types/relations";
@ -88,7 +88,8 @@ export default class File extends BaseModel {
serializeAs: 'filePath', serializeAs: 'filePath',
}) })
public get filePath() { public get filePath() {
return `/storage/app/public/${this.pathName}`; // return `/storage/app/public/${this.pathName}`;
return `/storage/app/data/${this.pathName}`;
// const mainTitle = this.titles?.find((title) => title.type === 'Main'); // const mainTitle = this.titles?.find((title) => title.type === 'Main');
// return mainTitle ? mainTitle.value : null; // return mainTitle ? mainTitle.value : null;
} }
@ -165,7 +166,7 @@ export default class File extends BaseModel {
public async delete() { public async delete() {
if (this.pathName) { if (this.pathName) {
// Delete file from additional storage // Delete file from additional storage
await drive.delete(this.pathName); await drive.use('local').delete(this.pathName);
} }
// Call the original delete method of the BaseModel to remove the record from the database // Call the original delete method of the BaseModel to remove the record from the database

View file

@ -16,9 +16,14 @@ export default class MimeType extends BaseModel {
@column({}) @column({})
public name: string; public name: string;
// 1 : n file_extensions are separated by '|' in the database
@column({}) @column({})
public file_extension: string; public file_extension: string;
// 1 : n alternate_mimetype are separated by '|' in the database
@column({})
public alternate_mimetype: string;
@column({}) @column({})
public enabled: boolean; public enabled: boolean;

View file

@ -3,7 +3,7 @@ import { DateTime } from 'luxon';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import Dataset from './dataset.js'; import Dataset from './dataset.js';
import BaseModel from './base_model.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 { export default class Person extends BaseModel {
public static namingStrategy = new SnakeCaseNamingStrategy(); public static namingStrategy = new SnakeCaseNamingStrategy();
@ -51,7 +51,7 @@ export default class Person extends BaseModel {
serializeAs: 'name', serializeAs: 'name',
}) })
public get fullName() { public get fullName() {
return `${this.firstName} ${this.lastName}`; return [this.firstName, this.lastName].filter(Boolean).join(' ');
} }
// @computed() // @computed()
@ -64,10 +64,12 @@ export default class Person extends BaseModel {
// return '2023-03-21 08:45:00'; // return '2023-03-21 08:45:00';
// } // }
@computed() @computed({
serializeAs: 'dataset_count',
})
public get datasetCount() { public get datasetCount() {
const stock = this.$extras.datasets_count; //my pivot column name was "stock" const stock = this.$extras.datasets_count; //my pivot column name was "stock"
return stock; return Number(stock);
} }
@computed() @computed()
@ -76,6 +78,16 @@ export default class Person extends BaseModel {
return contributor_type; 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, { @manyToMany(() => Dataset, {
pivotForeignKey: 'person_id', pivotForeignKey: 'person_id',
pivotRelatedForeignKey: 'document_id', pivotRelatedForeignKey: 'document_id',

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

@ -1,6 +1,6 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'; import { withAuthFinder } from '@adonisjs/auth/mixins/lucid';
import { column, manyToMany, hasMany, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'; import { column, manyToMany, hasMany, SnakeCaseNamingStrategy, computed, beforeFetch, beforeFind } from '@adonisjs/lucid/orm';
import hash from '@adonisjs/core/services/hash'; import hash from '@adonisjs/core/services/hash';
import Role from './role.js'; import Role from './role.js';
import db from '@adonisjs/lucid/services/db'; import db from '@adonisjs/lucid/services/db';
@ -49,7 +49,6 @@ export default class User extends compose(BaseModel, AuthFinder) {
@column() @column()
public login: string; public login: string;
@column() @column()
public firstName: string; public firstName: string;
@ -87,6 +86,9 @@ export default class User extends compose(BaseModel, AuthFinder) {
@column({}) @column({})
public state: number; public state: number;
@column({})
public avatar: string;
// @hasOne(() => TotpSecret, { // @hasOne(() => TotpSecret, {
// foreignKey: 'user_id', // foreignKey: 'user_id',
// }) // })
@ -104,6 +106,7 @@ export default class User extends compose(BaseModel, AuthFinder) {
// return Boolean(this.totp_secret?.twoFactorSecret); // return Boolean(this.totp_secret?.twoFactorSecret);
} }
@manyToMany(() => Role, { @manyToMany(() => Role, {
pivotForeignKey: 'account_id', pivotForeignKey: 'account_id',
pivotRelatedForeignKey: 'role_id', pivotRelatedForeignKey: 'role_id',
@ -121,6 +124,27 @@ export default class User extends compose(BaseModel, AuthFinder) {
}) })
public backupcodes: HasMany<typeof BackupCode>; public backupcodes: HasMany<typeof BackupCode>;
@computed({
serializeAs: 'is_admin',
})
public get isAdmin(): boolean {
const roles = this.roles;
const isAdmin = roles?.map((role: Role) => role.name).includes('administrator');
return isAdmin;
}
// public toJSON() {
// return {
// ...super.toJSON(),
// roles: []
// };
// }
@beforeFind()
@beforeFetch()
public static preloadRoles(user: User) {
user.preload('roles')
}
public async getBackupCodes(this: User): Promise<BackupCode[]> { public async getBackupCodes(this: User): Promise<BackupCode[]> {
const test = await this.related('backupcodes').query(); const test = await this.related('backupcodes').query();
// return test.map((role) => role.code); // return test.map((role) => role.code);

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 { export function sum(a: number, b: number): number {
return a + b; return a + b;
} }
@ -24,3 +37,88 @@ export function preg_match(regex: RegExp, str: string) {
const result: boolean = regex.test(str); const result: boolean = regex.test(str);
return result; 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;
}

View file

@ -1,6 +1,7 @@
import vine, { SimpleMessagesProvider } from '@vinejs/vine'; import vine, { SimpleMessagesProvider } from '@vinejs/vine';
import { TitleTypes, DescriptionTypes, ContributorTypes, ReferenceIdentifierTypes, RelationTypes } from '#contracts/enums'; import { TitleTypes, DescriptionTypes, ContributorTypes, ReferenceIdentifierTypes, RelationTypes } from '#contracts/enums';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
// import MimeType from '#models/mime_type'; // import MimeType from '#models/mime_type';
// const enabledExtensions = await MimeType.query().select('file_extension').where('enabled', true).exec(); // const enabledExtensions = await MimeType.query().select('file_extension').where('enabled', true).exec();
@ -39,7 +40,8 @@ export const createDatasetValidator = vine.compile(
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }), .translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}), }),
) )
.minLength(1), // .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine descriptions: vine
.array( .array(
vine.object({ vine.object({
@ -53,7 +55,8 @@ export const createDatasetValidator = vine.compile(
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }), .translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}), }),
) )
.minLength(1), // .minLength(1),
.arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine authors: vine
.array( .array(
vine.object({ vine.object({
@ -125,7 +128,7 @@ export const createDatasetValidator = vine.compile(
references: vine references: vine
.array( .array(
vine.object({ vine.object({
value: vine.string().trim().minLength(3).maxLength(255), value: vine.string().trim().minLength(3).maxLength(255).validateReference({ typeField: 'type' }),
type: vine.enum(Object.values(ReferenceIdentifierTypes)), type: vine.enum(Object.values(ReferenceIdentifierTypes)),
relation: vine.enum(Object.values(RelationTypes)), relation: vine.enum(Object.values(RelationTypes)),
label: vine.string().trim().minLength(2).maxLength(255), label: vine.string().trim().minLength(2).maxLength(255),
@ -155,8 +158,7 @@ export const createDatasetValidator = vine.compile(
.fileScan({ removeInfected: true }), .fileScan({ removeInfected: true }),
) )
.minLength(1), .minLength(1),
}), }),);
);
/** /**
* Validates the dataset's update action * Validates the dataset's update action
@ -186,7 +188,8 @@ export const updateDatasetValidator = vine.compile(
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }), .translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}), }),
) )
.minLength(1), // .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine descriptions: vine
.array( .array(
vine.object({ vine.object({
@ -200,7 +203,7 @@ export const updateDatasetValidator = vine.compile(
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }), .translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}), }),
) )
.minLength(1), .arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine authors: vine
.array( .array(
vine.object({ vine.object({
@ -272,7 +275,7 @@ export const updateDatasetValidator = vine.compile(
references: vine references: vine
.array( .array(
vine.object({ vine.object({
value: vine.string().trim().minLength(3).maxLength(255), value: vine.string().trim().minLength(3).maxLength(255).validateReference({ typeField: 'type' }),
type: vine.enum(Object.values(ReferenceIdentifierTypes)), type: vine.enum(Object.values(ReferenceIdentifierTypes)),
relation: vine.enum(Object.values(RelationTypes)), relation: vine.enum(Object.values(RelationTypes)),
label: vine.string().trim().minLength(2).maxLength(255), label: vine.string().trim().minLength(2).maxLength(255),
@ -311,12 +314,137 @@ export const updateDatasetValidator = vine.compile(
}), }),
); );
// files: schema.array([rules.minLength(1)]).members( export const updateEditorDatasetValidator = vine.compile(
// schema.file({ vine.object({
// size: '512mb', // first step
// extnames: ['jpg', 'gif', 'png', 'tif', 'pdf', 'zip', 'fgb', 'nc', 'qml', 'ovr', 'gpkg', 'gml', 'gpx', 'kml', 'kmz', 'json'], 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),
last_name: vine.string().trim().minLength(3).maxLength(255),
}),
)
.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),
last_name: vine.string().trim().minLength(3).maxLength(255),
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({ let messagesProvider = new SimpleMessagesProvider({
'minLength': '{{ field }} must be at least {{ min }} characters long', 'minLength': '{{ field }} must be at least {{ min }} characters long',
@ -368,8 +496,10 @@ let messagesProvider = new SimpleMessagesProvider({
'files.array.minLength': 'At least {{ min }} file upload is required.', 'files.array.minLength': 'At least {{ min }} file upload is required.',
'files.*.size': 'file size is to big', 'files.*.size': 'file size is to big',
'files.*.extnames': 'file extension is not supported', '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; createDatasetValidator.messagesProvider = messagesProvider;
updateDatasetValidator.messagesProvider = messagesProvider; updateDatasetValidator.messagesProvider = messagesProvider;
updateEditorDatasetValidator.messagesProvider = messagesProvider;
// export default createDatasetValidator; // export default createDatasetValidator;

View file

@ -16,7 +16,7 @@ export const createUserValidator = vine.compile(
first_name: vine.string().trim().minLength(3).maxLength(255), first_name: vine.string().trim().minLength(3).maxLength(255),
last_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' }), 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 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() .email()
.normalizeEmail() .normalizeEmail()
.isUnique({ table: 'accounts', column: 'email', whereNot: (field) => field.meta.objId }), .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 roles: vine.array(vine.number()).minLength(1), // define at least one role for the new user
}), }),
); );

View file

@ -142,7 +142,7 @@ export class VanillaErrorReporter implements ErrorReporterContract {
// } // }
this.hasErrors = true; this.hasErrors = true;
var test = field.getFieldPath(); // var test = field.getFieldPath();
// this.errors.push(error); // this.errors.push(error);
// if (this.errors[error.field]) { // if (this.errors[error.field]) {

View file

@ -88,7 +88,7 @@ export default class ValidateChecksum extends BaseCommand {
); );
// Construct the file path // Construct the file path
const filePath = '/storage/app/public/' + file.pathName; const filePath = '/storage/app/data/' + file.pathName;
try { try {
// Calculate the MD5 checksum of the file // Calculate the MD5 checksum of the file

View file

@ -80,7 +80,8 @@ export const http = defineConfig({
| headers. | headers.
| |
*/ */
trustProxy: proxyAddr.compile('loopback'), // trustProxy: proxyAddr.compile('loopback'),
trustProxy: proxyAddr.compile(['127.0.0.1', '::1/128']),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View file

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

View file

@ -47,7 +47,7 @@ const databaseConfig = defineConfig({
migrations: { migrations: {
naturalSort: true, naturalSort: true,
}, },
healthCheck: false, // healthCheck: false,
debug: false, debug: false,
pool: { min: 1, max: 100 }, pool: { min: 1, max: 100 },
}, },

View file

@ -1,151 +1,45 @@
/** // import env from '#start/env'
* Config source: https://git.io/JBt3o // import app from '@adonisjs/core/services/app'
* import { defineConfig, services } from '@adonisjs/drive'
* Feel free to let us know via PR, if you find something broken in this config
* file.
*/
import { defineConfig } from '#providers/drive/src/types/define_config';
import env from '#start/env';
// import { driveConfig } from '@adonisjs/core/build/config';
// import { driveConfig } from "@adonisjs/drive/build/config.js";
// import Application from '@ioc:Adonis/Core/Application';
/* const driveConfig = defineConfig({
|--------------------------------------------------------------------------
| Drive Config
|--------------------------------------------------------------------------
|
| The `DriveConfig` relies on the `DisksList` interface which is
| defined inside the `contracts` directory.
|
*/
export default defineConfig({
/*
|--------------------------------------------------------------------------
| Default disk
|--------------------------------------------------------------------------
|
| The default disk to use for managing file uploads. The value is driven by
| the `DRIVE_DISK` environment variable.
|
*/
disk: env.get('DRIVE_DISK', 'local'),
disks: { default: 'public',
/*
|--------------------------------------------------------------------------
| Local
|--------------------------------------------------------------------------
|
| Uses the local file system to manage files. Make sure to turn off serving
| files when not using this disk.
|
*/
local: {
driver: 'local',
visibility: 'public',
/*
|--------------------------------------------------------------------------
| Storage root - Local driver only
|--------------------------------------------------------------------------
|
| Define an absolute path to the storage directory from where to read the
| files.
|
*/
// root: Application.tmpPath('uploads'),
root: '/storage/app/public',
/* services: {
|--------------------------------------------------------------------------
| Serve files - Local driver only /**
|-------------------------------------------------------------------------- * Persist files on the local filesystem
|
| When this is set to true, AdonisJS will configure a files server to serve
| files from the disk root. This is done to mimic the behavior of cloud
| storage services that has inbuilt capabilities to serve files.
|
*/ */
public: services.fs({
location: '/storage/app/public/',
serveFiles: true, serveFiles: true,
routeBasePath: '/public',
visibility: 'public',
}),
local: services.fs({
location: '/storage/app/data/',
serveFiles: true,
routeBasePath: '/data',
visibility: 'public',
}),
/*
|--------------------------------------------------------------------------
| Base path - Local driver only
|--------------------------------------------------------------------------
|
| Base path is always required when "serveFiles = true". Also make sure
| the `basePath` is unique across all the disks using "local" driver and
| you are not registering routes with this prefix.
|
*/
basePath: '/uploads',
},
/* /**
|-------------------------------------------------------------------------- * Persist files on Digital Ocean spaces
| S3 Driver
|--------------------------------------------------------------------------
|
| Uses the S3 cloud storage to manage files. Make sure to install the s3
| drive separately when using it.
|
|**************************************************************************
| npm i @adonisjs/drive-s3
|**************************************************************************
|
*/ */
// s3: { // spaces: services.s3({
// driver: 's3', // credentials: {
// visibility: 'public', // accessKeyId: env.get('SPACES_KEY'),
// key: Env.get('S3_KEY'), // secretAccessKey: env.get('SPACES_SECRET'),
// secret: Env.get('S3_SECRET'),
// region: Env.get('S3_REGION'),
// bucket: Env.get('S3_BUCKET'),
// endpoint: Env.get('S3_ENDPOINT'),
//
// // For minio to work
// // forcePathStyle: true,
// }, // },
// region: env.get('SPACES_REGION'),
/* // bucket: env.get('SPACES_BUCKET'),
|-------------------------------------------------------------------------- // endpoint: env.get('SPACES_ENDPOINT'),
| GCS Driver
|--------------------------------------------------------------------------
|
| Uses the Google cloud storage to manage files. Make sure to install the GCS
| drive separately when using it.
|
|**************************************************************************
| npm i @adonisjs/drive-gcs
|**************************************************************************
|
*/
// gcs: {
// driver: 'gcs',
// visibility: 'public', // visibility: 'public',
// keyFilename: Env.get('GCS_KEY_FILENAME'), // }),
// bucket: Env.get('GCS_BUCKET'),
/*
|--------------------------------------------------------------------------
| Uniform ACL - Google cloud storage only
|--------------------------------------------------------------------------
|
| When using the Uniform ACL on the bucket, the "visibility" option is
| ignored. Since, the files ACL is managed by the google bucket policies
| directly.
|
|**************************************************************************
| Learn more: https://cloud.google.com/storage/docs/uniform-bucket-level-access
|**************************************************************************
|
| The following option just informs drive whether your bucket is using uniform
| ACL or not. The actual setting needs to be toggled within the Google cloud
| console.
|
*/
// usingUniformAcl: false,
// },
}, },
}); })
export default driveConfig

233
config/drive_self.ts Normal file
View file

@ -0,0 +1,233 @@
/**
* Config source: https://git.io/JBt3o
*
* Feel free to let us know via PR, if you find something broken in this config
* file.
*/
import { defineConfig } from '#providers/drive/src/types/define_config';
import env from '#start/env';
// import { driveConfig } from '@adonisjs/core/build/config';
// import { driveConfig } from "@adonisjs/drive/build/config.js";
// import Application from '@ioc:Adonis/Core/Application';
/*
|--------------------------------------------------------------------------
| Drive Config
|--------------------------------------------------------------------------
|
| The `DriveConfig` relies on the `DisksList` interface which is
| defined inside the `contracts` directory.
|
*/
export default defineConfig({
/*
|--------------------------------------------------------------------------
| Default disk
|--------------------------------------------------------------------------
|
| The default disk to use for managing file uploads. The value is driven by
| the `DRIVE_DISK` environment variable.
|
*/
disk: env.get('DRIVE_DISK', 'local'),
disks: {
/*
|--------------------------------------------------------------------------
| Local
|--------------------------------------------------------------------------
|
| Uses the local file system to manage files. Make sure to turn off serving
| files when not using this disk.
|
*/
local: {
driver: 'local',
visibility: 'public',
/*
|--------------------------------------------------------------------------
| Storage root - Local driver only
|--------------------------------------------------------------------------
|
| Define an absolute path to the storage directory from where to read the
| files.
|
*/
// root: Application.tmpPath('uploads'),
root: '/storage/app/data',
/*
|--------------------------------------------------------------------------
| Serve files - Local driver only
|--------------------------------------------------------------------------
|
| When this is set to true, AdonisJS will configure a files server to serve
| files from the disk root. This is done to mimic the behavior of cloud
| storage services that has inbuilt capabilities to serve files.
|
*/
serveFiles: true,
/*
|--------------------------------------------------------------------------
| Base path - Local driver only
|--------------------------------------------------------------------------
|
| Base path is always required when "serveFiles = true". Also make sure
| the `basePath` is unique across all the disks using "local" driver and
| you are not registering routes with this prefix.
|
*/
basePath: '/files',
},
local: {
driver: 'local',
visibility: 'public',
/*
|--------------------------------------------------------------------------
| Storage root - Local driver only
|--------------------------------------------------------------------------
|
| Define an absolute path to the storage directory from where to read the
| files.
|
*/
// root: Application.tmpPath('uploads'),
root: '/storage/app/data',
/*
|--------------------------------------------------------------------------
| Serve files - Local driver only
|--------------------------------------------------------------------------
|
| When this is set to true, AdonisJS will configure a files server to serve
| files from the disk root. This is done to mimic the behavior of cloud
| storage services that has inbuilt capabilities to serve files.
|
*/
serveFiles: true,
/*
|--------------------------------------------------------------------------
| Base path - Local driver only
|--------------------------------------------------------------------------
|
| Base path is always required when "serveFiles = true". Also make sure
| the `basePath` is unique across all the disks using "local" driver and
| you are not registering routes with this prefix.
|
*/
basePath: '/files',
},
fs: {
driver: 'local',
visibility: 'public',
/*
|--------------------------------------------------------------------------
| Storage root - Local driver only
|--------------------------------------------------------------------------
|
| Define an absolute path to the storage directory from where to read the
| files.
|
*/
// root: Application.tmpPath('uploads'),
root: '/storage/app/public',
/*
|--------------------------------------------------------------------------
| Serve files - Local driver only
|--------------------------------------------------------------------------
|
| When this is set to true, AdonisJS will configure a files server to serve
| files from the disk root. This is done to mimic the behavior of cloud
| storage services that has inbuilt capabilities to serve files.
|
*/
serveFiles: true,
/*
|--------------------------------------------------------------------------
| Base path - Local driver only
|--------------------------------------------------------------------------
|
| Base path is always required when "serveFiles = true". Also make sure
| the `basePath` is unique across all the disks using "local" driver and
| you are not registering routes with this prefix.
|
*/
basePath: '/public',
},
/*
|--------------------------------------------------------------------------
| S3 Driver
|--------------------------------------------------------------------------
|
| Uses the S3 cloud storage to manage files. Make sure to install the s3
| drive separately when using it.
|
|**************************************************************************
| npm i @adonisjs/drive-s3
|**************************************************************************
|
*/
// s3: {
// driver: 's3',
// visibility: 'public',
// key: Env.get('S3_KEY'),
// secret: Env.get('S3_SECRET'),
// region: Env.get('S3_REGION'),
// bucket: Env.get('S3_BUCKET'),
// endpoint: Env.get('S3_ENDPOINT'),
//
// // For minio to work
// // forcePathStyle: true,
// },
/*
|--------------------------------------------------------------------------
| GCS Driver
|--------------------------------------------------------------------------
|
| Uses the Google cloud storage to manage files. Make sure to install the GCS
| drive separately when using it.
|
|**************************************************************************
| npm i @adonisjs/drive-gcs
|**************************************************************************
|
*/
// gcs: {
// driver: 'gcs',
// visibility: 'public',
// keyFilename: Env.get('GCS_KEY_FILENAME'),
// bucket: Env.get('GCS_BUCKET'),
/*
|--------------------------------------------------------------------------
| Uniform ACL - Google cloud storage only
|--------------------------------------------------------------------------
|
| When using the Uniform ACL on the bucket, the "visibility" option is
| ignored. Since, the files ACL is managed by the google bucket policies
| directly.
|
|**************************************************************************
| Learn more: https://cloud.google.com/storage/docs/uniform-bucket-level-access
|**************************************************************************
|
| The following option just informs drive whether your bucket is using uniform
| ACL or not. The actual setting needs to be toggled within the Google cloud
| console.
|
*/
// usingUniformAcl: false,
// },
},
});

View file

@ -1,7 +1,8 @@
import { defineConfig } from '@adonisjs/inertia'; import { defineConfig } from '@adonisjs/inertia';
import type { HttpContext } from '@adonisjs/core/http'; import type { HttpContext } from '@adonisjs/core/http';
import type { InferSharedProps } from '@adonisjs/inertia/types'
export default defineConfig({ const inertiaConfig = defineConfig({
/** /**
* Path to the Edge view that will be used as the root view for Inertia responses * Path to the Edge view that will be used as the root view for Inertia responses
*/ */
@ -52,6 +53,12 @@ export default defineConfig({
}, },
}); });
export default inertiaConfig
declare module '@adonisjs/inertia/types' {
export interface SharedProps extends InferSharedProps<typeof inertiaConfig> {}
}
// import { InertiaConfig } from '@ioc:EidelLev/Inertia'; // import { InertiaConfig } from '@ioc:EidelLev/Inertia';
// /* // /*

View file

@ -12,7 +12,7 @@ const mailConfig = defineConfig({
mailers: { mailers: {
smtp: transports.smtp({ smtp: transports.smtp({
socketTimeout: 5000,// Overall timeout (5 seconds) // socketTimeout: 5000,// Overall timeout (5 seconds)
host: env.get('SMTP_HOST', ''), host: env.get('SMTP_HOST', ''),
port: env.get('SMTP_PORT'), port: env.get('SMTP_PORT'),
secure: false, secure: false,
@ -30,10 +30,10 @@ const mailConfig = defineConfig({
}, */ }, */
}), }),
resend: transports.resend({ // resend: transports.resend({
key: env.get('RESEND_API_KEY'), // key: env.get('RESEND_API_KEY'),
baseUrl: 'https://api.resend.com', // baseUrl: 'https://api.resend.com',
}), // }),
}, },
}); });

View file

@ -6,7 +6,7 @@
*/ */
import env from '#start/env'; import env from '#start/env';
import app from '@adonisjs/core/services/app'; // import app from '@adonisjs/core/services/app';
import { defineConfig, stores } from '@adonisjs/session'; import { defineConfig, stores } from '@adonisjs/session';
const sessionConfig = defineConfig({ const sessionConfig = defineConfig({

32
config/vite.ts Normal file
View file

@ -0,0 +1,32 @@
import { defineConfig } from '@adonisjs/vite';
const viteBackendConfig = defineConfig({
/**
* The output of vite will be written inside this
* directory. The path should be relative from
* the application root.
*/
buildDirectory: 'public/assets',
/**
* The path to the manifest file generated by the
* "vite build" command.
*/
manifestFile: 'public/assets/.vite/manifest.json',
/**
* Feel free to change the value of the "assetsUrl" to
* point to a CDN in production.
*/
assetsUrl: '/assets',
/**
* Add defer attribute to scripts for better performance.
*/
scriptAttributes: {
defer: true,
},
});
export default viteBackendConfig;

View file

@ -18,6 +18,7 @@ export default class Accounts extends BaseSchema {
table.text("two_factor_recovery_codes").nullable(); table.text("two_factor_recovery_codes").nullable();
table.smallint('state').nullable(); table.smallint('state').nullable();
table.bigint('last_counter').nullable(); table.bigint('last_counter').nullable();
table.string('avatar').nullable();
}); });
} }
@ -43,6 +44,7 @@ export default class Accounts extends BaseSchema {
// two_factor_recovery_codes text COLLATE pg_catalog."default", // two_factor_recovery_codes text COLLATE pg_catalog."default",
// state smallint, // state smallint,
// last_counter bigint, // last_counter bigint,
// avatar character varying(255),
// ) // )
// ALTER TABLE gba.accounts // ALTER TABLE gba.accounts
@ -85,3 +87,6 @@ export default class Accounts extends BaseSchema {
// GRANT ALL ON SEQUENCE gba.totp_secrets_id_seq TO tethys_admin; // GRANT ALL ON SEQUENCE gba.totp_secrets_id_seq TO tethys_admin;
// ALTER TABLE gba.totp_secrets ALTER COLUMN id SET DEFAULT nextval('gba.totp_secrets_id_seq'); // ALTER TABLE gba.totp_secrets ALTER COLUMN id SET DEFAULT nextval('gba.totp_secrets_id_seq');
// ALTER TABLE "accounts" ADD COLUMN "avatar" VARCHAR(255) NULL

View file

@ -5,7 +5,7 @@ export default class Collections extends BaseSchema {
public async up() { public async up() {
this.schema.createTable(this.tableName, (table) => { 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.integer('role_id').unsigned();
table table
.foreign('role_id', 'collections_role_id_foreign') .foreign('role_id', 'collections_role_id_foreign')
@ -54,3 +54,8 @@ export default class Collections extends BaseSchema {
// ON UPDATE CASCADE // ON UPDATE CASCADE
// ON DELETE CASCADE // ON DELETE CASCADE
// ) // )
// change to normal intzeger:
// ALTER TABLE collections ALTER COLUMN id DROP DEFAULT;
// DROP SEQUENCE IF EXISTS collections_id_seq;

View file

@ -0,0 +1,18 @@
import { BaseSchema } from "@adonisjs/lucid/schema";
export default class AddAlternateMimetypeToMimeTypes extends BaseSchema {
protected tableName = 'mime_types';
public async up () {
this.schema.alterTable(this.tableName, (table) => {
table.string('alternate_mimetype').nullable();
});
}
public async down () {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('alternate_mimetype');
});
}
}
// ALTER TABLE "mime_types" ADD COLUMN "alternate_mimetype" VARCHAR(255) NULL

6
index.d.ts vendored
View file

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

10544
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"dev": "node ace serve --watch", "dev": "node ace serve",
"devInspect": "node ace serve --watch --node-args='--inspect'",
"compress:xslt": "./node_modules/xslt3/xslt3.js -xsl:public/assets2/datasetxml2oai-pmh.xslt -export:public/assets2/datasetxml2oai.sef.json -t -nogo '-ns:##html5'", "compress:xslt": "./node_modules/xslt3/xslt3.js -xsl:public/assets2/datasetxml2oai-pmh.xslt -export:public/assets2/datasetxml2oai.sef.json -t -nogo '-ns:##html5'",
"compress:solr": "./node_modules/xslt3/xslt3.js -xsl:public/assets2/solr.xslt -export:public/assets2/solr.sef.json -t -nogo '-ns:##html5'", "compress:solr": "./node_modules/xslt3/xslt3.js -xsl:public/assets2/solr.xslt -export:public/assets2/solr.sef.json -t -nogo '-ns:##html5'",
"compress:doi": "./node_modules/xslt3/xslt3.js -xsl:public/assets2/doi_datacite.xslt -export:public/assets2/doi_datacite.sef.json -t -nogo '-ns:##html5'", "compress:doi": "./node_modules/xslt3/xslt3.js -xsl:public/assets2/doi_datacite.xslt -export:public/assets2/doi_datacite.sef.json -t -nogo '-ns:##html5'",
@ -15,59 +16,58 @@
"format-check": "prettier --check ./**/*.{ts,js}", "format-check": "prettier --check ./**/*.{ts,js}",
"test": "node ace test" "test": "node ace test"
}, },
"eslintIgnore": [ "eslintConfig": {
"ignorePatterns": [
"build" "build"
], ]
},
"alias": { "alias": {
"vue": "./node_modules/vue/dist/vue.esm-bundler.js" "vue": "./node_modules/vue/dist/vue.esm-bundler.js"
}, },
"devDependencies": { "devDependencies": {
"@adonisjs/assembler": "^7.1.1", "@adonisjs/assembler": "^7.1.1",
"@adonisjs/tsconfig": "^1.2.1", "@adonisjs/tsconfig": "^1.4.0",
"@babel/core": "^7.20.12", "@headlessui/vue": "^1.7.23",
"@babel/plugin-proposal-class-properties": "^7.18.6", "@japa/assert": "^4.0.1",
"@babel/plugin-proposal-decorators": "^7.20.13", "@japa/plugin-adonisjs": "^4.0.0",
"@babel/plugin-transform-runtime": "^7.19.6", "@japa/runner": "^4.2.0",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.18.6",
"@japa/api-client": "^2.0.3",
"@japa/assert": "^3.0.0",
"@japa/plugin-adonisjs": "^3.0.0",
"@japa/runner": "^3.1.1",
"@mdi/js": "^7.1.96", "@mdi/js": "^7.1.96",
"@poppinss/utils": "^6.7.2", "@poppinss/utils": "^6.7.2",
"@swc/core": "^1.4.2", "@swc/wasm": "^1.10.14",
"@symfony/webpack-encore": "^5.0.1",
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/clamscan": "^2.0.4", "@types/clamscan": "^2.0.4",
"@types/escape-html": "^1.0.4", "@types/escape-html": "^1.0.4",
"@types/leaflet": "^1.9.3", "@types/fs-extra": "^11.0.4",
"@types/leaflet": "^1.9.16",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.5.5", "@types/node": "^22.10.2",
"@types/proxy-addr": "^2.0.0", "@types/proxy-addr": "^2.0.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/source-map-support": "^0.5.6", "@types/source-map-support": "^0.5.6",
"@types/sprintf-js": "^1.1.4", "@types/sprintf-js": "^1.1.4",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"babel-preset-typescript-vue3": "^2.0.17", "babel-preset-typescript-vue3": "^2.0.17",
"chart.js": "^4.2.0", "chart.js": "^4.2.0",
"dotenv-webpack": "^8.0.1", "dotenv-webpack": "^8.0.1",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-adonis": "^2.1.1", "eslint-plugin-adonis": "^2.1.1",
"eslint-plugin-prettier": "^5.0.0-alpha.2", "eslint-plugin-prettier": "^5.0.0-alpha.2",
"hot-hook": "^0.4.0",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"pinia": "^2.0.30", "pinia": "^2.0.30",
"pino-pretty": "^11.2.2", "pino-pretty": "^13.0.0",
"postcss-loader": "^8.1.1", "postcss-loader": "^8.1.1",
"prettier": "^3.0.0", "prettier": "^3.4.2",
"supertest": "^6.3.3", "supertest": "^6.3.3",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.4.17",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
"ts-node": "^10.9.2", "ts-node-maintained": "^10.9.5",
"typescript": "^5.1.3", "typescript": "~5.7",
"vite": "^6.0.11",
"vue": "^3.4.26", "vue": "^3.4.26",
"vue-facing-decorator": "^3.0.0", "vue-facing-decorator": "^3.0.0",
"vue-loader": "^17.0.1", "vue-loader": "^17.0.1",
@ -75,30 +75,32 @@
"xslt3": "^2.5.0" "xslt3": "^2.5.0"
}, },
"dependencies": { "dependencies": {
"@adonisjs/auth": "^9.1.1", "@adonisjs/auth": "^9.2.4",
"@adonisjs/core": "^6.3.1", "@adonisjs/bodyparser": "^10.0.1",
"@adonisjs/core": "^6.17.0",
"@adonisjs/cors": "^2.2.1", "@adonisjs/cors": "^2.2.1",
"@adonisjs/drive": "^2.3.0", "@adonisjs/drive": "^3.2.0",
"@adonisjs/encore": "^1.0.0", "@adonisjs/inertia": "^2.1.3",
"@adonisjs/inertia": "^1.0.0-7", "@adonisjs/lucid": "^21.5.1",
"@adonisjs/lucid": "^21.1.0",
"@adonisjs/mail": "^9.2.2", "@adonisjs/mail": "^9.2.2",
"@adonisjs/redis": "^9.1.0", "@adonisjs/redis": "^9.1.0",
"@adonisjs/session": "^7.1.1", "@adonisjs/session": "^7.5.0",
"@adonisjs/shield": "^8.1.1", "@adonisjs/shield": "^8.1.1",
"@adonisjs/static": "^1.1.1", "@adonisjs/static": "^1.1.1",
"@adonisjs/vite": "^4.0.0",
"@eidellev/adonis-stardust": "^3.0.0", "@eidellev/adonis-stardust": "^3.0.0",
"@fontsource/archivo-black": "^5.0.1", "@fontsource/archivo-black": "^5.0.1",
"@fontsource/inter": "^5.0.1", "@fontsource/inter": "^5.0.1",
"@inertiajs/inertia": "^0.11.1", "@inertiajs/inertia": "^0.11.1",
"@inertiajs/vue3": "^1.0.0", "@inertiajs/vue3": "^2.0.3",
"@opensearch-project/opensearch": "^2.4.0", "@opensearch-project/opensearch": "^3.2.0",
"@phc/format": "^1.0.0", "@phc/format": "^1.0.0",
"@vinejs/vine": "^2.0.0", "@poppinss/manager": "^5.0.2",
"@vinejs/vine": "^3.0.0",
"axios": "^1.7.9",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"clamscan": "^2.1.2", "clamscan": "^2.1.2",
"crypto": "^1.0.1",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"deep-email-validator": "^0.1.21", "deep-email-validator": "^0.1.21",
"edge.js": "^6.0.1", "edge.js": "^6.0.1",
@ -121,6 +123,12 @@
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^3.1.1"
}, },
"hotHook": {
"boundaries": [
"./app/Controllers/**/*.ts",
"./app/middleware/*.ts"
]
},
"type": "module", "type": "module",
"imports": { "imports": {
"#controllers/*": "./app/Controllers/*.js", "#controllers/*": "./app/Controllers/*.js",

View file

@ -1,7 +1,10 @@
module.exports = { module.exports = {
plugins: { plugins: {
// 'postcss-import': {}, // 'postcss-import': {},
// 'postcss-nesting': {},
'tailwindcss/nesting': {}, 'tailwindcss/nesting': {},
// "@tailwindcss/postcss": {},
// tailwindcss: {},
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },

View file

@ -74,7 +74,8 @@ export class LocalDriver implements LocalDriverContract {
*/ */
public async exists(location: string): Promise<boolean> { public async exists(location: string): Promise<boolean> {
try { try {
return await this.adapter.pathExists(this.makePath(location)); let path_temp = this.makePath(location); //'/storage/app/files/421'
return await this.adapter.pathExists(path_temp);
} catch (error) { } catch (error) {
throw CannotGetMetaDataException.invoke(location, 'exists', error); throw CannotGetMetaDataException.invoke(location, 'exists', error);
} }

View file

@ -69,7 +69,7 @@ export default class MailProvider {
const mailConfigProvider = this.app.config.get('mail'); const mailConfigProvider = this.app.config.get('mail');
const config = await configProvider.resolve<any>(this.app, mailConfigProvider); const config = await configProvider.resolve<any>(this.app, mailConfigProvider);
const iwas = await config.mailers.smtp(); await config.mailers.smtp();
// iwas.config.host = 'hhhost'; // iwas.config.host = 'hhhost';
// this.app.config.set('mail.mailers.smtp.host', 'xhost'); // this.app.config.set('mail.mailers.smtp.host', 'xhost');
// const iwas = await config.mailers.smtp(); // const iwas = await config.mailers.smtp();

View file

@ -63,6 +63,15 @@ export default class QueryBuilderProvider {
public register() { public register() {
// Register your own bindings // Register your own bindings
// const ModelQueryBuilder = this.app.container.bind('@adonisjs/lucid/orm/ModelQueryBuilder');
// ModelQueryBuilder.macro('whereTrue', function (columnName: string) {
// return this.where(columnName, true);
// });
// ModelQueryBuilder.macro('whereFalse', function (columnName: string) {
// return this.where(columnName, false);
// });
} }
public async boot() { public async boot() {
@ -73,15 +82,14 @@ export default class QueryBuilderProvider {
// let rolesPluck = {}; // let rolesPluck = {};
let rolesPluck: { [key: number]: any } = {}; let rolesPluck: { [key: number]: any } = {};
const result = await this.exec(); const result = await this.exec();
result.forEach((user, index) => { result.forEach((user: { [key: string]: any }, index: number) => {
let idc; let idc: number;
if (!id) { if (!id) {
idc = index; idc = index;
} else { } else {
idc = user[id]; idc = user[id];
} }
const value = user[valueColumn]; const value: any = user[valueColumn];
// rolesPluck[idc] = user.name;
rolesPluck[idc] = value; rolesPluck[idc] = value;
}); });
return rolesPluck; return rolesPluck;

View file

@ -4,14 +4,15 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
|*/ |*/
import type { ApplicationService } from '@adonisjs/core/types'; import type { ApplicationService } from '@adonisjs/core/types';
import vine, { BaseLiteralType, Vine } from '@vinejs/vine'; import vine, { symbols, BaseLiteralType, Vine } from '@vinejs/vine';
import type { Validation, FieldContext, FieldOptions } from '@vinejs/vine/types'; import type { FieldContext, FieldOptions } from '@vinejs/vine/types';
// import type { MultipartFile, FileValidationOptions } from '@adonisjs/bodyparser/types'; // import type { MultipartFile, FileValidationOptions } from '@adonisjs/bodyparser/types';
import type { MultipartFile } from '@adonisjs/core/bodyparser'; import type { MultipartFile } from '@adonisjs/core/bodyparser';
import type { FileValidationOptions } from '@adonisjs/core/types/bodyparser'; import type { FileValidationOptions } from '@adonisjs/core/types/bodyparser';
import { Request, RequestValidator } from '@adonisjs/core/http'; import { Request, RequestValidator } from '@adonisjs/core/http';
import MimeType from '#models/mime_type'; import MimeType from '#models/mime_type';
/** /**
* Validation options accepted by the "file" rule * Validation options accepted by the "file" rule
*/ */
@ -28,8 +29,7 @@ declare module '@vinejs/vine' {
* Extend HTTP request class * Extend HTTP request class
*/ */
declare module '@adonisjs/core/http' { declare module '@adonisjs/core/http' {
interface Request extends RequestValidator { interface Request extends RequestValidator {}
}
} }
/** /**
@ -48,7 +48,7 @@ export async function getEnabledExtensions() {
.flat(); .flat();
return extensions; return extensions;
}; }
/** /**
* VineJS validation rule that validates the file to be an * VineJS validation rule that validates the file to be an
* instance of BodyParser MultipartFile class. * instance of BodyParser MultipartFile class.
@ -79,7 +79,9 @@ const isMultipartFile = vine.createRule(async (file: MultipartFile | unknown, op
// if (validatedFile.allowedExtensions === undefined && validationOptions.extnames) { // if (validatedFile.allowedExtensions === undefined && validationOptions.extnames) {
// validatedFile.allowedExtensions = validationOptions.extnames; // validatedFile.allowedExtensions = validationOptions.extnames;
// } // }
if (validatedFile.allowedExtensions === undefined && 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(); validatedFile.allowedExtensions = await getEnabledExtensions();
} }
/** /**
@ -102,7 +104,20 @@ 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> { 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; // #private;
// constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions, validations?: Validation<any>[]); // constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions, validations?: Validation<any>[]);
// clone(): this; // clone(): this;
@ -111,14 +126,16 @@ export class VineMultipartFile extends BaseLiteralType<MultipartFile, MultipartF
// extnames: (18) ['gpkg', 'htm', 'html', 'csv', 'txt', 'asc', 'c', 'cc', 'h', 'srt', 'tiff', 'pdf', 'png', 'zip', 'jpg', 'jpeg', 'jpe', 'xlsx'] // extnames: (18) ['gpkg', 'htm', 'html', 'csv', 'txt', 'asc', 'c', 'cc', 'h', 'srt', 'tiff', 'pdf', 'png', 'zip', 'jpg', 'jpeg', 'jpe', 'xlsx']
// size: '512mb' // size: '512mb'
public constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions, validations?: Validation<any>[]) { // public constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions, validations?: Validation<any>[]) {
public constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions) {
// super(options, validations); // super(options, validations);
super(options, [isMultipartFile(validationOptions || {})]); super(options, [isMultipartFile(validationOptions || {})]);
this.validationOptions = validationOptions; this.validationOptions = validationOptions;
} }
public clone(): any { public clone(): any {
return new VineMultipartFile(this.validationOptions, this.cloneOptions(), this.cloneValidations()); // return new VineMultipartFile(this.validationOptions, this.cloneOptions(), this.cloneValidations());
return new VineMultipartFile(this.validationOptions, this.cloneOptions());
} }
} }
@ -152,10 +169,12 @@ export default class VinejsProvider {
* The validate method can be used to validate the request * The validate method can be used to validate the request
* data for the current request using VineJS validators * data for the current request using VineJS validators
*/ */
Request.macro('validateUsing', function (...args) { Request.macro('validateUsing', function (this: Request, ...args) {
if (!this.ctx) {
throw new Error('HttpContext is not available');
}
return new RequestValidator(this.ctx).validateUsing(...args); return new RequestValidator(this.ctx).validateUsing(...args);
}); });
} }
/** /**

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

View file

@ -1,12 +0,0 @@
{
"entrypoints": {
"app": {
"css": [
"http://localhost:8080/assets/app.css"
],
"js": [
"http://localhost:8080/assets/app.js"
]
}
}
}

View file

@ -1,103 +0,0 @@
{
"assets/app.css": "http://localhost:8080/assets/app.css",
"assets/app.js": "http://localhost:8080/assets/app.js",
"assets/resources_js_apps_settings_l18n_de_js.js": "http://localhost:8080/assets/resources_js_apps_settings_l18n_de_js.js",
"assets/resources_js_apps_settings_l18n_en_js.js": "http://localhost:8080/assets/resources_js_apps_settings_l18n_en_js.js",
"assets/resources_js_Pages_Admin_License_Index_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_License_Index_vue.js",
"assets/resources_js_Pages_Admin_Mimetype_Create_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Mimetype_Create_vue.js",
"assets/resources_js_Pages_Admin_Mimetype_Delete_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Mimetype_Delete_vue.js",
"assets/resources_js_Pages_Admin_Mimetype_Index_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Mimetype_Index_vue.js",
"assets/resources_js_Pages_Admin_Permission_Create_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Permission_Create_vue.js",
"assets/resources_js_Pages_Admin_Permission_Edit_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Permission_Edit_vue.js",
"assets/resources_js_Pages_Admin_Permission_Index_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Permission_Index_vue.js",
"assets/resources_js_Pages_Admin_Permission_Show_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Permission_Show_vue.js",
"assets/resources_js_Pages_Admin_Role_Create_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Role_Create_vue.js",
"assets/resources_js_Pages_Admin_Role_Edit_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Role_Edit_vue.js",
"assets/resources_js_Pages_Admin_Role_Index_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Role_Index_vue.js",
"assets/resources_js_Pages_Admin_Role_Show_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Role_Show_vue.js",
"assets/resources_js_Pages_Admin_Settings_vue-resources_js_utils_toast_css.css": "http://localhost:8080/assets/resources_js_Pages_Admin_Settings_vue-resources_js_utils_toast_css.css",
"assets/resources_js_Pages_Admin_Settings_vue-resources_js_utils_toast_css.js": "http://localhost:8080/assets/resources_js_Pages_Admin_Settings_vue-resources_js_utils_toast_css.js",
"assets/resources_js_Pages_Admin_User_Create_vue-resources_js_Components_SimplePasswordMeter_password-f3312a.css": "http://localhost:8080/assets/resources_js_Pages_Admin_User_Create_vue-resources_js_Components_SimplePasswordMeter_password-f3312a.css",
"assets/resources_js_Pages_Admin_User_Create_vue-resources_js_Components_SimplePasswordMeter_password-f3312a.js": "http://localhost:8080/assets/resources_js_Pages_Admin_User_Create_vue-resources_js_Components_SimplePasswordMeter_password-f3312a.js",
"assets/resources_js_Pages_Admin_User_Edit_vue-resources_js_Components_SimplePasswordMeter_password-m-6dc207.css": "http://localhost:8080/assets/resources_js_Pages_Admin_User_Edit_vue-resources_js_Components_SimplePasswordMeter_password-m-6dc207.css",
"assets/resources_js_Pages_Admin_User_Edit_vue-resources_js_Components_SimplePasswordMeter_password-m-6dc207.js": "http://localhost:8080/assets/resources_js_Pages_Admin_User_Edit_vue-resources_js_Components_SimplePasswordMeter_password-m-6dc207.js",
"assets/resources_js_Pages_Admin_User_Index_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_User_Index_vue.js",
"assets/resources_js_Pages_Admin_User_Show_vue.js": "http://localhost:8080/assets/resources_js_Pages_Admin_User_Show_vue.js",
"assets/resources_js_Pages_App_vue.js": "http://localhost:8080/assets/resources_js_Pages_App_vue.js",
"assets/resources_js_Pages_Auth_AccountInfo_vue-resources_js_utils_toast_css-resources_js_Components_-06c7b5.css": "http://localhost:8080/assets/resources_js_Pages_Auth_AccountInfo_vue-resources_js_utils_toast_css-resources_js_Components_-06c7b5.css",
"assets/resources_js_Pages_Auth_AccountInfo_vue-resources_js_utils_toast_css-resources_js_Components_-06c7b5.js": "http://localhost:8080/assets/resources_js_Pages_Auth_AccountInfo_vue-resources_js_utils_toast_css-resources_js_Components_-06c7b5.js",
"assets/resources_js_Pages_Auth_Login_vue.js": "http://localhost:8080/assets/resources_js_Pages_Auth_Login_vue.js",
"assets/resources_js_Pages_Auth_Register_vue.js": "http://localhost:8080/assets/resources_js_Pages_Auth_Register_vue.js",
"assets/resources_js_Pages_Dashboard_vue.js": "http://localhost:8080/assets/resources_js_Pages_Dashboard_vue.js",
"assets/resources_js_Pages_Editor_Dataset_Approve_vue.js": "http://localhost:8080/assets/resources_js_Pages_Editor_Dataset_Approve_vue.js",
"assets/resources_js_Pages_Editor_Dataset_Doi_vue.js": "http://localhost:8080/assets/resources_js_Pages_Editor_Dataset_Doi_vue.js",
"assets/resources_js_Pages_Editor_Dataset_Index_vue.js": "http://localhost:8080/assets/resources_js_Pages_Editor_Dataset_Index_vue.js",
"assets/resources_js_Pages_Editor_Dataset_Publish_vue.js": "http://localhost:8080/assets/resources_js_Pages_Editor_Dataset_Publish_vue.js",
"assets/resources_js_Pages_Editor_Dataset_Receive_vue.js": "http://localhost:8080/assets/resources_js_Pages_Editor_Dataset_Receive_vue.js",
"assets/resources_js_Pages_Editor_Dataset_Reject_vue.js": "http://localhost:8080/assets/resources_js_Pages_Editor_Dataset_Reject_vue.js",
"assets/resources_js_Pages_Error_vue.js": "http://localhost:8080/assets/resources_js_Pages_Error_vue.js",
"assets/resources_js_Pages_Errors_ServerError_vue.js": "http://localhost:8080/assets/resources_js_Pages_Errors_ServerError_vue.js",
"assets/resources_js_Pages_Errors_not_found_vue.js": "http://localhost:8080/assets/resources_js_Pages_Errors_not_found_vue.js",
"assets/resources_js_Pages_Map_vue-resources_js_Components_Map_draw_component_vue-resources_js_Compon-b0925c.css": "http://localhost:8080/assets/resources_js_Pages_Map_vue-resources_js_Components_Map_draw_component_vue-resources_js_Compon-b0925c.css",
"assets/resources_js_Pages_Map_vue-resources_js_Components_Map_draw_component_vue-resources_js_Compon-b0925c.js": "http://localhost:8080/assets/resources_js_Pages_Map_vue-resources_js_Components_Map_draw_component_vue-resources_js_Compon-b0925c.js",
"assets/resources_js_Pages_ProfileView_vue.js": "http://localhost:8080/assets/resources_js_Pages_ProfileView_vue.js",
"assets/resources_js_Pages_Reviewer_Dataset_Index_vue.js": "http://localhost:8080/assets/resources_js_Pages_Reviewer_Dataset_Index_vue.js",
"assets/resources_js_Pages_Reviewer_Dataset_Reject_vue.js": "http://localhost:8080/assets/resources_js_Pages_Reviewer_Dataset_Reject_vue.js",
"assets/resources_js_Pages_Reviewer_Dataset_Review_vue.js": "http://localhost:8080/assets/resources_js_Pages_Reviewer_Dataset_Review_vue.js",
"assets/resources_js_Pages_Submitter_Dataset_Category_vue.css": "http://localhost:8080/assets/resources_js_Pages_Submitter_Dataset_Category_vue.css",
"assets/resources_js_Pages_Submitter_Dataset_Category_vue.js": "http://localhost:8080/assets/resources_js_Pages_Submitter_Dataset_Category_vue.js",
"assets/resources_js_Pages_Submitter_Dataset_Create_vue-resources_js_utils_toast_css-resources_js_Com-03a898.css": "http://localhost:8080/assets/resources_js_Pages_Submitter_Dataset_Create_vue-resources_js_utils_toast_css-resources_js_Com-03a898.css",
"assets/resources_js_Pages_Submitter_Dataset_Create_vue-resources_js_utils_toast_css-resources_js_Com-03a898.js": "http://localhost:8080/assets/resources_js_Pages_Submitter_Dataset_Create_vue-resources_js_utils_toast_css-resources_js_Com-03a898.js",
"assets/resources_js_Pages_Submitter_Dataset_Delete_vue.js": "http://localhost:8080/assets/resources_js_Pages_Submitter_Dataset_Delete_vue.js",
"assets/resources_js_Pages_Submitter_Dataset_Edit_vue-resources_js_utils_toast_css-resources_js_Compo-a37b65.css": "http://localhost:8080/assets/resources_js_Pages_Submitter_Dataset_Edit_vue-resources_js_utils_toast_css-resources_js_Compo-a37b65.css",
"assets/resources_js_Pages_Submitter_Dataset_Edit_vue-resources_js_utils_toast_css-resources_js_Compo-a37b65.js": "http://localhost:8080/assets/resources_js_Pages_Submitter_Dataset_Edit_vue-resources_js_utils_toast_css-resources_js_Compo-a37b65.js",
"assets/resources_js_Pages_Submitter_Dataset_Index_vue.css": "http://localhost:8080/assets/resources_js_Pages_Submitter_Dataset_Index_vue.css",
"assets/resources_js_Pages_Submitter_Dataset_Index_vue.js": "http://localhost:8080/assets/resources_js_Pages_Submitter_Dataset_Index_vue.js",
"assets/resources_js_Pages_Submitter_Dataset_Release_vue.js": "http://localhost:8080/assets/resources_js_Pages_Submitter_Dataset_Release_vue.js",
"assets/resources_js_Pages_Submitter_Person_Index_vue.js": "http://localhost:8080/assets/resources_js_Pages_Submitter_Person_Index_vue.js",
"assets/resources_js_Pages_register-view_register-view-component_vue.js": "http://localhost:8080/assets/resources_js_Pages_register-view_register-view-component_vue.js",
"assets/vendors-node_modules_mdi_js_mdi_js-node_modules_vue-loader_dist_exportHelper_js.js": "http://localhost:8080/assets/vendors-node_modules_mdi_js_mdi_js-node_modules_vue-loader_dist_exportHelper_js.js",
"assets/vendors-node_modules_focus-trap_dist_focus-trap_esm_js-node_modules_notiwind_dist_index_esm_js.js": "http://localhost:8080/assets/vendors-node_modules_focus-trap_dist_focus-trap_esm_js-node_modules_notiwind_dist_index_esm_js.js",
"assets/vendors-node_modules_vue-facing-decorator_dist_esm_utils_js.js": "http://localhost:8080/assets/vendors-node_modules_vue-facing-decorator_dist_esm_utils_js.js",
"assets/vendors-node_modules_toastify-js_src_toastify_js.js": "http://localhost:8080/assets/vendors-node_modules_toastify-js_src_toastify_js.js",
"assets/vendors-node_modules_leaflet_dist_leaflet-src_js-node_modules_leaflet_src_control_Control_Att-adabdc.js": "http://localhost:8080/assets/vendors-node_modules_leaflet_dist_leaflet-src_js-node_modules_leaflet_src_control_Control_Att-adabdc.js",
"assets/vendors-node_modules_buffer_index_js-node_modules_vuedraggable_dist_vuedraggable_umd_js.js": "http://localhost:8080/assets/vendors-node_modules_buffer_index_js-node_modules_vuedraggable_dist_vuedraggable_umd_js.js",
"assets/vendors-node_modules_mime_dist_src_index_js.js": "http://localhost:8080/assets/vendors-node_modules_mime_dist_src_index_js.js",
"assets/vendors-node_modules_numeral_numeral_js-node_modules_chart_js_dist_chart_js.js": "http://localhost:8080/assets/vendors-node_modules_numeral_numeral_js-node_modules_chart_js_dist_chart_js.js",
"assets/resources_js_Components_BaseButton_vue.js": "http://localhost:8080/assets/resources_js_Components_BaseButton_vue.js",
"assets/resources_js_Stores_main_ts-resources_js_Components_BaseDivider_vue-resources_js_Components_C-b45805.js": "http://localhost:8080/assets/resources_js_Stores_main_ts-resources_js_Components_BaseDivider_vue-resources_js_Components_C-b45805.js",
"assets/resources_js_Layouts_LayoutAuthenticated_vue.css": "http://localhost:8080/assets/resources_js_Layouts_LayoutAuthenticated_vue.css",
"assets/resources_js_Layouts_LayoutAuthenticated_vue.js": "http://localhost:8080/assets/resources_js_Layouts_LayoutAuthenticated_vue.js",
"assets/resources_js_Components_BaseButtons_vue-resources_js_Components_FormControl_vue-resources_js_-d830d6.js": "http://localhost:8080/assets/resources_js_Components_BaseButtons_vue-resources_js_Components_FormControl_vue-resources_js_-d830d6.js",
"assets/resources_js_Components_Admin_Pagination_vue-resources_js_Components_BaseButtons_vue-resource-6f3a70.js": "http://localhost:8080/assets/resources_js_Components_Admin_Pagination_vue-resources_js_Components_BaseButtons_vue-resource-6f3a70.js",
"assets/resources_js_utils_toast_ts-resources_js_Components_NotificationBar_vue.js": "http://localhost:8080/assets/resources_js_utils_toast_ts-resources_js_Components_NotificationBar_vue.js",
"assets/resources_js_Components_Map_draw_component_vue-resources_js_Components_Map_zoom_component_vue-058bcc.js": "http://localhost:8080/assets/resources_js_Components_Map_draw_component_vue-resources_js_Components_Map_zoom_component_vue-058bcc.js",
"assets/resources_js_Components_SectionMain_vue-resources_js_Components_SectionTitleLineWithButton_vu-764dfe.js": "http://localhost:8080/assets/resources_js_Components_SectionMain_vue-resources_js_Components_SectionTitleLineWithButton_vu-764dfe.js",
"assets/resources_js_Components_BaseButtons_vue-resources_js_Components_NotificationBar_vue-resources-7e06d8.js": "http://localhost:8080/assets/resources_js_Components_BaseButtons_vue-resources_js_Components_NotificationBar_vue-resources-7e06d8.js",
"assets/resources_js_Components_Admin_Sort_vue-resources_js_Components_SectionTitleLineWithButton_vue.js": "http://localhost:8080/assets/resources_js_Components_Admin_Sort_vue-resources_js_Components_SectionTitleLineWithButton_vue.js",
"assets/resources_js_Components_CardBoxModal_vue.js": "http://localhost:8080/assets/resources_js_Components_CardBoxModal_vue.js",
"assets/resources_js_Components_FileUpload_vue-resources_js_Components_FormCheckRadioGroup_vue-resour-25e686.js": "http://localhost:8080/assets/resources_js_Components_FileUpload_vue-resources_js_Components_FormCheckRadioGroup_vue-resour-25e686.js",
"assets/fonts/inter-latin-ext-400-normal.woff": "http://localhost:8080/assets/fonts/inter-latin-ext-400-normal.1c20f7dc.woff",
"assets/fonts/inter-latin-400-normal.woff": "http://localhost:8080/assets/fonts/inter-latin-400-normal.b0c8fe9d.woff",
"assets/fonts/inter-latin-ext-400-normal.woff2": "http://localhost:8080/assets/fonts/inter-latin-ext-400-normal.3d10c85f.woff2",
"assets/fonts/inter-latin-400-normal.woff2": "http://localhost:8080/assets/fonts/inter-latin-400-normal.9698cc7d.woff2",
"assets/fonts/archivo-black-latin-400-normal.woff2": "http://localhost:8080/assets/fonts/archivo-black-latin-400-normal.fc847a1f.woff2",
"assets/fonts/archivo-black-latin-ext-400-normal.woff2": "http://localhost:8080/assets/fonts/archivo-black-latin-ext-400-normal.21761451.woff2",
"assets/fonts/inter-cyrillic-ext-400-normal.woff": "http://localhost:8080/assets/fonts/inter-cyrillic-ext-400-normal.e8945162.woff",
"assets/fonts/archivo-black-latin-400-normal.woff": "http://localhost:8080/assets/fonts/archivo-black-latin-400-normal.58a301a6.woff",
"assets/fonts/inter-cyrillic-ext-400-normal.woff2": "http://localhost:8080/assets/fonts/inter-cyrillic-ext-400-normal.fd1478dc.woff2",
"assets/fonts/inter-cyrillic-400-normal.woff": "http://localhost:8080/assets/fonts/inter-cyrillic-400-normal.e2841352.woff",
"assets/fonts/inter-greek-400-normal.woff": "http://localhost:8080/assets/fonts/inter-greek-400-normal.a42da273.woff",
"assets/fonts/archivo-black-latin-ext-400-normal.woff": "http://localhost:8080/assets/fonts/archivo-black-latin-ext-400-normal.5ab5ba92.woff",
"assets/fonts/inter-greek-400-normal.woff2": "http://localhost:8080/assets/fonts/inter-greek-400-normal.a8de720a.woff2",
"assets/fonts/inter-cyrillic-400-normal.woff2": "http://localhost:8080/assets/fonts/inter-cyrillic-400-normal.cb04b2ee.woff2",
"assets/fonts/inter-greek-ext-400-normal.woff": "http://localhost:8080/assets/fonts/inter-greek-ext-400-normal.b9e1e894.woff",
"assets/fonts/inter-vietnamese-400-normal.woff": "http://localhost:8080/assets/fonts/inter-vietnamese-400-normal.96f8adc7.woff",
"assets/fonts/inter-greek-ext-400-normal.woff2": "http://localhost:8080/assets/fonts/inter-greek-ext-400-normal.f2fa0d9e.woff2",
"assets/fonts/inter-vietnamese-400-normal.woff2": "http://localhost:8080/assets/fonts/inter-vietnamese-400-normal.44c9df13.woff2",
"assets/images/marker-icon.png": "http://localhost:8080/assets/images/marker-icon.2b3e1faf.png",
"assets/images/layers-2x.png": "http://localhost:8080/assets/images/layers-2x.8f2c4d11.png",
"assets/images/layers.png": "http://localhost:8080/assets/images/layers.416d9136.png",
"assets/images/Close.svg": "http://localhost:8080/assets/images/Close.e4887675.svg",
"assets/vendors-node_modules_vue-facing-decorator_dist_esm_index_js-node_modules_vue-facing-decorator-818045.js": "http://localhost:8080/assets/vendors-node_modules_vue-facing-decorator_dist_esm_index_js-node_modules_vue-facing-decorator-818045.js"
}

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"}

View file

@ -1,19 +1,20 @@
/* @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500&display=swap'); */ /* @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 url('https://fonts.googleapis.com/css?family=Roboto:400,400i,600,700'); */
@tailwind base; /* @import '_checkbox-radio-switch.css'; */
@tailwind components;
@tailwind utilities;
@import '_checkbox-radio-switch.css';
@import '_progress.css'; @import '_progress.css';
@import '_scrollbars.css'; @import '_scrollbars.css';
@import '_table.css'; @import '_table.css';
@import '~leaflet/dist/leaflet.css'; /* @import '~leaflet/dist/leaflet.css'; */
@import '~/leaflet/dist/leaflet.css';
@import '@fontsource/inter/index.css'; @import '@fontsource/inter/index.css';
@import '@fontsource/archivo-black/index.css'; @import '@fontsource/archivo-black/index.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root { :root {
--color-main-background: #ffffff; --color-main-background: #ffffff;
--color-main-background-rgb: 255,255,255; --color-main-background-rgb: 255,255,255;
@ -108,6 +109,9 @@
--radius: 15; --radius: 15;
--pi: 3.14159265358979; --pi: 3.14159265358979;
} }
.leaflet-container .leaflet-pane {
z-index: 30!important;
}
/* @layer base { /* @layer base {
html, html,

Binary file not shown.

View file

@ -1,161 +1,142 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ComputedRef } from 'vue'; import { computed } from 'vue';
import { Link, usePage } from '@inertiajs/vue3'; import { Link, usePage } from '@inertiajs/vue3';
// import { Link } from '@inertiajs/inertia-vue3';
import { StyleService } from '@/Stores/style.service'; import { StyleService } from '@/Stores/style.service';
import { mdiMinus, mdiPlus } from '@mdi/js'; import { mdiMinus, mdiPlus } from '@mdi/js';
import { getButtonColor } from '@/colors'; import { getButtonColor } from '@/colors';
import BaseIcon from '@/Components/BaseIcon.vue'; import BaseIcon from '@/Components/BaseIcon.vue';
// import AsideMenuList from '@/Components/AsideMenuList.vue';
import { stardust } from '@eidellev/adonis-stardust/client'; import { stardust } from '@eidellev/adonis-stardust/client';
import type { User } from '@/Dataset'; import type { User } from '@/Dataset';
import { MenuItem } from '@headlessui/vue';
const props = defineProps({ interface MenuItem {
item: { href?: string;
type: Object, route?: string;
required: true, icon?: string;
}, label: string;
parentItem: { target?: string;
type: Object, color?: string;
required: false, children?: MenuItem[];
}, isOpen?: boolean;
// isDropdownList: Boolean, roles?: string[];
}); }
const user: ComputedRef<User> = computed(() => { const props = defineProps<{
return usePage().props.authUser as User; item: MenuItem;
parentItem?: MenuItem;
// isDropdownList?: boolean;
}>();
const emit = defineEmits<{
(e: 'menu-click', event: Event, item: MenuItem): void;
}>();
// Retrieve authenticated user from page props
const user = computed<User>(() => usePage().props.authUser as User);
// Check if the menu item has children
const hasChildren = computed(() => {
return Array.isArray(props.item?.children) && props.item.children.length > 0;
}); });
const itemRoute = computed(() => (props.item && props.item.route ? stardust.route(props.item.route) : '')); const itemRoute = computed(() => (props.item && props.item.route ? stardust.route(props.item.route) : ''));
// const isCurrentRoute = computed(() => (props.item && props.item.route ? stardust.isCurrent(props.item.route): false)); // const isCurrentRoute = computed(() => (props.item && props.item.route ? stardust.isCurrent(props.item.route): false));
// const itemHref = computed(() => (props.item && props.item.href ? props.item.href : ''));
const emit = defineEmits(['menu-click']); // Determine which element to render based on 'href' or 'route'
const isComponent = computed(() => {
if (props.item.href) {
return 'a';
}
if (props.item.route) {
return Link;
}
return 'div';
});
// Check if any child route is active
const isChildActive = computed(() => {
if (props.item.children && props.item.children.length > 0) {
return props.item.children.some(child => child.route && stardust.isCurrent(child.route));
}
return false;
});
// Automatically use prop item.isOpen if set from the parent,
// or if one of its children is active then force open state.
const isOpen = computed(() => {
return props.item.isOpen || isChildActive.value;
});
const styleService = StyleService(); const styleService = StyleService();
const hasColor = computed(() => props.item && props.item.color); const hasColor = computed(() => props.item && props.item.color);
// const isDropdownOpen = ref(false);
// const isChildSelected = computed(() => {
// if (props.item.children && props.item.children.length > 0) { // const children = computed(() => {
// return children.value.some(childItem => stardust.isCurrent(childItem.route)); // return props.item.children || [];
// }
// return false;
// }); // });
const hasChildren = computed(() => {
// props.item.children?.length > 0
if (props.item.children && props.item.children.length > 0) {
return true;
}
return false;
});
const children = computed(() => {
return props.item.children || [];
});
const componentClass = computed(() => [ const componentClass = computed(() => [
hasChildren ? 'py-3 px-6 text-sm font-semibold' : 'py-3 px-6', hasChildren ? 'py-3 px-6 text-sm font-semibold' : 'py-3 px-6',
hasColor.value ? getButtonColor(props.item.color, false, true) : styleService.asideMenuItemStyle, hasColor.value ? getButtonColor(props.item.color, false, true) : styleService.asideMenuItemStyle,
]); ]);
const menuClick = (event: Event) => {
// const toggleDropdown = () => {
// // emit('menu-click', event, props.item);
// // console.log(props.item);
// if (hasChildren.value) {
// isDropdownOpen.value = !isDropdownOpen.value;
// }
// // if (props.parentItem?.hasDropdown.value) {
// // props.parentItem.isDropdownActive.value = true;
// // }
// };
const menuClick = (event) => {
emit('menu-click', event, props.item); emit('menu-click', event, props.item);
if (hasChildren.value) { if (hasChildren.value) {
// if (isChildSelected.value == false) { // Toggle open state if the menu has children
// isDropdownOpen.value = !isDropdownOpen.value;
props.item.isOpen = !props.item.isOpen; props.item.isOpen = !props.item.isOpen;
// }
} }
}; };
// const handleChildSelected = () => { const activeStyle = computed(() => {
// isChildSelected.value = true;
// };
const activeInactiveStyle = computed(() => {
if (props.item.route && stardust.isCurrent(props.item.route)) { if (props.item.route && stardust.isCurrent(props.item.route)) {
// console.log(props.item.route); // console.log(props.item.route);
return styleService.asideMenuItemActiveStyle; return 'text-sky-600 font-bold';
} else { } else {
return null; return null;
} }
}); });
const is = computed(() => {
if (props.item.href) {
return 'a';
}
if (props.item.route) {
return Link;
}
return 'div';
});
const hasRoles = computed(() => { const hasRoles = computed(() => {
if (props.item.roles) { if (props.item.roles) {
return user.value.roles.some(role => props.item.roles.includes(role.name)); return user.value.roles.some(role => props.item.roles?.includes(role.name));
// return test; // return test;
} }
return true return true
}); });
// props.routeName && stardust.isCurrent(props.routeName) ? props.activeColor : null
</script> </script>
<!-- :target="props.item.target ?? null" --> <!-- :target="props.item.target ?? null" -->
<template> <template>
<li v-if="hasRoles"> <li v-if="hasRoles">
<!-- <component :is="itemHref ? 'div' : Link" :href="itemHref ? itemHref : itemRoute" --> <component :is="isComponent" :href="props.item.href ? props.item.href : itemRoute"
<component :is="is" :href="itemRoute ? stardust.route(props.item.route) : props.item.href" class="flex cursor-pointer dark:text-slate-300 dark:hover:text-white menu-item-wrapper"
class="flex cursor-pointer dark:text-slate-300 dark:hover:text-white menu-item-wrapper" :class="componentClass" :class="componentClass" @click="menuClick" :target="props.item.target || null">
@click="menuClick" v-bind:target="props.item.target ?? null"> <BaseIcon v-if="props.item.icon" :path="props.item.icon" class="flex-none menu-item-icon"
<BaseIcon v-if="item.icon" :path="item.icon" class="flex-none menu-item-icon" :class="activeInactiveStyle" :class="activeStyle" w="w-16" :size="18" />
w="w-16" :size="18" />
<div class="menu-item-label"> <div class="menu-item-label">
<span class="grow text-ellipsis line-clamp-1" :class="activeInactiveStyle"> <span class="grow text-ellipsis line-clamp-1" :class="[activeStyle]">
{{ item.label }} {{ props.item.label }}
</span> </span>
</div> </div>
<!-- plus icon for expanding sub menu --> <!-- Display plus or minus icon if there are child items -->
<BaseIcon v-if="hasChildren" :path="props.item.isOpen ? mdiMinus : mdiPlus" class="flex-none" <BaseIcon v-if="hasChildren" :path="isOpen ? mdiMinus : mdiPlus" class="flex-none"
:class="[activeInactiveStyle]" w="w-12" /> :class="[activeStyle]" w="w-12" />
</component> </component>
<!-- Render dropdown -->
<div class="menu-item-dropdown" <div class="menu-item-dropdown"
:class="[styleService.asideMenuDropdownStyle, props.item.isOpen ? 'block dark:bg-slate-800/50' : 'hidden']" :class="[styleService.asideMenuDropdownStyle, isOpen ? 'block dark:bg-slate-800/50' : 'hidden']"
v-if="hasChildren"> v-if="props.item.children && props.item.children.length > 0">
<ul> <ul>
<!-- <li v-for="( child, index ) in children " :key="index">
<AsideMenuItem :item="child" :key="index"> </AsideMenuItem> <AsideMenuItem v-for="(childItem, index) in (props.item.children as any[])" :key="index" :item="childItem"
</li> --> @menu-click="$emit('menu-click', $event, childItem)" />
<AsideMenuItem v-for="(childItem, index) in children" :key="index" :item="childItem" />
</ul> </ul>
</div> </div>
<!-- <AsideMenuList v-if="hasChildren" :items="item.children"
:class="[styleService.asideMenuDropdownStyle, isDropdownOpen ? 'block dark:bg-slate-800/50' : 'hidden']" /> -->
</li> </li>
</template> </template>
@ -171,11 +152,6 @@ const hasRoles = computed(() => {
/* margin-right: 10px; */ /* margin-right: 10px; */
} }
/* .menu-item-label {
font-size: 1.2rem;
font-weight: bold;
} */
.menu-item-dropdown { .menu-item-dropdown {
/* margin-left: 10px; */ /* margin-left: 10px; */
padding-left: 0.75rem; padding-left: 0.75rem;

View file

@ -36,13 +36,24 @@ const logoutItemClick = async () => {
await router.post(stardust.route('logout')); await router.post(stardust.route('logout'));
}; };
const menuClick = (event, item) => { interface MenuItem {
name: string;
label: string;
icon: string;
color: string;
link: string;
}
const menuClick = (event: Event, item: MenuItem) => {
emit('menu-click', event, item); emit('menu-click', event, item);
}; };
</script> </script>
<template> <template>
<aside id="aside" class="lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden"> <aside
id="aside"
class="lg:pb-2 lg:pl-2 w-60 fixed flex z-40 top-0 lg:top-16 h-screen lg:h-[calc(100vh-64px)] transition-position overflow-hidden"
>
<div :class="styleStore.asideStyle" class="lg:rounded-xl flex-1 flex flex-col overflow-hidden dark:bg-slate-900"> <div :class="styleStore.asideStyle" class="lg:rounded-xl flex-1 flex flex-col overflow-hidden dark:bg-slate-900">
<div :class="styleStore.asideBrandStyle" class="flex flex-row h-14 items-center justify-between dark:bg-slate-900"> <div :class="styleStore.asideBrandStyle" class="flex flex-row h-14 items-center justify-between dark:bg-slate-900">
<div class="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0"> <div class="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">

View file

@ -12,6 +12,10 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
showHeaderIcon: {
type: Boolean,
default: true,
},
headerIcon: { headerIcon: {
type: String, type: String,
default: null, default: null,
@ -63,7 +67,7 @@ const submit = (e) => {
<BaseIcon v-if="icon" :path="icon" class="mr-3" /> <BaseIcon v-if="icon" :path="icon" class="mr-3" />
{{ title }} {{ title }}
</div> </div>
<button 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="headerIconClick">
<BaseIcon :path="computedHeaderIcon" /> <BaseIcon :path="computedHeaderIcon" />
</button> </button>
</header> </header>

View file

@ -1,6 +1,6 @@
<script setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { mdiTrendingDown, mdiTrendingUp, mdiTrendingNeutral } from '@mdi/js'; // import { mdiTrendingDown, mdiTrendingUp, mdiTrendingNeutral } from '@mdi/js';
import CardBox from '@/Components/CardBox.vue'; import CardBox from '@/Components/CardBox.vue';
import BaseLevel from '@/Components/BaseLevel.vue'; import BaseLevel from '@/Components/BaseLevel.vue';
import PillTag from '@/Components/PillTag.vue'; import PillTag from '@/Components/PillTag.vue';
@ -27,6 +27,10 @@ const props = defineProps({
type: Number, type: Number,
default: 0, default: 0,
}, },
count: {
type: Number,
default: 0,
},
text: { text: {
type: String, type: String,
default: null, default: null,
@ -35,6 +39,10 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
allowEmailContact: {
type: Boolean,
default: false,
}
}); });
const pillType = computed(() => { const pillType = computed(() => {
@ -42,11 +50,11 @@ const pillType = computed(() => {
return props.type; return props.type;
} }
if (props.progress) { if (props.count) {
if (props.progress >= 60) { if (props.count >= 20) {
return 'success'; return 'success';
} }
if (props.progress >= 40) { if (props.count >= 5) {
return 'warning'; return 'warning';
} }
@ -56,17 +64,17 @@ const pillType = computed(() => {
return 'info'; return 'info';
}); });
const pillIcon = computed(() => { // const pillIcon = computed(() => {
return { // return {
success: mdiTrendingUp, // success: mdiTrendingUp,
warning: mdiTrendingNeutral, // warning: mdiTrendingNeutral,
danger: mdiTrendingDown, // danger: mdiTrendingDown,
info: mdiTrendingNeutral, // info: mdiTrendingNeutral,
}[pillType.value]; // }[pillType.value];
}); // });
const pillText = computed(() => props.text ?? `${props.progress}%`); // const pillText = computed(() => props.text ?? `${props.progress}%`);
</script> // </script>
<template> <template>
<CardBox class="mb-6 last:mb-0" hoverable> <CardBox class="mb-6 last:mb-0" hoverable>
@ -78,12 +86,21 @@ const pillText = computed(() => props.text ?? `${props.progress}%`);
{{ name }} {{ name }}
</h4> </h4>
<p class="text-gray-500 dark:text-slate-400"> <p class="text-gray-500 dark:text-slate-400">
<!-- {{ date }} @ {{ login }} --> <div v-if="props.allowEmailContact"> {{ email }}</div>
{{ email }}
</p> </p>
</div> </div>
</BaseLevel> </BaseLevel>
<PillTag :type="pillType" :text="pillText" small :icon="pillIcon" /> <!-- <PillTag :type="pillType" :text="text" small :icon="pillIcon" /> -->
<div class="text-center md:text-right space-y-2">
<p class="text-sm text-gray-500">
Count
</p>
<div>
<PillTag :type="pillType" :text="String(count)" small />
</div>
</div>
</BaseLevel> </BaseLevel>
</CardBox> </CardBox>
</template> </template>

View file

@ -0,0 +1,107 @@
<script lang="ts" setup>
import { computed, PropType } from 'vue';
import { mdiChartTimelineVariant, mdiFileDocumentOutline, mdiFileOutline, mdiDatabase } from '@mdi/js';
import CardBox from '@/Components/CardBox.vue';
import PillTag from '@/Components/PillTag.vue';
import IconRounded from '@/Components/IconRounded.vue';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
// Extend dayjs to support relative times
dayjs.extend(relativeTime);
interface Dataset {
account_id: number;
created_at: string;
creating_corporation: string;
editor_id: number;
embargo_date: string | null;
id: number;
language: string;
main_abstract: string | null;
main_title: string | null;
preferred_reviewer: string | null;
preferred_reviewer_email: string | null;
project_id: number | null;
publish_id: number;
publisher_name: string;
reject_editor_note: string | null;
reject_reviewer_note: string | null;
remaining_time: number;
reviewer_id: number;
server_date_modified: string;
server_date_published: string;
server_state: string;
type: string;
doi_identifier: string;
}
const props = defineProps({
dataset: {
type: Object as PropType<Dataset>,
required: true
}
});
const icon = computed(() => {
switch (props.dataset.type) {
case 'analysisdata':
return { icon: mdiChartTimelineVariant, type: 'success' };
case 'measurementdata':
return { icon: mdiFileDocumentOutline, type: 'warning' };
case 'monitoring':
return { icon: mdiFileOutline, type: 'info' };
case 'remotesensing':
return { icon: mdiDatabase, type: 'primary' };
case 'gis':
return { icon: mdiDatabase, type: 'info' };
case 'models':
return { icon: mdiChartTimelineVariant, type: 'success' };
case 'mixedtype':
return { icon: mdiFileDocumentOutline, type: 'warning' };
case 'vocabulary':
return { icon: mdiFileOutline, type: 'info' };
default:
return { icon: mdiDatabase, type: 'secondary' };
}
});
const displayTitle = computed(() => props.dataset.main_title || 'Untitled Dataset');
const doiLink = computed(() => {
return `https://doi.tethys.at/10.24341/tethys.${props.dataset.publish_id}`;
});
const relativeDate = computed(() => {
const publishedDate = dayjs(props.dataset.server_date_published);
if (publishedDate.isValid()) {
return publishedDate.fromNow();
}
return props.dataset.server_date_published;
});
// const displayBusiness = computed(() => props.dataset.publisher_name);
</script>
<template>
<CardBox class="mb-6 last:mb-0" hoverable>
<div class="flex items-start justify-between">
<IconRounded :icon="icon.icon" :type="icon.type" class="mr-6" />
<div class="flex-grow space-y-1 text-left" style="width: 70%;">
<h4 class="text-lg truncate" >
{{ displayTitle }}
</h4>
<p class="text-gray-500 dark:text-slate-400">
<b>
<a :href="doiLink" target="_blank">View Publication</a>
</b>
{{ relativeDate }}
</p>
</div>
<div class="flex flex-col items-end gap-2">
<p class="text-sm text-gray-500">{{ props.dataset.type }}</p>
<PillTag :type="icon.type" :text="props.dataset.type" small class="inline-flex" />
</div>
</div>
</CardBox>
</template>

View file

@ -61,7 +61,7 @@ const cancel = () => confirmCancel('cancel');
<CardBox <CardBox
v-show="value" v-show="value"
:title="title" :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" :header-icon="mdiClose"
modal modal
@header-icon-click="cancel" @header-icon-click="cancel"

View file

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

View file

@ -17,6 +17,15 @@
<p class="text-lg text-blue-700">Drop files to upload</p> <p class="text-lg text-blue-700">Drop files to upload</p>
</div> </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 --> <!-- scroll area -->
<div class="h-full p-8 w-full h-full flex flex-col"> <div class="h-full p-8 w-full h-full flex flex-col">
<header class="flex items-center justify-center w-full"> <header class="flex items-center justify-center w-full">
@ -32,9 +41,9 @@
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400"> <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 <span class="font-semibold">Click to upload</span> or drag and drop
</p> </p>
<!-- <p class="text-xs text-gray-500 dark:text-gray-400">SVG, PNG, JPG or GIF (MAX. 800x400px)</p> -->
</div> </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> </label>
</header> </header>
@ -182,7 +191,7 @@
<!-- sticky footer --> <!-- sticky footer -->
<footer class="flex justify-end px-8 pb-8 pt-4"> <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" class="ml-3 rounded-sm px-3 py-1 hover:bg-gray-300 focus:shadow-outline focus:outline-none"
@click="clearAllFiles"> @click="clearAllFiles">
Clear Clear
@ -198,7 +207,7 @@ import DeleteIcon from '@/Components/Icons/Delete.vue';
import RefreshIcon from '@/Components/Icons/Refresh.vue'; import RefreshIcon from '@/Components/Icons/Refresh.vue';
// import { Page, PageProps, Errors, ErrorBag } from '@inertiajs/inertia'; // import { Page, PageProps, Errors, ErrorBag } from '@inertiajs/inertia';
import Draggable from 'vuedraggable'; import Draggable from 'vuedraggable';
import { Buffer } from 'buffer'; // import { Buffer } from 'buffer';
import { TethysFile } from '@/Dataset'; import { TethysFile } from '@/Dataset';
// lastModified: 1691759507591 // lastModified: 1691759507591
@ -241,6 +250,8 @@ class FileUploadComponent extends Vue {
@Ref('overlay') overlay: HTMLDivElement; @Ref('overlay') overlay: HTMLDivElement;
public isLoading: boolean = false;
private counter: number = 0; private counter: number = 0;
// @Prop() files: Array<TestFile>; // @Prop() files: Array<TestFile>;
@ -257,6 +268,12 @@ class FileUploadComponent extends Vue {
}) })
filesToDelete: Array<TethysFile>; filesToDelete: Array<TethysFile>;
@Prop({
type: Boolean,
default: true,
})
showClearButton: boolean;
// // deletetFiles: Array<TethysFile> = []; // // deletetFiles: Array<TethysFile> = [];
get deletetFiles(): Array<TethysFile> { get deletetFiles(): Array<TethysFile> {
return this.filesToDelete; return this.filesToDelete;
@ -342,10 +359,10 @@ class FileUploadComponent extends Vue {
} }
// reset counter and append file to gallery when file is dropped // reset counter and append file to gallery when file is dropped
public dropHandler(event: DragEvent): void { public dropHandler(event: DragEvent): void {
event.preventDefault(); event.preventDefault();
const dataTransfer = event.dataTransfer; const dataTransfer = event.dataTransfer;
// let bigFileFound = false;
if (dataTransfer) { if (dataTransfer) {
for (const file of event.dataTransfer?.files) { for (const file of event.dataTransfer?.files) {
// let fileName = String(file.name.replace(/\.[^/.]+$/, '')); // let fileName = String(file.name.replace(/\.[^/.]+$/, ''));
@ -353,28 +370,73 @@ class FileUploadComponent extends Vue {
// if (file.type.match('image.*')) { // if (file.type.match('image.*')) {
// this.generateURL(file); // this.generateURL(file);
// } // }
// if (file.size > 62914560) { // 60 MB in bytes
// bigFileFound = true;
// }
this._addFile(file); this._addFile(file);
} }
this.overlay.classList.remove('draggedover'); this.overlay.classList.remove('draggedover');
this.counter = 0; 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) { public onChangeFile(event: Event) {
event.preventDefault(); event.preventDefault();
let target = event.target as HTMLInputElement;
// let uploadedFile = event.target.files[0]; // let uploadedFile = event.target.files[0];
// let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, '')); // let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, ''));
if (target && target.files) {
for (const file of event.target.files) { for (const file of event.target.files) {
// let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, '')); // let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, ''));
// file.label = fileName; // file.label = fileName;
// if (file.type.match('image.*')) { // if (file.type.match('image.*')) {
// this.generateURL(file); // 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); this._addFile(file);
} }
}
// if (bigFileFound) {
// this.isLoading = true;
// setTimeout(() => {
// this.isLoading = false;
// }, 1500);
// }
// this.overlay.classList.remove('draggedover'); // this.overlay.classList.remove('draggedover');
this.counter = 0; this.counter = 0;
this.isLoading = false;
} }
get errors(): IDictionary { get errors(): IDictionary {
@ -396,8 +458,10 @@ class FileUploadComponent extends Vue {
public clearAllFiles(event: Event) { public clearAllFiles(event: Event) {
event.preventDefault(); event.preventDefault();
if (this.showClearButton == true) {
this.items.splice(0); this.items.splice(0);
} }
}
public removeFile(key: number) { public removeFile(key: number) {
// Check if the key is within the bounds of the items array // Check if the key is within the bounds of the items array
@ -445,18 +509,19 @@ class FileUploadComponent extends Vue {
let localUrl: string = ''; let localUrl: string = '';
if (file instanceof File) { if (file instanceof File) {
localUrl = URL.createObjectURL(file as Blob); localUrl = URL.createObjectURL(file as Blob);
} else if (file.fileData) {
// const blob = new Blob([file.fileData]);
// localUrl = URL.createObjectURL(blob);
const parsed = JSON.parse(file.fileData);
file.fileData = '';
// retrieve the original buffer of data
const buff = Buffer.from(parsed.blob, 'base64');
const blob = new Blob([buff], { type: 'application/octet-stream' });
// file.blob = blob;
localUrl = URL.createObjectURL(blob);
file.fileSrc = localUrl;
} }
// else if (file.fileData) {
// // const blob = new Blob([file.fileData]);
// // localUrl = URL.createObjectURL(blob);
// const parsed = JSON.parse(file.fileData);
// file.fileData = '';
// // retrieve the original buffer of data
// const buff = Buffer.from(parsed.blob, 'base64');
// const blob = new Blob([buff], { type: 'application/octet-stream' });
// // file.blob = blob;
// localUrl = URL.createObjectURL(blob);
// file.fileSrc = localUrl;
// }
// setTimeout(() => { // setTimeout(() => {
// URL.revokeObjectURL(localUrl); // URL.revokeObjectURL(localUrl);
@ -464,17 +529,6 @@ class FileUploadComponent extends Vue {
return localUrl; 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) { public getFileSize(file: File) {
if (file.size > 1024) { if (file.size > 1024) {
if (file.size > 1048576) { if (file.size > 1048576) {
@ -487,17 +541,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) { private _addFile(file: File) {
// const reader = new FileReader(); // const reader = new FileReader();
// reader.onload = (event) => { // reader.onload = (event) => {
@ -529,14 +572,11 @@ class FileUploadComponent extends Vue {
// this.items.push(test); // this.items.push(test);
this.items[this.items.length] = test; this.items[this.items.length] = test;
} else { } else {
file.sort_order = this.items.length + 1;
this.items.push(file); 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 { private _hasFiles(dataTransfer: DataTransfer | null): boolean {
return dataTransfer ? dataTransfer.items.length > 0 : false; 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 <!-- Get more with <a href="https://tailwind-vue.justboil.me/" target="_blank" class="text-blue-600">Premium
version</a> --> version</a> -->
</div> </div>
<div class="md:py-3"> <div class="md:py-1">
<a href="https://www.tethys.at" target="_blank"> <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> </a>
</div> </div>
</BaseLevel> </BaseLevel>

View file

@ -1,43 +1,59 @@
<script setup> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
const props = defineProps({ interface Props {
name: { name: string;
type: String, type?: 'checkbox' | 'radio' | 'switch';
required: true, label?: string | null;
}, modelValue: Array<any> | string | number | boolean | null;
type: { inputValue: string | number | boolean;
type: String, }
default: 'checkbox',
validator: (value) => ['checkbox', 'radio', 'switch'].includes(value), const props = defineProps<Props>();
},
label: { const emit = defineEmits<{ (e: 'update:modelValue', value: Props['modelValue']): void }>();
type: String,
default: null,
},
modelValue: {
type: [Array, String, Number, Boolean],
default: null,
},
inputValue: {
type: [String, Number, Boolean],
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const computedValue = computed({ const computedValue = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value) => { set: (value) => {
emit('update:modelValue', value); emit('update:modelValue', props.type === 'radio' ? [value] : value);
}, },
}); });
const inputType = computed(() => (props.type === 'radio' ? 'radio' : 'checkbox')); 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;
});
</script> </script>
<template> <template>
<label :class="type" class="mr-6 mb-3 last:mr-0"> <label v-if="type === 'radio'" :class="[type]"
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue" /> class="mr-6 mb-3 last:mr-0 inline-flex items-center cursor-pointer relative">
<span class="check" /> <input v-model="computedValue" :type="inputType" :name="name" :value="inputValue"
<span class="pl-2">{{ label }}</span> class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0"
:checked="isChecked" />
<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> </label>
</template> </template>

View file

@ -1,17 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref, PropType } from 'vue';
import FormCheckRadio from '@/Components/FormCheckRadio.vue'; import FormCheckRadio from '@/Components/FormCheckRadio.vue';
// import BaseButton from '@/Components/BaseButton.vue';
// import FormControl from '@/Components/FormControl.vue';
const props = defineProps({ const props = defineProps({
options: { options: {
type: Object, type: Object,
default: () => {}, default: () => { },
},
allowManualAdding: {
type: Boolean,
default: false,
},
manualAddingPlaceholder: {
type: String,
default: 'Add manually',
required: false,
}, },
name: { name: {
type: String, type: String,
required: true, required: true,
}, },
type: { type: {
type: String, type: String as PropType<'checkbox' | 'radio' | 'switch'>,
default: 'checkbox', default: 'checkbox',
validator: (value: string) => ['checkbox', 'radio', 'switch'].includes(value), validator: (value: string) => ['checkbox', 'radio', 'switch'].includes(value),
}, },
@ -35,7 +47,7 @@ const computedValue = computed({
if (props.modelValue.every((item) => typeof item === 'number')) { if (props.modelValue.every((item) => typeof item === 'number')) {
return props.modelValue; return props.modelValue;
} else if (props.modelValue.every((item) => hasIdAttribute(item))) { } else if (props.modelValue.every((item) => hasIdAttribute(item))) {
const ids = props.modelValue.map((obj) => obj.id.toString()); const ids = props.modelValue.map((obj) => obj.id);
return ids; return ids;
} }
return props.modelValue; return props.modelValue;
@ -55,6 +67,29 @@ const computedValue = computed({
const hasIdAttribute = (obj: any): obj is { id: any } => { const hasIdAttribute = (obj: any): obj is { id: any } => {
return typeof obj === 'object' && 'id' in obj; return typeof obj === 'object' && 'id' in obj;
}; };
const newOption = ref<string>('');
const addOption = () => {
if (newOption.value && !props.options[newOption.value]) {
props.options[newOption.value] = newOption.value;
newOption.value = '';
}
};
const inputElClass = computed(() => {
const base = [
'px-3 py-2 max-w-full border-gray-700 rounded w-full',
'dark:placeholder-gray-400',
'h-12',
'border',
'bg-transparent'
// props.isReadOnly ? 'bg-gray-50 dark:bg-slate-600' : 'bg-white dark:bg-slate-800',
];
// if (props.icon) {
// base.push('pl-10');
// }
return base;
});
</script> </script>
<template> <template>
@ -63,15 +98,17 @@ const hasIdAttribute = (obj: any): obj is { id: any } => {
<!-- :label="value" --> <!-- :label="value" -->
<!-- :input-value="value.id" <!-- :input-value="value.id"
:label="value.name" --> :label="value.name" -->
<FormCheckRadio <div v-if="allowManualAdding && type === 'checkbox'" class="flex items-center mt-2 mb-2 relative">
v-for="(value, key) in options" <input v-model="newOption" :placeholder="manualAddingPlaceholder" :class="inputElClass"
:key="key" @keydown.prevent.enter="addOption" />
v-model="computedValue" <svg v-show="newOption.length >= 2" @click.prevent="addOption" xmlns="http://www.w3.org/2000/svg"
:type="type" class="w-6 h-6 absolute right-2 top-1/2 transform -translate-y-1/2 cursor-pointer text-gray-500"
:name="name" viewBox="0 0 24 24" fill="currentColor">
:input-value="key" <path
:label="value" 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" />
:class="componentClass" </svg>
/> </div>
<FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
:name="name" :input-value="Number(key)" :label="value" :class="componentClass" />
</div> </div>
</template> </template>

View file

@ -67,15 +67,28 @@ const computedValue = computed({
emit('update:modelValue', value); emit('update:modelValue', value);
}, },
}); });
// focus:ring focus:outline-none border-gray-700
const inputElClass = computed(() => { const inputElClass = computed(() => {
const base = [ 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', 'dark:placeholder-gray-400',
props.extraHigh ? 'h-80' : (computedType.value === 'textarea' ? 'h-44' : 'h-12'), props.extraHigh ? 'h-80' : (computedType.value === 'textarea' ? 'h-44' : 'h-12'),
props.borderless ? 'border-0' : 'border', props.borderless ? 'border-0' : 'border',
// props.transparent && !props.isReadOnly ? 'bg-transparent' : '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', // 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) { if (props.icon) {
base.push('pl-10', 'pr-10'); base.push('pl-10', 'pr-10');
} }
@ -118,6 +131,9 @@ if (props.ctrlKFocus) {
mainService.isFieldFocusRegistered = false; mainService.isFieldFocusRegistered = false;
}); });
} }
const focus = () => {
inputEl?.value.focus();
};
</script> </script>
<template> <template>
@ -130,7 +146,7 @@ if (props.ctrlKFocus) {
</option> </option>
</select> </select>
<textarea v-else-if="computedType === 'textarea'" :id="id" v-model="computedValue" :class="inputElClass" <textarea v-else-if="computedType === 'textarea'" :id="id" v-model="computedValue" :class="inputElClass"
:name="name" :placeholder="placeholder" :required="required" /> :name="name" :placeholder="placeholder" :required="required" :readonly="isReadOnly"/>
<input v-else :id="id" ref="inputEl" v-model="computedValue" :name="name" :inputmode="inputmode" <input v-else :id="id" ref="inputEl" v-model="computedValue" :name="name" :inputmode="inputmode"
:autocomplete="autocomplete" :required="required" :placeholder="placeholder" :type="computedType" :autocomplete="autocomplete" :required="required" :placeholder="placeholder" :type="computedType"
:class="inputElClass" :readonly="isReadOnly" /> :class="inputElClass" :readonly="isReadOnly" />

View file

@ -2,7 +2,7 @@
import { computed, useSlots } from 'vue'; import { computed, useSlots } from 'vue';
defineProps({ const props = defineProps({
label: { label: {
type: String, type: String,
default: null, default: null,
@ -15,6 +15,10 @@ defineProps({
type: String, type: String,
default: null, default: null,
}, },
// class: {
// type: Object,
// default: {},
// },
}); });
const slots = useSlots(); const slots = useSlots();
@ -36,7 +40,7 @@ const wrapperClass = computed(() => {
</script> </script>
<template> <template>
<div class="mb-6 last:mb-0"> <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="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> <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"> <div v-bind:class="wrapperClass">

File diff suppressed because one or more lines are too long

View file

@ -282,7 +282,7 @@ const handleDrawEventCreated = async (event) => {
</template> </template>
<style lang="css"> <style scoped lang="css">
/* .leaflet-container { /* .leaflet-container {
height: 600px; height: 600px;
width: 100%; width: 100%;

View file

@ -268,7 +268,7 @@ export default class DrawControlComponent extends Vue {
position: absolute; position: absolute;
left: 10px; left: 10px;
top: 100px; top: 100px;
z-index: 999; z-index: 40;
} }
.btn-group-vertical button { .btn-group-vertical button {

View file

@ -372,6 +372,9 @@ export default class MapComponent extends Vue {
margin-top: 0.5em; margin-top: 0.5em;
} }
.leaflet-container .leaflet-pane {
z-index: 30!important;
}
/* .leaflet-pane { /* .leaflet-pane {
z-index: 30; z-index: 30;
} */ } */

View file

@ -100,7 +100,7 @@ export default class ZoomControlComponent extends Vue {
position: absolute; position: absolute;
left: 10px; left: 10px;
top: 10px; top: 10px;
z-index: 999; z-index: 40;
} }
.btn-group-vertical button { .btn-group-vertical button {

View file

@ -0,0 +1,138 @@
<template>
<div class="relative mb-4">
<!-- <label for="mimetype-input" class="block text-sm font-medium text-gray-700">Search for Mimetypes</label> -->
<input id="mimetype-input" v-model="newExtension" type="text" placeholder="Enter Mimetype Name"
class="block w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-blue-500 focus:border-blue-500"
:class="inputElClass" @input="handleInputChange" @keydown.down="onArrowDown" @keydown.up="onArrowUp"
@keydown.prevent.enter="onEnter">
</input>
<svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="newExtension.length < 2"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="newExtension.length >= 2"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
@click="clearInput">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<ul v-if="showDropdown && filteredMimetypes.length"
class="bg-white dark:bg-slate-800 w-full mt-2 max-h-28 overflow-y-auto scroll-smooth">
<li v-for="(mimeType, index) in filteredMimetypes" :key="index" @click="selectResult(mimeType)"
class="pl-8 pr-2 py-1 border-b-2 border-gray-100 relative cursor-pointer hover:bg-blue-500 hover:text-white"
:class="{
'bg-blue-500 text-white': selectedIndex === index,
'bg-white text-gray-900': selectedIndex !== index
}" :ref="(el) => {
if (ul) {
ul[index] = el as HTMLLIElement;
}
}">
<svg class="absolute w-4 h-4 left-2 top-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
<span>{{ mimeType }}</span>
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import { ref, Ref, watch, computed } from 'vue';
// import mime from 'mime';
const emit = defineEmits(['onSelectResult', 'onClearInput'])
const props = defineProps({
borderless: Boolean,
transparent: Boolean,
mimeTypes: {
type: Array as () => string[],
required: true
},
// form: Object,
// isValidMimeType: Function,
});
const newExtension = ref('');
const showDropdown = ref(false);
const filteredMimetypes = ref<string[]>([]);
const selectedIndex: Ref<number> = ref(0);
const ul: Ref<HTMLLIElement[] | null> = ref<HTMLLIElement[]>([]);
watch(selectedIndex, (selectedIndex: number) => {
if (selectedIndex != null && ul.value != null) {
const currentElement: HTMLLIElement = ul.value[selectedIndex];
currentElement &&
currentElement?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'start',
});
}
});
const inputElClass = computed(() => {
const base = [
'block p-2.5 w-full z-20 text-sm text-gray-900 bg-gray-50 rounded-r-lg',
'dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500',
'h-12',
props.borderless ? 'border-0' : 'border',
props.transparent ? 'bg-transparent' : 'bg-white dark:bg-slate-800',
];
base.push('pl-10');
return base;
});
const handleInputChange = (e: Event) => {
const target = <HTMLInputElement>e.target;
newExtension.value = target.value;
if (newExtension.value.length >= 2) {
showDropdown.value = true;
filteredMimetypes.value = props.mimeTypes.filter(mimeType =>
mimeType.toLowerCase().includes(newExtension.value.toLowerCase())
);
} else {
showDropdown.value = false;
}
};
const selectResult = (mimeType: string) => {
showDropdown.value = false;
newExtension.value = '';
selectedIndex.value = -1;
emit('onSelectResult', mimeType);
};
const clearInput = () => {
newExtension.value = '';
showDropdown.value = false;
// props.form.name = '';
// props.resetFileExtensions();
emit('onClearInput');
};
const onArrowDown = () => {
if (filteredMimetypes.value.length > 0) {
selectedIndex.value = selectedIndex.value === filteredMimetypes.value.length - 1 ? 0 : selectedIndex.value + 1;
}
};
const onArrowUp = () => {
if (filteredMimetypes.value.length > 0) {
selectedIndex.value = selectedIndex.value == 0 || selectedIndex.value == -1 ? filteredMimetypes.value.length - 1 : selectedIndex.value - 1;
}
};
const onEnter = () => {
if (Array.isArray(filteredMimetypes.value) && filteredMimetypes.value.length && selectedIndex.value !== -1 && selectedIndex.value < filteredMimetypes.value.length) {
const mimeType = filteredMimetypes.value[selectedIndex.value];
selectResult(mimeType);
}
};
</script>

View file

@ -21,12 +21,11 @@ import {
mdiFormatListGroup, mdiFormatListGroup,
mdiFormatListNumbered, mdiFormatListNumbered,
mdiLogout, mdiLogout,
mdiGithub,
mdiThemeLightDark, mdiThemeLightDark,
mdiViewDashboard, mdiViewDashboard,
mdiMapSearch,
mdiInformationVariant, mdiInformationVariant,
mdiGlasses, mdiGlasses,
mdiXml
} from '@mdi/js'; } from '@mdi/js';
import NavBarItem from '@/Components/NavBarItem.vue'; import NavBarItem from '@/Components/NavBarItem.vue';
import NavBarItemLabel from '@/Components/NavBarItemLabel.vue'; import NavBarItemLabel from '@/Components/NavBarItemLabel.vue';
@ -72,7 +71,7 @@ const menuNavBarToggle = () => {
const menuOpenLg = () => { const menuOpenLg = () => {
layoutStore.isAsideLgActive = true; layoutStore.isAsideLgActive = true;
}; };
const userHasRoles = (roleNames): boolean => { const userHasRoles = (roleNames: Array<string>): boolean => {
return user.value.roles.some(role => roleNames.includes(role.name)); return user.value.roles.some(role => roleNames.includes(role.name));
}; };
@ -95,12 +94,13 @@ const showAbout = async () => {
</script> </script>
<template> <template>
<nav class="text-base top-0 left-0 right-0 fixed bg-gray-50 h-14 z-40 w-screen transition-position lg:w-auto dark:bg-slate-800" <nav class="text-base top-0 left-0 right-0 fixed bg-lime h-14 z-50 w-screen transition-position lg:w-auto dark:bg-slate-800"
:class="{ 'xl:pl-60': props.showBurger == true }"> :class="{ 'xl:pl-60': props.showBurger == true }">
<FirstrunWizard ref="about"></FirstrunWizard> <FirstrunWizard ref="about"></FirstrunWizard>
<div class="flex lg:items-stretch" :class="containerMaxW"> <div class="flex lg:items-stretch" :class="containerMaxW">
<div class="flex-1 items-stretch flex h-14"> <div class="flex-1 items-stretch flex h-14">
<NavBarItem type="flex lg:hidden" @click.prevent="layoutStore.asideMobileToggle()" v-if="props.showBurger"> <NavBarItem type="flex lg:hidden" @click.prevent="layoutStore.asideMobileToggle()"
v-if="props.showBurger">
<BaseIcon :path="layoutStore.isAsideMobileExpanded ? mdiBackburger : mdiForwardburger" size="24" /> <BaseIcon :path="layoutStore.isAsideMobileExpanded ? mdiBackburger : mdiForwardburger" size="24" />
</NavBarItem> </NavBarItem>
<NavBarItem type="hidden lg:flex xl:hidden" @click.prevent="menuOpenLg" v-if="props.showBurger"> <NavBarItem type="hidden lg:flex xl:hidden" @click.prevent="menuOpenLg" v-if="props.showBurger">
@ -110,9 +110,9 @@ const showAbout = async () => {
<NavBarItemLabel :icon="mdiViewDashboard" label="Dashboard" size="22" is-hover-label-only <NavBarItemLabel :icon="mdiViewDashboard" label="Dashboard" size="22" is-hover-label-only
route-name="apps.dashboard" /> route-name="apps.dashboard" />
</NavBarItem> </NavBarItem>
<NavBarItem route-name="apps.map"> <!-- <NavBarItem route-name="apps.map">
<NavBarItemLabel :icon="mdiMapSearch" label="Map" size="22" is-hover-label-only route-name="apps.map" /> <NavBarItemLabel :icon="mdiMapSearch" label="Map" size="22" is-hover-label-only route-name="apps.map" />
</NavBarItem> </NavBarItem> -->
<!-- <NavBarItem> <!-- <NavBarItem>
<NavBarSearch /> <NavBarSearch />
</NavBarItem> --> </NavBarItem> -->
@ -122,10 +122,10 @@ const showAbout = async () => {
<BaseIcon :path="isMenuNavBarActive ? mdiClose : mdiDotsVertical" size="24" /> <BaseIcon :path="isMenuNavBarActive ? mdiClose : mdiDotsVertical" size="24" />
</NavBarItem> </NavBarItem>
</div> </div>
<div class="absolute w-screen top-14 left-0 bg-gray-50 shadow lg:w-auto lg:items-stretch lg:flex lg:grow lg:static lg:border-b-0 lg:overflow-visible lg:shadow-none dark:bg-slate-800" <div class="fixed w-screen top-14 left-0 shadow lg:w-auto lg:items-stretch lg:flex lg:grow lg:static lg:border-b-0 lg:overflow-visible lg:shadow-none dark:bg-slate-800"
:class="[isMenuNavBarActive ? 'block' : 'hidden']"> :class="[isMenuNavBarActive ? 'block' : 'hidden']">
<div <div
class="max-h-screen-menu overflow-y-auto lg:overflow-visible lg:flex lg:items-stretch lg:justify-end lg:ml-auto"> class="bg-white lg:bg-lime dark:bg-transparent max-h-screen-menu overflow-y-auto lg:overflow-visible lg:flex lg:items-stretch lg:justify-end lg:ml-auto">
<!-- help menu --> <!-- help menu -->
<NavBarMenu> <NavBarMenu>
@ -150,7 +150,7 @@ const showAbout = async () => {
<!-- personal menu --> <!-- personal menu -->
<NavBarMenu> <NavBarMenu>
<NavBarItemLabel v-bind:label="`hello ${user.login}`"> <NavBarItemLabel v-bind:label="`hello ${user.login}`">
<UserAvatarCurrentUser class="w-6 h-6 mr-3 inline-flex" /> <UserAvatarCurrentUser :user="user" class="w-6 h-6 mr-3 inline-flex" />
</NavBarItemLabel> </NavBarItemLabel>
<template #dropdown> <template #dropdown>
<!-- <NavBarItem> --> <!-- <NavBarItem> -->
@ -170,12 +170,9 @@ const showAbout = async () => {
<NavBarItem v-if="userHasRoles(['reviewer'])" :route-name="'reviewer.dataset.list'"> <NavBarItem v-if="userHasRoles(['reviewer'])" :route-name="'reviewer.dataset.list'">
<NavBarItemLabel :icon="mdiGlasses" label="Reviewer Menu" /> <NavBarItemLabel :icon="mdiGlasses" label="Reviewer Menu" />
</NavBarItem> </NavBarItem>
<!-- <NavBarItem> <!-- <NavBarItem @click="showAbout">
<NavBarItemLabel :icon="mdiEmail" label="Messages" />
</NavBarItem> -->
<NavBarItem @click="showAbout">
<NavBarItemLabel :icon="mdiInformationVariant" label="About" /> <NavBarItemLabel :icon="mdiInformationVariant" label="About" />
</NavBarItem> </NavBarItem> -->
<BaseDivider nav-bar /> <BaseDivider nav-bar />
<NavBarItem @click="logout"> <NavBarItem @click="logout">
<NavBarItemLabel :icon="mdiLogout" label="Log Out" /> <NavBarItemLabel :icon="mdiLogout" label="Log Out" />
@ -186,12 +183,15 @@ const showAbout = async () => {
<NavBarItem is-desktop-icon-only @click.prevent="toggleLightDark"> <NavBarItem is-desktop-icon-only @click.prevent="toggleLightDark">
<NavBarItemLabel v-bind:icon="mdiThemeLightDark" label="Light/Dark" is-desktop-icon-only /> <NavBarItemLabel v-bind:icon="mdiThemeLightDark" label="Light/Dark" is-desktop-icon-only />
</NavBarItem> </NavBarItem>
<NavBarItem href="https://gitea.geologie.ac.at/geolba/tethys" target="_blank" is-desktop-icon-only> <!-- <NavBarItem href="" target="_blank" is-desktop-icon-only>
<NavBarItemLabel v-bind:icon="mdiGithub" label="GitHub" is-desktop-icon-only /> <NavBarItemLabel v-bind:icon="mdiGithub" label="GitHub" is-desktop-icon-only />
</NavBarItem> -->
<NavBarItem href="/oai" target="_blank" is-desktop-icon-only>
<NavBarItemLabel v-bind:icon="mdiXml" label="OAI Interface" is-desktop-icon-only />
</NavBarItem> </NavBarItem>
<NavBarItem is-desktop-icon-only @click="showAbout"> <!-- <NavBarItem is-desktop-icon-only @click="showAbout">
<NavBarItemLabel v-bind:icon="mdiInformationVariant" label="About" is-desktop-icon-only /> <NavBarItemLabel v-bind:icon="mdiInformationVariant" label="About" is-desktop-icon-only />
</NavBarItem> </NavBarItem> -->
<NavBarItem is-desktop-icon-only @click="logout"> <NavBarItem is-desktop-icon-only @click="logout">
<NavBarItemLabel v-bind:icon="mdiLogout" label="Log out" is-desktop-icon-only /> <NavBarItemLabel v-bind:icon="mdiLogout" label="Log out" is-desktop-icon-only />
</NavBarItem> </NavBarItem>

View file

@ -1,4 +1,4 @@
<script setup> <script lang="ts" setup>
import { StyleService } from '@/Stores/style.service'; import { StyleService } from '@/Stores/style.service';
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'; import { computed, ref, onMounted, onBeforeUnmount } from 'vue';
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'; import { mdiChevronUp, mdiChevronDown } from '@mdi/js';
@ -15,10 +15,10 @@ const toggle = () => {
isDropdownActive.value = !isDropdownActive.value; isDropdownActive.value = !isDropdownActive.value;
}; };
const root = ref(null); const root = ref(NavBarItem);
const forceClose = (event) => { const forceClose = (event: MouseEvent) => {
if (!root.value.$el.contains(event.target)) { if (!root.value?.$el.contains(event.target)) {
isDropdownActive.value = false; isDropdownActive.value = false;
} }
}; };

View file

@ -28,7 +28,7 @@
autocomplete="off" autocomplete="off"
@keydown.down="onArrowDown" @keydown.down="onArrowDown"
@keydown.up="onArrowUp" @keydown.up="onArrowUp"
@keydown.enter="onEnter" @keydown.enter.prevent="onEnter"
/> />
<svg <svg
class="w-4 h-4 absolute left-2.5 top-3.5" class="w-4 h-4 absolute left-2.5 top-3.5"

View file

@ -5,7 +5,7 @@
<div class="relative" data-te-dropdown-ref> <div class="relative" data-te-dropdown-ref>
<button id="states-button" data-dropdown-toggle="dropdown-states" <button id="states-button" data-dropdown-toggle="dropdown-states"
class="whitespace-nowrap h-12 z-10 inline-flex items-center py-2.5 px-4 text-sm font-medium text-center text-gray-500 bg-gray-100 border border-gray-300 rounded-l-lg hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 dark:focus:ring-gray-700 dark:text-white dark:border-gray-600" class="whitespace-nowrap h-12 z-10 inline-flex items-center py-2.5 px-4 text-sm font-medium text-center text-gray-500 bg-gray-100 border border-gray-300 rounded-l-lg hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 dark:focus:ring-gray-700 dark:text-white dark:border-gray-600"
type="button" @click.prevent="showStates"> type="button" :disabled="isReadOnly" @click.prevent="showStates">
<!-- <svg aria-hidden="true" class="h-3 mr-2" viewBox="0 0 15 12" fill="none" xmlns="http://www.w3.org/2000/svg"> <!-- <svg aria-hidden="true" class="h-3 mr-2" viewBox="0 0 15 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" width="14" height="12" rx="2" fill="white" /> <rect x="0.5" width="14" height="12" rx="2" fill="white" />
<mask id="mask0_12694_49953" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="15" height="12"> <mask id="mask0_12694_49953" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="15" height="12">
@ -65,7 +65,7 @@
</svg> --> </svg> -->
<!-- eng --> <!-- eng -->
{{ language }} {{ language }}
<svg aria-hidden="true" class="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20" <svg aria-hidden="true" class="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20" v-if="!isReadOnly"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" <path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
@ -93,7 +93,7 @@
<!-- :class="inputElClass" --> <!-- :class="inputElClass" -->
<!-- class="block p-2.5 w-full z-20 text-sm text-gray-900 bg-gray-50 rounded-r-lg border-l-gray-50 border-l-2 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-l-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500" --> <!-- class="block p-2.5 w-full z-20 text-sm text-gray-900 bg-gray-50 rounded-r-lg border-l-gray-50 border-l-2 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-l-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500" -->
<input v-model="computedValue" type="text" :name="props.name" autocomplete="off" :class="inputElClass" <input v-model="computedValue" type="text" :name="props.name" autocomplete="off" :class="inputElClass"
placeholder="Search Keywords..." required @input="handleInput" /> placeholder="Search Keywords..." required @input="handleInput" :readonly="isReadOnly" />
<!-- v-model="data.search" --> <!-- v-model="data.search" -->
<svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="computedValue.length < 2" <svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="computedValue.length < 2"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -101,7 +101,7 @@
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
<svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="computedValue.length >= 2" <svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="computedValue.length >= 2 && !isReadOnly"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click="() => { xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click="() => {
computedValue = ''; computedValue = '';
data.isOpen = false; data.isOpen = false;
@ -166,6 +166,10 @@ let props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
isReadOnly: {
type: Boolean,
default: false,
},
required: Boolean, required: Boolean,
borderless: Boolean, borderless: Boolean,
transparent: Boolean, transparent: Boolean,
@ -190,11 +194,18 @@ const inputElClass = computed(() => {
'dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500', 'dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500',
'h-12', 'h-12',
props.borderless ? 'border-0' : 'border', props.borderless ? 'border-0' : 'border',
props.transparent ? 'bg-transparent' : 'bg-white dark:bg-slate-800', props.transparent && 'bg-transparent',
// props.isReadOnly ? 'bg-gray-50 dark:bg-slate-600' : 'bg-white dark:bg-slate-800', // props.isReadOnly ? 'bg-gray-50 dark:bg-slate-600' : 'bg-white dark:bg-slate-800',
]; ];
// if (props.icon) { // if (props.icon) {
base.push('pl-10'); base.push('pl-10');
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');
}
// } // }
return base; return base;
}); });

View file

@ -5,9 +5,9 @@ import SectionBanner from '@/Components/SectionBanner.vue';
</script> </script>
<template> <template>
<SectionBanner bg="greenBlue"> <SectionBanner bg="greenBlue">
<h1 class="text-3xl text-white mb-6">Like the project? Please star on <b>Gitea</b>!</h1> <h1 class="text-3xl text-white mb-6">Like the project? Please star on <b>GeoSphere Git Repository</b>!</h1>
<div> <div>
<BaseButton href="https://gitea.geologie.ac.at/geolba/tethys" :icon="mdiGithub" label="Gitea" target="_blank" rounded-full /> <BaseButton href="https://gitea.geosphere.at/geolba/tethys.backend" :icon="mdiGithub" label="Forgejo" target="_blank" rounded-full />
</div> </div>
</SectionBanner> </SectionBanner>
</template> </template>

View file

@ -15,6 +15,10 @@ defineProps({
required: true, required: true,
}, },
main: Boolean, main: Boolean,
showCogButton: {
type: Boolean,
default: false,
}
}); });
const hasSlot = computed(() => useSlots().default); const hasSlot = computed(() => useSlots().default);
@ -30,6 +34,6 @@ const hasSlot = computed(() => useSlots().default);
</h1> </h1>
</div> </div>
<slot v-if="hasSlot" /> <slot v-if="hasSlot" />
<BaseButton v-else :icon="mdiCog" small /> <BaseButton v-else-if="showCogButton" :icon="mdiCog" small />
</section> </section>
</template> </template>

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