From be34474a84f929a8f777a2e74920bc235a56cae0 Mon Sep 17 00:00:00 2001 From: Jay Cammarano <67079013+jaycammarano@users.noreply.github.com> Date: Thu, 12 Aug 2021 18:46:18 -0400 Subject: [PATCH] Aggregate resolvers added to GraphQL options (#7373) * Don't use tags interface for CSV filter (#7258) Fixes #6778 * Rely on `RETURNING` when possible (#7259) * WIP use returning clause instead of max from id * Use returning where applicable, fallback to fetch Fixes #6279 * update dependency p-queue to v7 (#7255) Co-authored-by: Renovate Bot * update dependency @vitejs/plugin-vue to v1.4.0 (#7263) Co-authored-by: Renovate Bot * Move p-queue to app dev dependencies (#7273) * Log error message when registering app extension fails (#7274) * update dependency rollup to v2.56.1 (#7269) Co-authored-by: Renovate Bot * update dependency vue-router to v4.0.11 (#7272) Co-authored-by: Renovate Bot * update dependency ts-node to v10.2.0 (#7271) Co-authored-by: Renovate Bot * Only loads app extensions if SERVE_APP is true (#7275) This also ensures API/App only load their respective extensions in dev. * Fix gitignore file in extension templates being deleted when publishing (#7279) * New Crowdin updates (#7260) * New translations en-US.yaml (Spanish) * New translations en-US.yaml (Spanish) * New translations en-US.yaml (Russian) * New translations en-US.yaml (Russian) * New translations en-US.yaml (Russian) * New translations en-US.yaml (Russian) * update typescript-eslint monorepo to v4.29.1 (#7283) Co-authored-by: Renovate Bot * Only treat `tinyint(1)` and `tinyint(0)` as booleans (#7287) * added an if catch for tinyint(1) and tinyint(0) * made suggested changes toLowerCase() * update dependency @vue/compiler-sfc to v3.2.0 (#7288) Co-authored-by: Renovate Bot * update dependency vue to v3.2.0 (#7289) Co-authored-by: Renovate Bot * Handle JSON in labels display (#7292) Fixes #7278 * update dependency pinia to v2.0.0-rc.3 (#7055) Co-authored-by: Renovate Bot * update vue monorepo to v3.2.1 (#7293) Co-authored-by: Renovate Bot * Flush caches on server (re)start (#7294) * v9.0.0-rc.89 * Update package-lock * Update release script To workaround breaking change in npm patch :tada: * Update changelog * update dependency pinia to v2.0.0-rc.4 (#7297) Co-authored-by: Renovate Bot * update dependency rollup to v2.56.2 (#7303) Co-authored-by: Renovate Bot * Fix HTTP method for collections.createMany in SDK (#7304) * Fix HTTP method for collections.createMany in SDK * Post collections in data body Co-authored-by: rijkvanzanten * Add perm check for sqlite, upload, extensions dirs (#7310) Co-authored-by: Rijk van Zanten * update dependency eslint-plugin-vue to v7.16.0 (#7300) Co-authored-by: Renovate Bot * Fix uuid resolving in SQLite (#7312) Fixes #7306 * Clear the file payload after file upload (#7315) Fixes #7305 * Improve type checking * Mention TELEMETRY environment variable in docs (#7317) * Mention TELEMETRY environment variable in docs * Add clarification Co-authored-by: rijkvanzanten * Import access from fs-extra instead of fs/promises * Resolve sorting in list-o2m-tree-view on dnd * Fix graphql GET request cache query extraction (#7319) Fixes #7298 * Check for related collection before creation relation (#7323) Fixes #7302 * Fix colors on different types (#7322) Co-authored-by: Rijk van Zanten * group is working on aggregate resolver * Check for non-existing parent pk records (#7331) Fixes #7330 * Schema field types are not translated in the app (#7327) * Fix field type label translations * Use translate-object-values util Co-authored-by: rijkvanzanten * Update release script * Add import ref for TS * Tweak, hopefully fix release flow * getAggregateQuery * clean up payload Co-authored-by: Rijk van Zanten * Treat alias-only fields properly * Add missing translations (#7358) * v9.0.0-rc.90 * Update changelog.md * update dependency nanoid to v3.1.24 (#7365) Co-authored-by: Renovate Bot * update dependency supertest to v6.1.5 (#7360) Co-authored-by: Renovate Bot * update vue monorepo to v3.2.2 (#7355) Co-authored-by: Renovate Bot * filters working avg{id} format with number fields * Fix english string after #7358 (#7371) Fixed wrong string in en-US after #7358 PR * group field working * update dependency nanoid to v3.1.25 (#7375) Co-authored-by: Renovate Bot * update dependency directory-tree to v2.3.0 (#7376) Co-authored-by: Renovate Bot * Export Collection button now shows collection name not table name (#7379) * export collection button to uses name not db name * removed unused var * fixed for review * computed collectionName * Add support for Geometry type, add Map Layout & Interface (#5684) * Added map layout * Cleanup and bug fixes * Removed package-lock * Cleanup and fixes * Small fix * Added back package-lock * Saved camera, autofitting option, bug fixes * Refactor and ui improvements * Improvements * Added seled mode * Removed unused dependency * Changed selection behaviour, cleanup. * update import and dependencies * make custom style into drawer * remove unused imports * use lodash functions * add popups * allow header to become small * reorganize settings * add styling to popup * change default template * add projection option * add basic map interface * finish simple map * add mapbox style * support more mapbox layouts * add api key option * add mapbox backgrounds to layout * warn when no api key is set * fix for latest version * Improved map layout and interface, bug fixes, refactoring. . . * Added postgis geometry format, added marker icon shadow * Made map buttons bigger and their icons thinner. Added transition to header bar. * Bug fixes and error handling in map interface. * Moved box-select control out of the map component. Removed material icons sprite and use addImage for marker support. * Handle MultiGeometry -> Geometry interface error. * Removed hardcoded styles. Added migrations for basemap column. Lots of refactoring. Removed hardcoded styles. Added migrations for basemap column. Lots of refactoring. * Fixed style reloading error. Added translations. * Moved worker code to lib. * Removed worker code. Prevent Mapbox from removing access_token from the URL. * Refactoring. * Change basemap selection to in-map dropdown for layout and interface. * Touchscreen selection support and small fixes. * Small change. * Fixed unused imports. * Added support for PostgreSQL identity column * Renamed migration. Added crs translation. * Only show fields using the map interface in the map layout. * Removed logging. * Reverted Dockerfile change. * Improved crs support. * Fixed translations. * Check for schema identity before updating it. * Fixed popup not updating on feature hover. * Added feature hover styling. Fixed layer customization input. Added out of bounds error handling. * Added geometry type and support for database native geometries. * Fixed linting. * Fixed layout. * Fixed layout. * Actually fixed linting * Full support for native geometries Fixed basemap input Improved feature popup on hover Locked interfaced support * Fixed geometryType option not updating * Bug fixes in interface * Fixed crash when empty basemap settings. Fixed fitBounds option not updating. * Added back storage type option. Improved interface behaviour. * Dropped wkb because of vendor inconsistency with binary data * Updated layout to match new geometry type. Fixed geojson payload transform. * Added missing geometry_format attributes to local types. * Fixed typos & refactoring * Removed dependency on proj4 * Fix error when empty map interface options * Set geometry SRID to 4326 when inserting into the database * Add support for selectMode * Fix error on initial source load * Added geocoder, use GeoJSON for api i/o, removed geometry_format option, refactoring * Added geometry intersects filter. Created geometry helper class. * Fix error when null geometryOptions, added mapbox_key setting. * Moved all geometry parsing/serializing into processGeometries in `payload.ts`. Fixed type errors. * Migrate to Vue 3 * Use wellknown instead of wkx * Fixed basemap selection. * Added available operator for geometry type * Added nintersects filter, fixed map interface for filter input * Added intersects_bbox filter & bug fixes. * Fixed icons rendering * Fixed cursor icon in select mode * Added geometry aggregate function * Fixed geometry processing bug when imported from relational field. * Fixed error with geocoder instanciation * Removed @types/maplibre-gl dependency * Removed fitViewToData options * Merge remote-tracking branch 'upstream/main' into map-layout * Fixed style and geometryType in map interface options * Fixed style change on map interface. * Improved fitViewToData behaviour * Fixed type imports and previous merge conflict * Fixed linting * Added available operators * Fix and merge migrations * Remove outdated p-queue dep * Fix get-schema column extract * Replace pg with postgis for local debugging * Re-add missing import * Add mapbox as a basemap when key exists * Remove unused tz flag * Process delta in payloadservice * Set default map, add limit number styling * Default display template to just PK * Tweak styling of error dialog * Fix method usage in helpers * Move sdo_geo to oracle section * Remove extensions from ts config exclude * Move geo types to shared, remove _Geometry * Remove unused type * Tiny Tweaks * Remove fit to bounds option in favor of on * Validate incoming intersects query * Deepmap filter values * Add GraphQL support * No defaultValue for geometryType * Resolve c * Fix translations Co-authored-by: Nitwel Co-authored-by: Rijk van Zanten * New Crowdin updates (#7359) * New translations en-US.yaml (Estonian) * New translations en-US.yaml (Ukrainian) * New translations en-US.yaml (Norwegian) * New translations en-US.yaml (Polish) * New translations en-US.yaml (Portuguese) * New translations en-US.yaml (Russian) * New translations en-US.yaml (Serbian (Cyrillic)) * New translations en-US.yaml (Swedish) * New translations en-US.yaml (Turkish) * New translations en-US.yaml (Chinese Traditional) * New translations en-US.yaml (Portuguese, Brazilian) * New translations en-US.yaml (Indonesian) * New translations en-US.yaml (Spanish, Chile) * New translations en-US.yaml (Thai) * New translations en-US.yaml (Hindi) * New translations en-US.yaml (Malay) * New translations en-US.yaml (Serbian (Latin)) * New translations en-US.yaml (Dutch) * New translations en-US.yaml (Italian) * New translations en-US.yaml (Afrikaans) * New translations en-US.yaml (Lithuanian) * New translations en-US.yaml (Spanish, Latin America) * New translations en-US.yaml (Slovenian) * New translations en-US.yaml (Vietnamese) * New translations en-US.yaml (Chinese Simplified) * New translations en-US.yaml (Bulgarian) * New translations en-US.yaml (Romanian) * New translations en-US.yaml (French) * New translations en-US.yaml (Spanish) * New translations en-US.yaml (Arabic) * New translations en-US.yaml (Georgian) * New translations en-US.yaml (Catalan) * New translations en-US.yaml (Czech) * New translations en-US.yaml (Danish) * New translations en-US.yaml (German) * New translations en-US.yaml (Greek) * New translations en-US.yaml (Finnish) * New translations en-US.yaml (Hebrew) * New translations en-US.yaml (Hungarian) * New translations en-US.yaml (Japanese) * Update source file en-US.yaml * New translations en-US.yaml (Italian) * New translations en-US.yaml (Slovenian) * New translations en-US.yaml (Estonian) * New translations en-US.yaml (Estonian) * New translations en-US.yaml (Sinhala) * New translations en-US.yaml (Russian) * New translations en-US.yaml (Russian) * New translations en-US.yaml (Bulgarian) * Update source file en-US.yaml * New translations en-US.yaml (Estonian) * New translations en-US.yaml (Norwegian) * New translations en-US.yaml (Polish) * New translations en-US.yaml (Portuguese) * New translations en-US.yaml (Russian) * New translations en-US.yaml (Swedish) * New translations en-US.yaml (Turkish) * New translations en-US.yaml (Portuguese, Brazilian) * New translations en-US.yaml (Spanish, Chile) * New translations en-US.yaml (Thai) * New translations en-US.yaml (Serbian (Latin)) * New translations en-US.yaml (Dutch) * New translations en-US.yaml (Lithuanian) * New translations en-US.yaml (Spanish, Latin America) * New translations en-US.yaml (Vietnamese) * New translations en-US.yaml (Chinese Simplified) * New translations en-US.yaml (Bulgarian) * New translations en-US.yaml (French) * New translations en-US.yaml (Spanish) * New translations en-US.yaml (Arabic) * New translations en-US.yaml (German) * New translations en-US.yaml (Finnish) * New translations en-US.yaml (Hungarian) * update dependency directory-tree to v2.3.1 (#7380) Co-authored-by: Renovate Bot * pin dependencies (#7384) Co-authored-by: Renovate Bot * update dependency macos-release to v3 (#7381) * update dependency macos-release to v3 * Update package-lock Co-authored-by: Renovate Bot Co-authored-by: rijkvanzanten * New Crowdin updates (#7386) * Update source file en-US.yaml * New translations en-US.yaml (Estonian) * New translations en-US.yaml (Polish) * New translations en-US.yaml (Portuguese) * New translations en-US.yaml (Russian) * New translations en-US.yaml (Swedish) * New translations en-US.yaml (Turkish) * New translations en-US.yaml (Chinese Traditional) * New translations en-US.yaml (Portuguese, Brazilian) * New translations en-US.yaml (Indonesian) * New translations en-US.yaml (Spanish, Chile) * New translations en-US.yaml (Thai) * New translations en-US.yaml (Serbian (Latin)) * New translations en-US.yaml (Dutch) * New translations en-US.yaml (Italian) * New translations en-US.yaml (Lithuanian) * New translations en-US.yaml (Spanish, Latin America) * New translations en-US.yaml (Slovenian) * New translations en-US.yaml (Vietnamese) * New translations en-US.yaml (Chinese Simplified) * New translations en-US.yaml (Bulgarian) * New translations en-US.yaml (French) * New translations en-US.yaml (Spanish) * New translations en-US.yaml (Arabic) * New translations en-US.yaml (German) * New translations en-US.yaml (Finnish) * New translations en-US.yaml (Hungarian) * Revert "update dependency macos-release to v3 (#7381)" (#7389) This reverts commit ca111a80cb037091aa1203f6f5663dd9c4292245. * update dependency npm to v7.20.6 (#7387) Co-authored-by: Renovate Bot * Fix flat lock number * Small tweaks, fix type bug Co-authored-by: Rijk van Zanten Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Renovate Bot Co-authored-by: Nicola Krumschmidt Co-authored-by: Pascal Jufer Co-authored-by: Adrian Dimitrov Co-authored-by: Oreille <33065839+Oreilles@users.noreply.github.com> Co-authored-by: Nitwel --- .github/workflows/release.yml | 6 + api/package.json | 28 +- api/src/app.ts | 9 +- api/src/cache.ts | 6 + api/src/controllers/files.ts | 5 +- api/src/controllers/utils.ts | 7 +- api/src/database/helpers/geometry.ts | 163 ++ api/src/database/index.ts | 5 +- .../20210811A-add-geometry-config.ts | 15 + api/src/database/run-ast.ts | 20 +- api/src/database/seeds/run.ts | 6 +- .../database/system-data/fields/settings.yaml | 79 +- .../database/system-data/fields/users.yaml | 22 +- .../database/system-data/fields/webhooks.yaml | 26 +- api/src/extensions.ts | 23 +- api/src/services/collections.ts | 4 +- api/src/services/fields.ts | 31 +- api/src/services/graphql.ts | 197 +- api/src/services/items.ts | 55 +- api/src/services/payload.ts | 78 +- api/src/services/relations.ts | 4 + api/src/services/specifications.ts | 3 + api/src/types/query.ts | 16 - api/src/types/schema.ts | 28 +- api/src/utils/apply-query.ts | 39 +- api/src/utils/geometry.ts | 18 + api/src/utils/get-cache-key.ts | 2 +- api/src/utils/get-default-value.ts | 2 +- api/src/utils/get-graphql-type.ts | 4 +- api/src/utils/get-local-type.ts | 58 +- api/src/utils/get-schema.ts | 37 +- api/src/utils/is-url-allowed.ts | 1 + api/src/utils/sanitize-query.ts | 14 +- api/src/utils/validate-query.ts | 24 +- api/src/utils/validate-storage.ts | 31 + app/package.json | 38 +- .../v-form/form-field-interface.vue | 1 + app/src/displays/labels/labels.vue | 16 +- app/src/displays/register.ts | 4 +- app/src/hydrate.ts | 3 + app/src/interfaces/file/file.vue | 2 +- .../group-accordion/accordion-section.vue | 2 +- app/src/interfaces/input-code/index.ts | 2 +- app/src/interfaces/input/index.ts | 2 +- .../list-o2m-tree-view/list-o2m-tree-view.vue | 4 +- app/src/interfaces/list/options.vue | 3 +- app/src/interfaces/map/index.ts | 14 + app/src/interfaces/map/map.vue | 449 ++++ app/src/interfaces/map/options.vue | 132 + app/src/interfaces/map/style.ts | 219 ++ .../presentation-links/presentation-links.vue | 29 +- app/src/interfaces/register.ts | 4 +- app/src/lang/translations/af-ZA.yaml | 2 + app/src/lang/translations/ar-SA.yaml | 6 + app/src/lang/translations/bg-BG.yaml | 48 + app/src/lang/translations/ca-ES.yaml | 2 + app/src/lang/translations/cs-CZ.yaml | 3 + app/src/lang/translations/da-DK.yaml | 3 + app/src/lang/translations/de-DE.yaml | 6 + app/src/lang/translations/el-GR.yaml | 3 + app/src/lang/translations/en-US.yaml | 27 + app/src/lang/translations/es-419.yaml | 59 + app/src/lang/translations/es-CL.yaml | 58 + app/src/lang/translations/es-ES.yaml | 61 + app/src/lang/translations/et-EE.yaml | 11 +- app/src/lang/translations/fi-FI.yaml | 6 + app/src/lang/translations/fr-FR.yaml | 6 + app/src/lang/translations/he-IL.yaml | 2 + app/src/lang/translations/hi-IN.yaml | 2 + app/src/lang/translations/hu-HU.yaml | 6 + app/src/lang/translations/id-ID.yaml | 5 + app/src/lang/translations/it-IT.yaml | 33 +- app/src/lang/translations/ja-JP.yaml | 3 + app/src/lang/translations/ka-GE.yaml | 2 + app/src/lang/translations/lt-LT.yaml | 6 + app/src/lang/translations/ms-MY.yaml | 3 + app/src/lang/translations/nl-NL.yaml | 6 + app/src/lang/translations/no-NO.yaml | 4 + app/src/lang/translations/pl-PL.yaml | 6 + app/src/lang/translations/pt-BR.yaml | 6 + app/src/lang/translations/pt-PT.yaml | 6 + app/src/lang/translations/ro-RO.yaml | 2 + app/src/lang/translations/ru-RU.yaml | 79 +- app/src/lang/translations/si-LK.yaml | 1 + app/src/lang/translations/sl-SI.yaml | 45 + app/src/lang/translations/sr-CS.yaml | 6 + app/src/lang/translations/sr-SP.yaml | 3 + app/src/lang/translations/sv-SE.yaml | 6 + app/src/lang/translations/th-TH.yaml | 6 + app/src/lang/translations/tr-TR.yaml | 6 + app/src/lang/translations/uk-UA.yaml | 3 + app/src/lang/translations/vi-VN.yaml | 6 + app/src/lang/translations/zh-CN.yaml | 6 + app/src/lang/translations/zh-TW.yaml | 5 + app/src/layouts/map/actions.vue | 46 + app/src/layouts/map/components/map.vue | 511 ++++ app/src/layouts/map/index.ts | 363 +++ app/src/layouts/map/map.vue | 255 ++ app/src/layouts/map/options.vue | 110 + app/src/layouts/map/sidebar.vue | 24 + app/src/layouts/map/style.ts | 81 + app/src/layouts/register.ts | 4 +- .../modules/collections/routes/collection.vue | 11 +- app/src/modules/register.ts | 4 +- .../field-detail/components/schema.vue | 65 +- .../routes/data-model/field-detail/store.ts | 5 + .../fields/components/fields-management.vue | 3 +- app/src/stores/app.ts | 1 + app/src/utils/geometry/basemap.ts | 113 + app/src/utils/geometry/controls.ts | 193 ++ app/src/utils/geometry/index.ts | 130 + app/src/utils/get-default-display-for-type.ts | 1 + .../utils/get-default-interface-for-type.ts | 1 + app/src/utils/get-setting.ts | 7 + app/src/utils/get-theme.ts | 17 + app/src/utils/render-string-template.ts | 18 +- .../export-sidebar-detail.vue | 10 +- .../filter-sidebar-detail/filter-input.vue | 4 + .../get-available-operators-for-type.ts | 6 + .../components/header-bar/header-bar.vue | 18 +- app/src/views/private/private-view.vue | 5 + app/vite.config.js | 8 +- changelog.md | 194 ++ docker-compose.yml | 2 +- docs/package.json | 4 +- docs/reference/environment-variables.md | 12 +- lerna.json | 2 +- package-lock.json | 2348 ++++++++++++++--- package.json | 14 +- packages/cli/package.json | 8 +- packages/create-directus-project/package.json | 2 +- packages/drive-azure/package.json | 4 +- packages/drive-gcs/package.json | 4 +- packages/drive-s3/package.json | 4 +- packages/drive/package.json | 2 +- packages/extension-sdk/package.json | 4 +- .../extension-sdk/src/cli/commands/create.ts | 2 + .../extension-sdk/src/cli/utils/rename-map.ts | 20 + packages/extension-sdk/templates/common/.keep | 0 .../common/{.gitignore => _gitignore} | 0 packages/format-title/package.json | 4 +- packages/gatsby-source-directus/package.json | 2 +- packages/schema/package.json | 2 +- packages/schema/src/dialects/postgres.ts | 143 +- packages/sdk/package.json | 6 +- packages/sdk/src/handlers/collections.ts | 4 +- packages/shared/package.json | 9 +- packages/shared/src/constants/extensions.ts | 6 +- packages/shared/src/constants/fields.ts | 12 + packages/shared/src/types/extensions.ts | 7 +- packages/shared/src/types/fields.ts | 11 +- packages/shared/src/types/filter.ts | 10 +- packages/shared/src/types/geometry.ts | 29 + packages/shared/src/types/index.ts | 1 + .../utils/get-filter-operators-for-type.ts | 4 + .../src/utils/node/ensure-extension-dirs.ts | 6 +- .../shared/src/utils/node/get-extensions.ts | 70 +- packages/specs/package.json | 2 +- 158 files changed, 6696 insertions(+), 827 deletions(-) create mode 100644 api/src/database/helpers/geometry.ts create mode 100644 api/src/database/migrations/20210811A-add-geometry-config.ts create mode 100644 api/src/utils/geometry.ts create mode 100644 api/src/utils/validate-storage.ts create mode 100644 app/src/interfaces/map/index.ts create mode 100644 app/src/interfaces/map/map.vue create mode 100644 app/src/interfaces/map/options.vue create mode 100644 app/src/interfaces/map/style.ts create mode 100644 app/src/layouts/map/actions.vue create mode 100644 app/src/layouts/map/components/map.vue create mode 100644 app/src/layouts/map/index.ts create mode 100644 app/src/layouts/map/map.vue create mode 100644 app/src/layouts/map/options.vue create mode 100644 app/src/layouts/map/sidebar.vue create mode 100644 app/src/layouts/map/style.ts create mode 100644 app/src/utils/geometry/basemap.ts create mode 100644 app/src/utils/geometry/controls.ts create mode 100644 app/src/utils/geometry/index.ts create mode 100644 app/src/utils/get-setting.ts create mode 100644 app/src/utils/get-theme.ts create mode 100644 packages/extension-sdk/src/cli/utils/rename-map.ts delete mode 100644 packages/extension-sdk/templates/common/.keep rename packages/extension-sdk/templates/common/{.gitignore => _gitignore} (100%) create mode 100644 packages/shared/src/types/geometry.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e623131c5b1aa..f11d0d85c85d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,9 @@ jobs: with: node-version: '16.x' + # See https://github.com/npm/cli/issues/3637 + - run: npm i -g npm@7.20.2 + - uses: c-hive/gha-npm-cache@v1 - run: npm ci - run: npm run build @@ -65,6 +68,9 @@ jobs: node-version: '16.x' registry-url: 'https://registry.npmjs.org' + # See https://github.com/npm/cli/issues/3637 + - run: npm i -g npm@7.20.2 + - run: npm ci - run: npx lerna publish from-git --no-verify-access --yes diff --git a/api/package.json b/api/package.json index cb365eb377db1..678995363a1c3 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "directus", - "version": "9.0.0-rc.88", + "version": "9.0.0-rc.90", "license": "GPL-3.0-only", "homepage": "https://github.com/directus/directus#readme", "description": "Directus is a real-time API and App dashboard for managing SQL database content.", @@ -68,15 +68,15 @@ "example.env" ], "dependencies": { - "@directus/app": "9.0.0-rc.88", - "@directus/drive": "9.0.0-rc.88", - "@directus/drive-azure": "9.0.0-rc.88", - "@directus/drive-gcs": "9.0.0-rc.88", - "@directus/drive-s3": "9.0.0-rc.88", - "@directus/format-title": "9.0.0-rc.88", - "@directus/schema": "9.0.0-rc.88", - "@directus/shared": "9.0.0-rc.88", - "@directus/specs": "9.0.0-rc.88", + "@directus/app": "9.0.0-rc.90", + "@directus/drive": "9.0.0-rc.90", + "@directus/drive-azure": "9.0.0-rc.90", + "@directus/drive-gcs": "9.0.0-rc.90", + "@directus/drive-s3": "9.0.0-rc.90", + "@directus/format-title": "9.0.0-rc.90", + "@directus/schema": "9.0.0-rc.90", + "@directus/shared": "9.0.0-rc.90", + "@directus/specs": "9.0.0-rc.90", "@godaddy/terminus": "^4.9.0", "@rollup/plugin-alias": "^3.1.2", "@rollup/plugin-virtual": "^2.0.3", @@ -92,7 +92,7 @@ "cookie-parser": "^1.4.5", "cors": "^2.8.5", "csv-parser": "^3.0.0", - "date-fns": "^2.21.1", + "date-fns": "^2.22.1", "deep-map": "^2.0.0", "destroy": "^1.0.4", "dotenv": "^10.0.0", @@ -101,6 +101,7 @@ "exifr": "^7.1.2", "express": "^4.17.1", "express-session": "^1.17.2", + "flat": "^5.0.2", "fs-extra": "^10.0.0", "grant": "^5.4.14", "graphql": "^15.5.0", @@ -139,7 +140,8 @@ "stream-json": "^1.7.1", "update-check": "^1.5.4", "uuid": "^8.3.2", - "uuid-validate": "0.0.3" + "uuid-validate": "0.0.3", + "wellknown": "^0.5.0" }, "optionalDependencies": { "@keyv/redis": "^2.1.2", @@ -167,6 +169,7 @@ "@types/express": "4.17.13", "@types/express-pino-logger": "4.0.2", "@types/express-session": "1.17.4", + "@types/flat": "^5.0.2", "@types/fs-extra": "9.0.12", "@types/inquirer": "7.3.3", "@types/js-yaml": "4.0.2", @@ -185,6 +188,7 @@ "@types/stream-json": "1.7.1", "@types/uuid": "8.3.1", "@types/uuid-validate": "0.0.1", + "@types/wellknown": "0.5.1", "copyfiles": "2.4.1", "cross-env": "7.0.3", "ts-node-dev": "1.1.8", diff --git a/api/src/app.ts b/api/src/app.ts index 3f05840818c51..b902c987ae753 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -41,8 +41,11 @@ import sanitizeQuery from './middleware/sanitize-query'; import schema from './middleware/schema'; import { track } from './utils/track'; import { validateEnv } from './utils/validate-env'; +import { validateStorage } from './utils/validate-storage'; import { register as registerWebhooks } from './webhooks'; import { session } from './middleware/session'; +import { flushCaches } from './cache'; +import { URL } from 'url'; export default async function createApp(): Promise { validateEnv(['KEY', 'SECRET']); @@ -53,6 +56,8 @@ export default async function createApp(): Promise { logger.warn('PUBLIC_URL is not a valid URL'); } + await validateStorage(); + await validateDBConnection(); if ((await isInstalled()) === false) { @@ -64,6 +69,8 @@ export default async function createApp(): Promise { logger.warn(`Database migrations have not all been run`); } + await flushCaches(); + await initializeExtensions(); registerExtensionHooks(); @@ -171,7 +178,7 @@ export default async function createApp(): Promise { app.use('/relations', relationsRouter); app.use('/revisions', revisionsRouter); app.use('/roles', rolesRouter); - app.use('/server/', serverRouter); + app.use('/server', serverRouter); app.use('/settings', settingsRouter); app.use('/users', usersRouter); app.use('/utils', utilsRouter); diff --git a/api/src/cache.ts b/api/src/cache.ts index f19a92d9e1751..813fc9a7da05a 100644 --- a/api/src/cache.ts +++ b/api/src/cache.ts @@ -23,6 +23,12 @@ export function getCache(): { cache: Keyv | null; schemaCache: Keyv | null } { return { cache, schemaCache }; } +export async function flushCaches(): Promise { + const { schemaCache, cache } = getCache(); + await schemaCache?.clear(); + await cache?.clear(); +} + function getKeyvInstance(ttl: number | undefined): Keyv { switch (env.CACHE_STORE) { case 'redis': diff --git a/api/src/controllers/files.ts b/api/src/controllers/files.ts index 3538a94937d66..78ae4077dc562 100644 --- a/api/src/controllers/files.ts +++ b/api/src/controllers/files.ts @@ -33,7 +33,7 @@ const multipartHandler = asyncHandler(async (req, res, next) => { */ let disk: string = toArray(env.STORAGE_LOCATIONS)[0]; - const payload: Partial = {}; + let payload: Partial = {}; let fileCount = 0; busboy.on('field', (fieldname: keyof File, val) => { @@ -70,6 +70,9 @@ const multipartHandler = asyncHandler(async (req, res, next) => { storage: payload.storage || disk, }; + // Clear the payload for the next to-be-uploaded file + payload = {}; + try { const primaryKey = await service.uploadOne(fileStream, payloadWithRequiredFields, existingPrimaryKey); savedFiles.push(primaryKey); diff --git a/api/src/controllers/utils.ts b/api/src/controllers/utils.ts index a4414aebef084..ec64b628fd51f 100644 --- a/api/src/controllers/utils.ts +++ b/api/src/controllers/utils.ts @@ -8,7 +8,7 @@ import { respond } from '../middleware/respond'; import { RevisionsService, UtilsService, ImportService } from '../services'; import asyncHandler from '../utils/async-handler'; import Busboy from 'busboy'; -import { getCache } from '../cache'; +import { flushCaches } from '../cache'; const router = Router(); @@ -123,10 +123,7 @@ router.post( throw new ForbiddenException(); } - const { cache, schemaCache } = getCache(); - - await cache?.clear(); - await schemaCache?.clear(); + await flushCaches(); res.status(200).end(); }) diff --git a/api/src/database/helpers/geometry.ts b/api/src/database/helpers/geometry.ts new file mode 100644 index 0000000000000..c3dcc3e84ab2d --- /dev/null +++ b/api/src/database/helpers/geometry.ts @@ -0,0 +1,163 @@ +import { Field, RawField } from '@directus/shared/types'; +import { Knex } from 'knex'; +import { stringify as geojsonToWKT, GeoJSONGeometry } from 'wellknown'; +import getDatabase from '..'; + +let geometryHelper: KnexSpatial | undefined; + +export function getGeometryHelper(): KnexSpatial { + if (!geometryHelper) { + const db = getDatabase(); + const client = db.client.config.client as string; + const constructor = { + mysql: KnexSpatial_MySQL, + mariadb: KnexSpatial_MySQL, + sqlite3: KnexSpatial, + pg: KnexSpatial_PG, + redshift: KnexSpatial_Redshift, + mssql: KnexSpatial_MSSQL, + oracledb: KnexSpatial_Oracle, + }[client]; + if (!constructor) { + throw new Error(`Geometry helper not implemented on ${client}.`); + } + geometryHelper = new constructor(db); + } + return geometryHelper; +} + +class KnexSpatial { + constructor(protected knex: Knex) {} + isTrue(expression: Knex.Raw) { + return expression; + } + isFalse(expression: Knex.Raw) { + return expression.wrap('NOT ', ''); + } + createColumn(table: Knex.CreateTableBuilder, field: RawField | Field) { + const type = field.schema?.geometry_type ?? 'geometry'; + return table.specificType(field.field, type); + } + asText(table: string, column: string): Knex.Raw { + return this.knex.raw('st_astext(??.??) as ??', [table, column, column]); + } + fromText(text: string): Knex.Raw { + return this.knex.raw('st_geomfromtext(?, 4326)', text); + } + fromGeoJSON(geojson: GeoJSONGeometry): Knex.Raw { + return this.fromText(geojsonToWKT(geojson)); + } + _intersects(key: string, geojson: GeoJSONGeometry): Knex.Raw { + const geometry = this.fromGeoJSON(geojson); + return this.knex.raw('st_intersects(??, ?)', [key, geometry]); + } + intersects(key: string, geojson: GeoJSONGeometry): Knex.Raw { + return this.isTrue(this._intersects(key, geojson)); + } + nintersects(key: string, geojson: GeoJSONGeometry): Knex.Raw { + return this.isFalse(this._intersects(key, geojson)); + } + _intersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw { + const geometry = this.fromGeoJSON(geojson); + return this.knex.raw('intersects(??, ?)', [key, geometry]); + } + intersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw { + return this.isTrue(this._intersects_bbox(key, geojson)); + } + nintersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw { + return this.isFalse(this._intersects_bbox(key, geojson)); + } + collect(table: string, column: string): Knex.Raw { + return this.knex.raw('st_astext(st_collect(??.??))', [table, column]); + } +} + +class KnexSpatial_PG extends KnexSpatial { + createColumn(table: Knex.CreateTableBuilder, field: RawField | Field) { + const type = field.schema?.geometry_type ?? 'geometry'; + return table.specificType(field.field, `geometry(${type})`); + } + _intersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw { + const geometry = this.fromGeoJSON(geojson); + return this.knex.raw('?? && ?', [key, geometry]); + } +} + +class KnexSpatial_MySQL extends KnexSpatial { + collect(table: string, column: string): Knex.Raw { + return this.knex.raw( + `concat('geometrycollection(', group_concat(? separator ', '), ')'`, + this.asText(table, column) + ); + } +} + +class KnexSpatial_Redshift extends KnexSpatial { + createColumn(table: Knex.CreateTableBuilder, field: RawField | Field) { + const type = field.schema?.geometry_type ?? 'geometry'; + if (type !== 'geometry') field.meta!.special![1] = type; + return table.specificType(field.field, 'geometry'); + } +} + +class KnexSpatial_MSSQL extends KnexSpatial { + isTrue(expression: Knex.Raw) { + return expression.wrap(``, ` = 1`); + } + isFalse(expression: Knex.Raw) { + return expression.wrap(``, ` = 0`); + } + createColumn(table: Knex.CreateTableBuilder, field: RawField | Field) { + const type = field.schema?.geometry_type ?? 'geometry'; + if (type !== 'geometry') field.meta!.special![1] = type; + return table.specificType(field.field, 'geometry'); + } + asText(table: string, column: string): Knex.Raw { + return this.knex.raw('??.??.STAsText() as ??', [table, column, column]); + } + fromText(text: string): Knex.Raw { + return this.knex.raw('geometry::STGeomFromText(?, 4326)', text); + } + _intersects(key: string, geojson: GeoJSONGeometry): Knex.Raw { + const geometry = this.fromGeoJSON(geojson); + return this.knex.raw('??.STIntersects(?)', [key, geometry]); + } + _intersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw { + const geometry = this.fromGeoJSON(geojson); + return this.knex.raw('??.STEnvelope().STIntersects(?.STEnvelope())', [key, geometry]); + } + collect(table: string, column: string): Knex.Raw { + return this.knex.raw('geometry::CollectionAggregate(??.??).STAsText()', [table, column]); + } +} + +class KnexSpatial_Oracle extends KnexSpatial { + isTrue(expression: Knex.Raw) { + return expression.wrap(``, ` = 'TRUE'`); + } + isFalse(expression: Knex.Raw) { + return expression.wrap(``, ` = 'FALSE'`); + } + createColumn(table: Knex.CreateTableBuilder, field: RawField | Field) { + const type = field.schema?.geometry_type ?? 'geometry'; + if (type !== 'geometry') field.meta!.special![1] = type; + return table.specificType(field.field, 'sdo_geometry'); + } + asText(table: string, column: string): Knex.Raw { + return this.knex.raw('sdo_util.from_wktgeometry(??.??) as ??', [table, column, column]); + } + fromText(text: string): Knex.Raw { + return this.knex.raw('sdo_geometry(?, 4326)', text); + } + _intersects(key: string, geojson: GeoJSONGeometry): Knex.Raw { + const geometry = this.fromGeoJSON(geojson); + return this.knex.raw(`sdo_overlapbdyintersect(??, ?)`, [key, geometry]); + } + _intersects_bbox(key: string, geojson: GeoJSONGeometry): Knex.Raw { + const geometry = this.fromGeoJSON(geojson); + return this.knex.raw(`sdo_overlapbdyintersect(sdo_geom.sdo_mbr(??), sdo_geom.sdo_mbr(?))`, [key, geometry]); + } + collect(table: string, column: string): Knex.Raw { + return this.knex.raw(`concat('geometrycollection(', listagg(?, ', '), ')'`, this.asText(table, column)); + } +} diff --git a/api/src/database/index.ts b/api/src/database/index.ts index 14d31b1a4b768..9a3b2ed434a40 100644 --- a/api/src/database/index.ts +++ b/api/src/database/index.ts @@ -53,7 +53,10 @@ export default function getDatabase(): Knex { searchPath: env.DB_SEARCH_PATH, connection: env.DB_CONNECTION_STRING || connectionConfig, log: { - warn: (msg) => logger.warn(msg), + warn: (msg) => { + if (msg.startsWith('.returning()')) return; + return logger.warn(msg); + }, error: (msg) => logger.error(msg), deprecate: (msg) => logger.info(msg), debug: (msg) => logger.debug(msg), diff --git a/api/src/database/migrations/20210811A-add-geometry-config.ts b/api/src/database/migrations/20210811A-add-geometry-config.ts new file mode 100644 index 0000000000000..dc86a4cb8abb1 --- /dev/null +++ b/api/src/database/migrations/20210811A-add-geometry-config.ts @@ -0,0 +1,15 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('directus_settings', (table) => { + table.json('basemaps'); + table.string('mapbox_key'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_settings', (table) => { + table.dropColumn('basemaps'); + table.dropColumn('mapbox_key'); + }); +} diff --git a/api/src/database/run-ast.ts b/api/src/database/run-ast.ts index 0421e0d516805..a3baeb0abc9c3 100644 --- a/api/src/database/run-ast.ts +++ b/api/src/database/run-ast.ts @@ -9,6 +9,8 @@ import { getColumn } from '../utils/get-column'; import { stripFunction } from '../utils/strip-function'; import { toArray } from '@directus/shared/utils'; import getDatabase from './index'; +import { isNativeGeometry } from '../utils/geometry'; +import { getGeometryHelper } from '../database/helpers/geometry'; type RunASTOptions = { /** @@ -150,6 +152,20 @@ async function parseCurrentLevel( return { columnsToSelect, nestedCollectionNodes, primaryKeyField }; } +function getColumnPreprocessor(knex: Knex, schema: SchemaOverview, table: string) { + const helper = getGeometryHelper(); + + return function (column: string): Knex.Raw { + const field = schema.collections[table].fields[column]; + + if (isNativeGeometry(field)) { + return helper.asText(table, column); + } + + return getColumn(knex, table, column); + }; +} + function getDBQuery( schema: SchemaOverview, knex: Knex, @@ -158,8 +174,8 @@ function getDBQuery( query: Query, nested?: boolean ): Knex.QueryBuilder { - const dbQuery = knex.select(columns.map((column) => getColumn(knex, table, column))).from(table); - + const preProcess = getColumnPreprocessor(knex, schema, table); + const dbQuery = knex.select(columns.map(preProcess)).from(table); const queryCopy = clone(query); queryCopy.limit = typeof queryCopy.limit === 'number' ? queryCopy.limit : 100; diff --git a/api/src/database/seeds/run.ts b/api/src/database/seeds/run.ts index cf86c8eddf887..6a14b83ff9006 100644 --- a/api/src/database/seeds/run.ts +++ b/api/src/database/seeds/run.ts @@ -3,7 +3,8 @@ import yaml from 'js-yaml'; import { Knex } from 'knex'; import { isObject } from 'lodash'; import path from 'path'; -import { Type } from '@directus/shared/types'; +import { Type, Field } from '@directus/shared/types'; +import { getGeometryHelper } from '../helpers/geometry'; type TableSeed = { table: string; @@ -55,6 +56,9 @@ export default async function runSeed(database: Knex): Promise { column = tableBuilder.string(columnName); } else if (columnInfo.type === 'hash') { column = tableBuilder.string(columnName, 255); + } else if (columnInfo.type === 'geometry') { + const helper = getGeometryHelper(); + column = helper.createColumn(tableBuilder, { field: columnName } as Field); } else { column = tableBuilder[columnInfo.type!](columnName); } diff --git a/api/src/database/system-data/fields/settings.yaml b/api/src/database/system-data/fields/settings.yaml index aba76bc0ae990..f135037b38ce7 100644 --- a/api/src/database/system-data/fields/settings.yaml +++ b/api/src/database/system-data/fields/settings.yaml @@ -44,7 +44,7 @@ fields: interface: presentation-divider options: icon: public - title: Public Pages + title: $t:fields.directus_settings.public_pages special: - alias - no-data @@ -74,7 +74,7 @@ fields: interface: presentation-divider options: icon: security - title: Security + title: $t:security special: - alias - no-data @@ -104,7 +104,7 @@ fields: interface: presentation-divider options: icon: storage - title: Files & Thumbnails + title: $t:fields.directus_settings.files_and_thumbnails special: - alias - no-data @@ -115,7 +115,7 @@ fields: options: fields: - field: key - name: Key + name: $t:key type: string schema: is_nullable: false @@ -144,7 +144,7 @@ fields: text: Fit outside width: half - field: width - name: Width + name: $t:width type: integer schema: is_nullable: false @@ -152,7 +152,7 @@ fields: interface: input width: half - field: height - name: Height + name: $t:height type: integer schema: is_nullable: false @@ -161,7 +161,7 @@ fields: width: half - field: quality type: integer - name: Quality + name: $t:quality schema: default_value: 80 is_nullable: false @@ -236,23 +236,23 @@ fields: options: choices: - value: all - text: All + text: $t:all - value: none - text: None + text: $t:none - value: presets - text: Presets Only + text: $t:presets_only width: half - field: storage_default_folder interface: system-folder width: half - note: Default folder where new files are uploaded + note: $t:interfaces.system-folder.field_hint - field: overrides_divider interface: presentation-divider options: icon: brush - title: App Overrides + title: $t:fields.directus_settings.overrides special: - alias - no-data @@ -264,3 +264,58 @@ fields: language: css lineNumber: true width: full + + - field: map_divider + interface: presentation-divider + options: + icon: map + title: $t:maps + special: + - alias + - no-data + width: full + + - field: mapbox_key + interface: input + options: + icon: key + title: Mapbox Access Token + placeholder: pk.eyJ1Ijo..... + iconLeft: vpn_key + font: monospace + width: half + + - field: basemaps + interface: list + special: json + options: + template: '{{name}}' + fields: + - field: name + name: $t:name + schema: + is_nullable: false + meta: + interface: text-input + options: + placeholder: Enter the basemap name... + - field: type + name: $t:type + meta: + interface: select-dropdown + options: + choices: + - value: raster + text: Raster + - value: tile + text: Raster TileJSON + - value: style + text: Mapbox Style + - field: url + name: $t:url + schema: + is_nullable: false + meta: + interface: text-input + options: + placeholder: http://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png diff --git a/api/src/database/system-data/fields/users.yaml b/api/src/database/system-data/fields/users.yaml index 9c3760a58ecd4..16b7d17cddb1d 100644 --- a/api/src/database/system-data/fields/users.yaml +++ b/api/src/database/system-data/fields/users.yaml @@ -64,7 +64,7 @@ fields: interface: presentation-divider options: icon: face - title: User Preferences + title: $t:fields.directus_users.user_preferences special: - alias - no-data @@ -79,11 +79,11 @@ fields: options: choices: - value: auto - text: Automatic (Based on System) + text: $t:fields.directus_users.theme_auto - value: light - text: Light Mode + text: $t:fields.directus_users.theme_light - value: dark - text: Dark Mode + text: $t:fields.directus_users.theme_dark width: half - field: tfa_secret @@ -95,7 +95,7 @@ fields: interface: presentation-divider options: icon: verified_user - title: Admin Options + title: $t:fields.directus_users.admin_options color: '#E35169' special: - alias @@ -106,15 +106,15 @@ fields: interface: select-dropdown options: choices: - - text: Draft + - text: $t:fields.directus_users.status_draft value: draft - - text: Invited + - text: $t:fields.directus_users.status_invited value: invited - - text: Active + - text: $t:fields.directus_users.status_active value: active - - text: Suspended + - text: $t:fields.directus_users.status_suspended value: suspended - - text: Archived + - text: $t:fields.directus_users.status_archived value: archived width: half @@ -132,7 +132,7 @@ fields: interface: token options: iconRight: vpn_key - placeholder: Enter a secure access token... + placeholder: $t:fields.directus_users.token_placeholder width: full - field: id diff --git a/api/src/database/system-data/fields/webhooks.yaml b/api/src/database/system-data/fields/webhooks.yaml index 82ae7c3f27e5a..17e7f4518b74d 100644 --- a/api/src/database/system-data/fields/webhooks.yaml +++ b/api/src/database/system-data/fields/webhooks.yaml @@ -38,26 +38,26 @@ fields: defaultBackground: 'var(--background-normal-alt)' showAsDot: true choices: - - text: Active + - text: $t:active value: active foreground: 'var(--primary-10)' background: 'var(--primary)' - - text: Inactive + - text: $t:inactive value: inactive foreground: 'var(--foreground-normal)' background: 'var(--background-normal-alt)' options: choices: - - text: Active + - text: $t:active value: active - - text: Inactive + - text: $t:inactive value: inactive width: half - field: data interface: boolean options: - label: Send Event Data + label: $t:fields.directus_webhooks.data_label special: boolean width: half display: boolean @@ -66,7 +66,7 @@ fields: interface: presentation-divider options: icon: api - title: Triggers + title: $t:fields.directus_webhooks.triggers special: - alias - no-data @@ -76,11 +76,11 @@ fields: interface: select-multiple-checkbox options: choices: - - text: Create + - text: $t:create value: create - - text: Update + - text: $t:update value: update - - text: Delete + - text: $t:delete_label value: delete special: csv width: full @@ -89,19 +89,19 @@ fields: defaultForeground: 'var(--foreground-normal)' defaultBackground: 'var(--background-normal-alt)' choices: - - text: Create + - text: $t:create value: create foreground: 'var(--primary)' background: 'var(--primary-25)' - - text: Update + - text: $t:update value: update foreground: 'var(--blue)' background: 'var(--blue-25)' - - text: Delete + - text: $t:delete_label value: delete foreground: 'var(--danger)' background: 'var(--danger-25)' - - text: Login + - text: $t:login value: authenticate foreground: 'var(--purple)' background: 'var(--purple-25)' diff --git a/api/src/extensions.ts b/api/src/extensions.ts index 39432e45aabed..1856574860379 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -8,7 +8,14 @@ import { getPackageExtensions, resolvePackage, } from '@directus/shared/utils/node'; -import { APP_EXTENSION_TYPES, APP_SHARED_DEPS } from '@directus/shared/constants'; +import { + API_EXTENSION_PACKAGE_TYPES, + API_EXTENSION_TYPES, + APP_EXTENSION_TYPES, + APP_SHARED_DEPS, + EXTENSION_PACKAGE_TYPES, + EXTENSION_TYPES, +} from '@directus/shared/constants'; import getDatabase from './database'; import emitter from './emitter'; import env from './env'; @@ -32,14 +39,14 @@ let extensionBundles: Partial> = {}; export async function initializeExtensions(): Promise { try { - await ensureExtensionDirs(env.EXTENSIONS_PATH); + await ensureExtensionDirs(env.EXTENSIONS_PATH, env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES); extensions = await getExtensions(); } catch (err) { logger.warn(`Couldn't load extensions`); logger.warn(err); } - if (env.SERVE_APP ?? env.NODE_ENV !== 'development') { + if (env.SERVE_APP) { extensionBundles = await generateExtensionBundles(); } @@ -72,8 +79,14 @@ export function registerExtensionHooks(): void { } async function getExtensions(): Promise { - const packageExtensions = await getPackageExtensions('.'); - const localExtensions = await getLocalExtensions(env.EXTENSIONS_PATH); + const packageExtensions = await getPackageExtensions( + '.', + env.SERVE_APP ? EXTENSION_PACKAGE_TYPES : API_EXTENSION_PACKAGE_TYPES + ); + const localExtensions = await getLocalExtensions( + env.EXTENSIONS_PATH, + env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES + ); return [...packageExtensions, ...localExtensions]; } diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index 7ce3c213b554e..df6a55d5bc5b8 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -7,11 +7,11 @@ import { systemCollectionRows } from '../database/system-data/collections'; import env from '../env'; import { ForbiddenException, InvalidPayloadException } from '../exceptions'; import logger from '../logger'; -import { FieldsService, RawField } from '../services/fields'; +import { FieldsService } from '../services/fields'; import { ItemsService, MutationOptions } from '../services/items'; import Keyv from 'keyv'; import { AbstractServiceOptions, Collection, CollectionMeta, SchemaOverview } from '../types'; -import { Accountability, FieldMeta } from '@directus/shared/types'; +import { Accountability, FieldMeta, RawField } from '@directus/shared/types'; export type RawCollection = { collection: string; diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index f4d5256a6f827..6aabdc8482fd9 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -13,16 +13,14 @@ import { ItemsService } from '../services/items'; import { PayloadService } from '../services/payload'; import { AbstractServiceOptions, SchemaOverview } from '../types'; import { Accountability } from '@directus/shared/types'; -import { Field, FieldMeta, Type } from '@directus/shared/types'; +import { Field, FieldMeta, RawField, Type } from '@directus/shared/types'; import getDefaultValue from '../utils/get-default-value'; import getLocalType from '../utils/get-local-type'; import { toArray } from '@directus/shared/utils'; import { isEqual, isNil } from 'lodash'; import { RelationsService } from './relations'; +import { getGeometryHelper } from '../database/helpers/geometry'; import Keyv from 'keyv'; -import { DeepPartial } from '@directus/shared/types'; - -export type RawField = DeepPartial & { field: string; type: Type }; export class FieldsService { knex: Knex; @@ -77,25 +75,22 @@ export class FieldsService { fields.push(...systemFieldRows); } - let columns = await this.schemaInspector.columnInfo(collection); - - columns = columns.map((column) => { - return { - ...column, - default_value: getDefaultValue(column), - }; - }); + const columns = (await this.schemaInspector.columnInfo(collection)).map((column) => ({ + ...column, + default_value: getDefaultValue(column), + })); const columnsWithSystem = columns.map((column) => { const field = fields.find((field) => { return field.field === column.name && field.collection === column.table; }); + const { type = 'alias', ...info } = column ? getLocalType(column, field) : {}; const data = { collection: column.table, field: column.name, - type: column ? getLocalType(column, field) : 'alias', - schema: column, + type: type, + schema: { ...column, ...info }, meta: field || null, }; @@ -202,12 +197,13 @@ export class FieldsService { // Do nothing } + const { type = 'alias', ...info } = column ? getLocalType(column, fieldInfo) : {}; const data = { collection, field, - type: column ? getLocalType(column, fieldInfo) : 'alias', + type, meta: fieldInfo || null, - schema: column || null, + schema: type == 'alias' ? null : { ...column, ...info }, }; return data; @@ -458,6 +454,9 @@ export class FieldsService { column = table.dateTime(field.field, { useTz: false }); } else if (field.type === 'timestamp') { column = table.timestamp(field.field, { useTz: true }); + } else if (field.type === 'geometry') { + const helper = getGeometryHelper(); + column = helper.createColumn(table, field); } else { column = table[field.type](field.field); } diff --git a/api/src/services/graphql.ts b/api/src/services/graphql.ts index e0a7a721f97f0..eeb1ba34a6703 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql.ts @@ -1,4 +1,5 @@ import argon2 from 'argon2'; +import { validateQuery } from '../utils/validate-query'; import { ArgumentNode, BooleanValueNode, @@ -51,7 +52,7 @@ import { ForbiddenException, GraphQLValidationException, InvalidPayloadException import { BaseException } from '@directus/shared/exceptions'; import { listExtensions } from '../extensions'; import { Accountability } from '@directus/shared/types'; -import { AbstractServiceOptions, Action, GraphQLParams, Item, Query, SchemaOverview } from '../types'; +import { AbstractServiceOptions, Action, Aggregate, GraphQLParams, Item, Query, SchemaOverview } from '../types'; import { getGraphQLType } from '../utils/get-graphql-type'; import { reduceSchema } from '../utils/reduce-schema'; import { sanitizeQuery } from '../utils/sanitize-query'; @@ -92,6 +93,12 @@ const GraphQLVoid = new GraphQLScalarType({ }, }); +export const GraphQLGeoJSON = new GraphQLScalarType({ + ...GraphQLJSON, + name: 'GraphQLGeoJSON', + description: 'GeoJSON value', +}); + export const GraphQLDate = new GraphQLScalarType({ ...GraphQLString, name: 'Date', @@ -220,6 +227,22 @@ export class GraphQLService { acc[`${collectionName}_by_id`] = ReadCollectionTypes[collection.collection].getResolver( `${collection.collection}_by_id` ); + + const hasAggregate = Object.values(collection.fields).some((field) => { + const graphqlType = getGraphQLType(field.type); + + if (graphqlType === GraphQLInt || graphqlType === GraphQLFloat) { + return true; + } + + return false; + }); + + if (hasAggregate) { + acc[`${collectionName}_aggregated`] = ReadCollectionTypes[collection.collection].getResolver( + `${collection.collection}_aggregated` + ); + } } return acc; @@ -319,7 +342,6 @@ export class GraphQLService { for (const collection of Object.values(schema[action].collections)) { if (Object.keys(collection.fields).length === 0) continue; if (SYSTEM_DENY_LIST.includes(collection.collection)) continue; - CollectionTypes[collection.collection] = schemaComposer.createObjectTC({ name: action === 'read' ? collection.collection : `${action}_${collection.collection}`, fields: Object.values(collection.fields).reduce((acc, field) => { @@ -402,8 +424,12 @@ export class GraphQLService { */ function getReadableTypes() { const { CollectionTypes: ReadCollectionTypes } = getTypes('read'); + const ReadableCollectionFilterTypes: Record = {}; + const AggregatedFunctions: Record> = {}; + const AggregatedFilters: Record> = {}; + const StringFilterOperators = schemaComposer.createInputTC({ name: 'string_filter_operators', fields: { @@ -536,6 +562,30 @@ export class GraphQLService { }, }); + const GeometryFilterOperators = schemaComposer.createInputTC({ + name: 'geometry_filter_operators', + fields: { + _eq: { + type: GraphQLGeoJSON, + }, + _neq: { + type: GraphQLGeoJSON, + }, + _intersects: { + type: GraphQLGeoJSON, + }, + _nintersects: { + type: GraphQLGeoJSON, + }, + _intersects_bbox: { + type: GraphQLGeoJSON, + }, + _nintersects_bbox: { + type: GraphQLGeoJSON, + }, + }, + }); + for (const collection of Object.values(schema.read.collections)) { if (Object.keys(collection.fields).length === 0) continue; if (SYSTEM_DENY_LIST.includes(collection.collection)) continue; @@ -557,12 +607,14 @@ export class GraphQLService { case GraphQLDate: filterOperatorType = DateFilterOperators; break; + case GraphQLGeoJSON: + filterOperatorType = GeometryFilterOperators; + break; default: filterOperatorType = StringFilterOperators; } acc[field.field] = filterOperatorType; - return acc; }, {} as InputTypeComposerFieldConfigMapDefinition), }); @@ -572,6 +624,69 @@ export class GraphQLService { _or: [ReadableCollectionFilterTypes[collection.collection]], }); + AggregatedFilters[collection.collection] = schemaComposer.createObjectTC({ + name: `${collection.collection}_aggregated_fields`, + fields: Object.values(collection.fields).reduce((acc, field) => { + const graphqlType = getGraphQLType(field.type); + + switch (graphqlType) { + case GraphQLInt: + case GraphQLFloat: + acc[field.field] = { + type: GraphQLFloat, + description: field.note, + }; + break; + default: + break; + } + + return acc; + }, {} as ObjectTypeComposerFieldConfigMapDefinition), + }); + + AggregatedFunctions[collection.collection] = schemaComposer.createObjectTC({ + name: `${collection.collection}_aggregated`, + fields: { + group: { + name: 'group', + type: GraphQLJSON, + }, + avg: { + name: 'avg', + type: AggregatedFilters[collection.collection], + }, + sum: { + name: 'sum', + type: AggregatedFilters[collection.collection], + }, + count: { + name: 'count', + type: AggregatedFilters[collection.collection], + }, + countDistinct: { + name: 'countDistinct', + type: AggregatedFilters[collection.collection], + }, + avgDistinct: { + name: 'avgDistinct', + type: AggregatedFilters[collection.collection], + }, + sumDistinct: { + name: 'sumDistinct', + type: AggregatedFilters[collection.collection], + }, + min: { + name: 'min', + type: AggregatedFilters[collection.collection], + }, + max: { + name: 'max', + type: AggregatedFilters[collection.collection], + }, + }, + }); + ReadCollectionTypes[collection.collection].addResolver({ name: collection.collection, args: collection.singleton @@ -603,7 +718,19 @@ export class GraphQLService { return result; }, }); + ReadCollectionTypes[collection.collection].addResolver({ + name: `${collection.collection}_aggregated`, + type: [AggregatedFunctions[collection.collection]], + args: { + groupBy: GraphQLString, + }, + resolve: async ({ info, context }: { info: GraphQLResolveInfo; context: Record }) => { + const result = await self.resolveQuery(info); + context.data = result; + return result; + }, + }); if (collection.singleton === false) { ReadCollectionTypes[collection.collection].addResolver({ name: `${collection.collection}_by_id`, @@ -836,18 +963,26 @@ export class GraphQLService { async resolveQuery(info: GraphQLResolveInfo): Promise | null> { let collection = info.fieldName; if (this.scope === 'system') collection = `directus_${collection}`; - const selections = this.replaceFragmentsInSelections(info.fieldNodes[0]?.selectionSet?.selections, info.fragments); if (!selections) return null; const args: Record = this.parseArgs(info.fieldNodes[0].arguments || [], info.variableValues); - const query = this.getQuery(args, selections, info.variableValues); - if (collection.endsWith('_by_id') && collection in this.schema.collections === false) { - collection = collection.slice(0, -6); - } + let query: Record; + + const isAggregate = collection.endsWith('_aggregated') && collection in this.schema.collections === false; + if (isAggregate) { + query = this.getAggregateQuery(args, selections); + collection = collection.slice(0, -11); + } else { + query = this.getQuery(args, selections, info.variableValues); + + if (collection.endsWith('_by_id') && collection in this.schema.collections === false) { + collection = collection.slice(0, -6); + } + } if (args.id) { query.filter = { _and: [ @@ -862,20 +997,19 @@ export class GraphQLService { query.limit = 1; } - const result = await this.read(collection, query); - if (args.id) { return result?.[0] || null; } - + if (query.group) { + // for every entry in result add a group field based on query.group; + result.map((field) => { + field.group = field[query.group[0]]; + }); + } return result; } - /** - * Generic mutation resolver that converts the incoming GraphQL mutation AST into a Directus query and executes the - * appropriate C-UD operation - */ async resolveMutation( args: Record, info: GraphQLResolveInfo @@ -1039,7 +1173,6 @@ export class GraphQLService { for (let selection of selections) { if ((selection.kind === 'Field' || selection.kind === 'InlineFragment') !== true) continue; selection = selection as FieldNode | InlineFragmentNode; - let current: string; if (selection.kind === 'InlineFragment') { @@ -1082,11 +1215,38 @@ export class GraphQLService { } } } - return uniq(fields); }; - query.fields = parseFields(selections); + return query; + } + + /** + * Resolve the aggregation query based on the requested aggregated fields + */ + getAggregateQuery(rawQuery: Query, selections: readonly SelectionNode[]): Query { + const query: Query = sanitizeQuery(rawQuery, this.accountability); + + query.aggregate = {}; + + for (let aggregationGroup of selections) { + if ((aggregationGroup.kind === 'Field') !== true) continue; + + aggregationGroup = aggregationGroup as FieldNode; + + // filter out graphql pointers, like __typename + if (aggregationGroup.name.value.startsWith('__')) continue; + + const aggregateProperty = aggregationGroup.name.value as keyof Aggregate; + + query.aggregate[aggregateProperty] = + aggregationGroup.selectionSet?.selections.map((selectionNode) => { + selectionNode = selectionNode as FieldNode; + return selectionNode.name.value; + }) ?? []; + } + + validateQuery(query); return query; } @@ -2037,6 +2197,7 @@ export class GraphQLService { info.fragments ); const query = this.getQuery(args, selections || [], info.variableValues); + return await service.readOne(this.accountability.user, query); }, }, diff --git a/api/src/services/items.ts b/api/src/services/items.ts index 8f4aa387186d8..ce0b95324fac0 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -133,13 +133,15 @@ export class ItemsService implements AbstractSer let primaryKey = payloadWithTypeCasting[primaryKeyField]; try { - await trx.insert(payloadWithoutAliases).into(this.collection); + const result = await trx.insert(payloadWithoutAliases).into(this.collection).returning(primaryKeyField); + primaryKey = primaryKey ?? result[0]; } catch (err) { throw await translateDatabaseError(err); } - // When relying on a database auto-incremented ID, we'll have to fetch it from the DB in - // order to know what the PK is of the just-inserted item + // Most database support returning, those who don't tend to return the PK anyways + // (MySQL/SQLite). In case the primary key isn't know yet, we'll do a best-attempt at + // fetching it based on the last inserted row if (!primaryKey) { // Fetching it with max should be safe, as we're in the context of the current transaction const result = await trx.max(primaryKeyField, { as: 'id' }).from(this.collection).first(); @@ -162,9 +164,7 @@ export class ItemsService implements AbstractSer item: primaryKey, }; - await trx.insert(activityRecord).into('directus_activity'); - - const { id: activityID } = await trx.max('id', { as: 'id ' }).from('directus_activity').first(); + const activityID = (await trx.insert(activityRecord).into('directus_activity').returning('id'))[0] as number; // If revisions are tracked, create revisions record if (this.schema.collections[this.collection].accountability === 'all') { @@ -172,13 +172,11 @@ export class ItemsService implements AbstractSer activity: activityID, collection: this.collection, item: primaryKey, - data: JSON.stringify(payload), - delta: JSON.stringify(payload), + data: await payloadService.prepareDelta(payload), + delta: await payloadService.prepareDelta(payload), }; - await trx.insert(revisionRecord).into('directus_revisions'); - - const { id: revisionID } = await trx.max('id', { as: 'id' }).from('directus_revisions').first(); + const revisionID = (await trx.insert(revisionRecord).into('directus_revisions').returning('id'))[0] as number; // Make sure to set the parent field of the child-revision rows const childrenRevisions = [...revisionsM2O, ...revisionsA2O, ...revisionsO2M]; @@ -470,10 +468,7 @@ export class ItemsService implements AbstractSer const activityPrimaryKeys: PrimaryKey[] = []; for (const activityRecord of activityRecords) { - await trx.insert(activityRecord).into('directus_activity'); - const result = await trx.max('id', { as: 'id' }).from('directus_activity').first(); - const primaryKey = result.id; - + const primaryKey = (await trx.insert(activityRecord).into('directus_activity').returning('id'))[0] as number; activityPrimaryKeys.push(primaryKey); } @@ -485,18 +480,28 @@ export class ItemsService implements AbstractSer const snapshots = await itemsService.readMany(keys); - const revisionRecords = activityPrimaryKeys.map((key, index) => ({ - activity: key, - collection: this.collection, - item: keys[index], - data: - snapshots && Array.isArray(snapshots) ? JSON.stringify(snapshots?.[index]) : JSON.stringify(snapshots), - delta: JSON.stringify(payloadWithTypeCasting), - })); + const revisionRecords: { + activity: PrimaryKey; + collection: string; + item: PrimaryKey; + data: string; + delta: string; + }[] = []; + + for (let i = 0; i < activityPrimaryKeys.length; i++) { + revisionRecords.push({ + activity: activityPrimaryKeys[i], + collection: this.collection, + item: keys[i], + data: snapshots && Array.isArray(snapshots) ? JSON.stringify(snapshots[i]) : JSON.stringify(snapshots), + delta: await payloadService.prepareDelta(payloadWithTypeCasting), + }); + } for (let i = 0; i < revisionRecords.length; i++) { - await trx.insert(revisionRecords[i]).into('directus_revisions'); - const { id: revisionID } = await trx.max('id', { as: 'id' }).from('directus_revisions').first(); + const revisionID = ( + await trx.insert(revisionRecords[i]).into('directus_revisions').returning('id') + )[0] as number; if (opts?.onRevisionCreate) { opts.onRevisionCreate(revisionID); diff --git a/api/src/services/payload.ts b/api/src/services/payload.ts index d428f760d74ad..7a5bb2022239f 100644 --- a/api/src/services/payload.ts +++ b/api/src/services/payload.ts @@ -2,7 +2,7 @@ import argon2 from 'argon2'; import { format, parseISO } from 'date-fns'; import Joi from 'joi'; import { Knex } from 'knex'; -import { clone, cloneDeep, isObject, isPlainObject, omit } from 'lodash'; +import { clone, cloneDeep, isObject, isPlainObject, omit, isNil } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import getDatabase from '../database'; import { ForbiddenException, InvalidPayloadException } from '../exceptions'; @@ -10,6 +10,10 @@ import { AbstractServiceOptions, Item, PrimaryKey, Query, SchemaOverview, Altera import { Accountability } from '@directus/shared/types'; import { toArray } from '@directus/shared/utils'; import { ItemsService } from './items'; +import { unflatten } from 'flat'; +import { isNativeGeometry } from '../utils/geometry'; +import { getGeometryHelper } from '../database/helpers/geometry'; +import { parse as wktToGeoJSON } from 'wellknown'; type Action = 'create' | 'read' | 'update'; @@ -19,6 +23,7 @@ type Transformers = { value: any; payload: Partial; accountability: Accountability | null; + specials: string[]; }) => Promise; }; @@ -121,7 +126,7 @@ export class PayloadService { action: Action, payload: Partial | Partial[] ): Promise | Partial[]> { - const processedPayload = toArray(payload); + let processedPayload = toArray(payload); if (processedPayload.length === 0) return []; @@ -148,18 +153,23 @@ export class PayloadService { }) ); - await this.processDates(processedPayload, action); + this.processGeometries(processedPayload, action); + this.processDates(processedPayload, action); if (['create', 'update'].includes(action)) { processedPayload.forEach((record) => { for (const [key, value] of Object.entries(record)) { - if (Array.isArray(value) || (typeof value === 'object' && value instanceof Date !== true && value !== null)) { - record[key] = JSON.stringify(value); + if (Array.isArray(value) || (typeof value === 'object' && !(value instanceof Date) && value !== null)) { + if (!value.isRawInstance) { + record[key] = JSON.stringify(value); + } } } }); } + processedPayload = processedPayload.map((item: Record) => unflatten(item, { delimiter: '->' })); + if (Array.isArray(payload)) { return processedPayload; } @@ -185,6 +195,7 @@ export class PayloadService { value, payload, accountability, + specials: fieldSpecials, }); } } @@ -192,14 +203,40 @@ export class PayloadService { return value; } + /** + * Native geometries are stored in custom binary format. We need to insert them with + * the function st_geomfromtext. For this to work, that function call must not be + * escaped. It's therefore placed as a Knex.Raw object in the payload. Thus the need + * to check if the value is a raw instance before stringifying it in the next step. + */ + processGeometries>[]>(payloads: T, action: Action): T { + const helper = getGeometryHelper(); + + const process = + action == 'read' + ? (value: any) => { + if (typeof value === 'string') return wktToGeoJSON(value); + } + : (value: any) => helper.fromGeoJSON(typeof value == 'string' ? JSON.parse(value) : value); + + const fieldsInCollection = Object.entries(this.schema.collections[this.collection].fields); + const geometryColumns = fieldsInCollection.filter(([_, field]) => isNativeGeometry(field)); + + for (const [name] of geometryColumns) { + for (const payload of payloads) { + if (payload[name]) { + payload[name] = process(payload[name]); + } + } + } + + return payloads; + } /** * Knex returns `datetime` and `date` columns as Date.. This is wrong for date / datetime, as those * shouldn't return with time / timezone info respectively */ - async processDates( - payloads: Partial>[], - action: Action - ): Promise>[]> { + processDates(payloads: Partial>[], action: Action): Partial>[] { const fieldsInCollection = Object.entries(this.schema.collections[this.collection].fields); const dateColumns = fieldsInCollection.filter(([_name, field]) => @@ -512,8 +549,9 @@ export class PayloadService { // primary key might be reported as a string instead of number, coming from the // http route, and or a bigInteger in the DB if ( - existingRecord[relation.field] == parent || - existingRecord[relation.field] == payload[currentPrimaryKeyField] + isNil(existingRecord[relation.field]) === false && + (existingRecord[relation.field] == parent || + existingRecord[relation.field] == payload[currentPrimaryKeyField]) ) { savedPrimaryKeys.push(existingRecord[relatedPrimaryKeyField]); continue; @@ -637,4 +675,22 @@ export class PayloadService { return { revisions }; } + + /** + * Transforms the input partial payload to match the output structure, to have consistency + * between delta and data + */ + async prepareDelta(data: Partial): Promise { + let payload = cloneDeep(data); + + for (const key in payload) { + if (payload[key]?.isRawInstance) { + payload[key] = payload[key].bindings[0]; + } + } + + payload = await this.processValues('read', payload); + + return JSON.stringify(payload); + } } diff --git a/api/src/services/relations.ts b/api/src/services/relations.ts index 6df956a4ab9d5..636e5336da60b 100644 --- a/api/src/services/relations.ts +++ b/api/src/services/relations.ts @@ -143,6 +143,10 @@ export class RelationsService { ); } + if (relation.related_collection && relation.related_collection in this.schema.collections === false) { + throw new InvalidPayloadException(`Collection "${relation.related_collection}" doesn't exist`); + } + const existingRelation = this.schema.relations.find( (existingRelation) => existingRelation.collection === relation.collection && existingRelation.field === relation.field diff --git a/api/src/services/specifications.ts b/api/src/services/specifications.ts index eb4957b5d1d0c..a3d1be4107056 100644 --- a/api/src/services/specifications.ts +++ b/api/src/services/specifications.ts @@ -526,6 +526,9 @@ class OASSpecsService implements SpecificationSubService { type: 'string', format: 'uuid', }, + geometry: { + type: 'string', + }, }; } diff --git a/api/src/types/query.ts b/api/src/types/query.ts index ad4925b2751c4..b7e5e98d341a4 100644 --- a/api/src/types/query.ts +++ b/api/src/types/query.ts @@ -37,19 +37,3 @@ export type Aggregate = { min?: string[]; max?: string[]; }; - -export type FilterOperator = - | 'eq' - | 'neq' - | 'contains' - | 'ncontains' - | 'in' - | 'nin' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'null' - | 'nnull' - | 'empty' - | 'nempty'; diff --git a/api/src/types/schema.ts b/api/src/types/schema.ts index 0bc23e94a2fcb..c0c5eddf2be79 100644 --- a/api/src/types/schema.ts +++ b/api/src/types/schema.ts @@ -2,7 +2,20 @@ import { Type } from '@directus/shared/types'; import { Permission } from './permissions'; import { Relation } from './relation'; -type CollectionsOverview = { +export type FieldOverview = { + field: string; + defaultValue: any; + nullable: boolean; + type: Type | 'unknown' | 'alias'; + dbType: string | null; + precision: number | null; + scale: number | null; + special: string[]; + note: string | null; + alias: boolean; +}; + +export type CollectionsOverview = { [name: string]: { collection: string; primary: string; @@ -11,18 +24,7 @@ type CollectionsOverview = { note: string | null; accountability: 'all' | 'activity' | null; fields: { - [name: string]: { - field: string; - defaultValue: any; - nullable: boolean; - type: Type | 'unknown' | 'alias'; - dbType: string | null; - precision: number | null; - scale: number | null; - special: string[]; - note: string | null; - alias: boolean; - }; + [name: string]: FieldOverview; }; }; }; diff --git a/api/src/utils/apply-query.ts b/api/src/utils/apply-query.ts index 4309dd91cb240..15436bf94e207 100644 --- a/api/src/utils/apply-query.ts +++ b/api/src/utils/apply-query.ts @@ -7,6 +7,7 @@ import { Aggregate, Filter, Query, Relation, SchemaOverview } from '../types'; import { applyFunctionToColumnName } from './apply-function-to-column-name'; import { getColumn } from './get-column'; import { getRelationType } from './get-relation-type'; +import { getGeometryHelper } from '../database/helpers/geometry'; const generateAlias = customAlphabet('abcdefghijklmnopqrstuvwxyz', 5); @@ -97,6 +98,7 @@ export default function applyQuery( * ) * ``` */ + export function applyFilter( knex: Knex, schema: SchemaOverview, @@ -425,6 +427,23 @@ export function applyFilter( dbQuery[logical].whereNotBetween(selectionRaw, value); } + + const geometryHelper = getGeometryHelper(); + + if (operator == '_intersects') { + dbQuery[logical].whereRaw(geometryHelper.intersects(key, compareValue)); + } + + if (operator == '_nintersects') { + dbQuery[logical].whereRaw(geometryHelper.nintersects(key, compareValue)); + } + if (operator == '_intersects_bbox') { + dbQuery[logical].whereRaw(geometryHelper.intersects_bbox(key, compareValue)); + } + + if (operator == '_nintersects_bbox') { + dbQuery[logical].whereRaw(geometryHelper.nintersects_bbox(key, compareValue)); + } } function getWhereColumn(path: string[], collection: string) { @@ -515,39 +534,39 @@ export function applyAggregate(dbQuery: Knex.QueryBuilder, aggregate: Aggregate) for (const field of fields) { if (operation === 'avg') { - dbQuery.avg(field, { as: `${field}_avg` }); + dbQuery.avg(field, { as: `avg->${field}` }); } - if (operation === 'avg_distinct') { - dbQuery.avgDistinct(field, { as: `${field}_avg_distinct` }); + if (operation === 'avgDistinct') { + dbQuery.avgDistinct(field, { as: `avgDistinct->${field}` }); } if (operation === 'count') { if (field === '*') { dbQuery.count('*', { as: 'count' }); } else { - dbQuery.count(field, { as: `${field}_count` }); + dbQuery.count(field, { as: `count->${field}` }); } } - if (operation === 'count_distinct') { - dbQuery.countDistinct(field, { as: `${field}_count_distinct` }); + if (operation === 'countDistinct') { + dbQuery.countDistinct(field, { as: `countDistinct->${field}` }); } if (operation === 'sum') { - dbQuery.sum(field, { as: `${field}_sum` }); + dbQuery.sum(field, { as: `sum->${field}` }); } if (operation === 'sumDistinct') { - dbQuery.sum(field, { as: `${field}_sum_distinct` }); + dbQuery.sumDistinct(field, { as: `sumDistinct->${field}` }); } if (operation === 'min') { - dbQuery.min(field, { as: `${field}_min` }); + dbQuery.min(field, { as: `min->${field}` }); } if (operation === 'max') { - dbQuery.max(field, { as: `${field}_max` }); + dbQuery.max(field, { as: `max->${field}` }); } } } diff --git a/api/src/utils/geometry.ts b/api/src/utils/geometry.ts new file mode 100644 index 0000000000000..3f6ca7956e51f --- /dev/null +++ b/api/src/utils/geometry.ts @@ -0,0 +1,18 @@ +import { FieldOverview } from '../types'; +const dbGeometricTypes = new Set([ + 'point', + 'polygon', + 'linestring', + 'multipoint', + 'multipolygon', + 'multilinestring', + 'geometry', + 'geometrycollection', + 'sdo_geometry', + 'user-defined', +]); + +export function isNativeGeometry(field: FieldOverview): boolean { + const { type, dbType } = field; + return type == 'geometry' && dbGeometricTypes.has(dbType!.toLowerCase()); +} diff --git a/api/src/utils/get-cache-key.ts b/api/src/utils/get-cache-key.ts index 30236ee829af9..99f83e54f3634 100644 --- a/api/src/utils/get-cache-key.ts +++ b/api/src/utils/get-cache-key.ts @@ -8,7 +8,7 @@ export function getCacheKey(req: Request): string { const info = { user: req.accountability?.user || null, path, - query: path?.includes('/graphql') ? req.params.query : req.sanitizedQuery, + query: path?.includes('/graphql') ? req.query.query : req.sanitizedQuery, }; const key = hash(info); diff --git a/api/src/utils/get-default-value.ts b/api/src/utils/get-default-value.ts index afb8d60217cdd..ad33f029fd58f 100644 --- a/api/src/utils/get-default-value.ts +++ b/api/src/utils/get-default-value.ts @@ -5,7 +5,7 @@ import getLocalType from './get-local-type'; export default function getDefaultValue( column: SchemaOverview[string]['columns'][string] | Column ): string | boolean | null { - const type = getLocalType(column); + const { type } = getLocalType(column); let defaultValue = column.default_value ?? null; if (defaultValue === null) return null; diff --git a/api/src/utils/get-graphql-type.ts b/api/src/utils/get-graphql-type.ts index 4edce04d06dd8..e3ec380b9803f 100644 --- a/api/src/utils/get-graphql-type.ts +++ b/api/src/utils/get-graphql-type.ts @@ -8,7 +8,7 @@ import { GraphQLType, } from 'graphql'; import { GraphQLJSON } from 'graphql-compose'; -import { GraphQLDate } from '../services/graphql'; +import { GraphQLDate, GraphQLGeoJSON } from '../services/graphql'; import { Type } from '@directus/shared/types'; export function getGraphQLType(localType: Type | 'alias' | 'unknown'): GraphQLScalarType | GraphQLList { @@ -25,6 +25,8 @@ export function getGraphQLType(localType: Type | 'alias' | 'unknown'): GraphQLSc return new GraphQLList(GraphQLString); case 'json': return GraphQLJSON; + case 'geometry': + return GraphQLGeoJSON; case 'timestamp': case 'dateTime': case 'date': diff --git a/api/src/utils/get-local-type.ts b/api/src/utils/get-local-type.ts index 8b7fbe8eec420..3e70fd3d25818 100644 --- a/api/src/utils/get-local-type.ts +++ b/api/src/utils/get-local-type.ts @@ -3,10 +3,15 @@ import { Column } from 'knex-schema-inspector/dist/types/column'; import { FieldMeta, Type } from '@directus/shared/types'; import getDatabase from '../database'; -const localTypeMap: Record = { +type LocalTypeEntry = { + type: Type | 'unknown'; + geometry_type?: 'Point' | 'LineString' | 'Polygon' | 'MultiPoint' | 'MultiLineString' | 'MultiPolygon'; +}; + +const localTypeMap: Record = { // Shared boolean: { type: 'boolean' }, - tinyint: { type: 'boolean' }, + tinyint: { type: 'integer' }, smallint: { type: 'integer' }, mediumint: { type: 'integer' }, int: { type: 'integer' }, @@ -38,6 +43,15 @@ const localTypeMap: Record = { decimal: { type: 'decimal' }, numeric: { type: 'integer' }, + // Geometries + point: { type: 'geometry', geometry_type: 'Point' }, + linestring: { type: 'geometry', geometry_type: 'LineString' }, + polygon: { type: 'geometry', geometry_type: 'Polygon' }, + multipoint: { type: 'geometry', geometry_type: 'MultiPoint' }, + multilinestring: { type: 'geometry', geometry_type: 'MultiLineString' }, + multipolygon: { type: 'geometry', geometry_type: 'MultiPolygon' }, + geometry: { type: 'geometry' }, + // MySQL string: { type: 'text' }, year: { type: 'integer' }, @@ -49,7 +63,7 @@ const localTypeMap: Record = { bit: { type: 'boolean' }, smallmoney: { type: 'float' }, money: { type: 'float' }, - datetimeoffset: { type: 'timestamp', useTimezone: true }, + datetimeoffset: { type: 'timestamp' }, datetime2: { type: 'dateTime' }, smalldatetime: { type: 'dateTime' }, nchar: { type: 'text' }, @@ -73,16 +87,17 @@ const localTypeMap: Record = { _varchar: { type: 'string' }, bpchar: { type: 'string' }, timestamptz: { type: 'timestamp' }, - 'timestamp with time zone': { type: 'timestamp', useTimezone: true }, + 'timestamp with time zone': { type: 'timestamp' }, 'timestamp without time zone': { type: 'dateTime' }, timetz: { type: 'time' }, - 'time with time zone': { type: 'time', useTimezone: true }, + 'time with time zone': { type: 'time' }, 'time without time zone': { type: 'time' }, float4: { type: 'float' }, float8: { type: 'float' }, // Oracle number: { type: 'integer' }, + sdo_geometry: { type: 'geometry' }, // SQLite integerfirst: { type: 'integer' }, @@ -91,18 +106,21 @@ const localTypeMap: Record = { export default function getLocalType( column: SchemaOverview[string]['columns'][string] | Column, field?: { special?: FieldMeta['special'] } -): Type | 'unknown' { +): LocalTypeEntry { const database = getDatabase(); - const type = localTypeMap[column.data_type.toLowerCase().split('(')[0]]; + const type = localTypeMap[column.data_type.toLowerCase().split('(')[0]] as LocalTypeEntry; const special = field?.special; if (special) { - if (special.includes('json')) return 'json'; - if (special.includes('hash')) return 'hash'; - if (special.includes('csv')) return 'csv'; - if (special.includes('uuid')) return 'uuid'; + if (special.includes('json')) return { type: 'json' }; + if (special.includes('hash')) return { type: 'hash' }; + if (special.includes('csv')) return { type: 'csv' }; + if (special.includes('uuid')) return { type: 'uuid' }; + if (type?.type == 'geometry' && !type.geometry_type) { + type.geometry_type = special[1] as any; + } } /** Handle OracleDB timestamp with time zone */ @@ -111,26 +129,30 @@ export default function getLocalType( if (type.startsWith('timestamp')) { if (type.endsWith('with local time zone')) { - return 'timestamp'; + return { type: 'timestamp' }; } else { - return 'dateTime'; + return { type: 'dateTime' }; } } } - /** Handle Postgres numeric decimals */ if (column.data_type === 'numeric' && column.numeric_precision !== null && column.numeric_scale !== null) { - return 'decimal'; + return { type: 'decimal' }; } /** Handle MS SQL varchar(MAX) (eg TEXT) types */ if (column.data_type === 'nvarchar' && column.max_length === -1) { - return 'text'; + return { type: 'text' }; + } + + /** Handle Boolean as TINYINT*/ + if (column.data_type.toLowerCase() === 'tinyint(1)' || column.data_type.toLowerCase() === 'tinyint(0)') { + return { type: 'boolean' }; } if (type) { - return type.type; + return type; } - return 'unknown'; + return { type: 'unknown' }; } diff --git a/api/src/utils/get-schema.ts b/api/src/utils/get-schema.ts index fdbc61158913c..9f6759cf78047 100644 --- a/api/src/utils/get-schema.ts +++ b/api/src/utils/get-schema.ts @@ -136,18 +136,20 @@ async function getDatabaseSchema( note: collectionMeta?.note || null, sortField: collectionMeta?.sort_field || null, accountability: collectionMeta ? collectionMeta.accountability : 'all', - fields: mapValues(schemaOverview[collection].columns, (column) => ({ - field: column.column_name, - defaultValue: getDefaultValue(column) ?? null, - nullable: column.is_nullable ?? true, - type: getLocalType(column) || 'alias', - dbType: column.data_type, - precision: column.numeric_precision || null, - scale: column.numeric_scale || null, - special: [], - note: null, - alias: false, - })), + fields: mapValues(schemaOverview[collection].columns, (column) => { + return { + field: column.column_name, + defaultValue: getDefaultValue(column) ?? null, + nullable: column.is_nullable ?? true, + type: getLocalType(column).type, + dbType: column.data_type, + precision: column.numeric_precision || null, + scale: column.numeric_scale || null, + special: [], + note: null, + alias: false, + }; + }), }; } @@ -168,20 +170,19 @@ async function getDatabaseSchema( if (!result.collections[field.collection]) continue; const existing = result.collections[field.collection].fields[field.field]; + const column = schemaOverview[field.collection].columns[field.field]; + const special = field.special ? toArray(field.special) : []; + const { type = 'alias' } = existing && column ? getLocalType(column, { special }) : {}; result.collections[field.collection].fields[field.field] = { field: field.field, defaultValue: existing?.defaultValue ?? null, nullable: existing?.nullable ?? true, - type: existing - ? getLocalType(schemaOverview[field.collection].columns[field.field], { - special: field.special ? toArray(field.special) : [], - }) - : 'alias', + type: type, dbType: existing?.dbType || null, precision: existing?.precision || null, scale: existing?.scale || null, - special: field.special ? toArray(field.special) : [], + special: special, note: field.note, alias: existing?.alias ?? true, }; diff --git a/api/src/utils/is-url-allowed.ts b/api/src/utils/is-url-allowed.ts index b0d81a851fcf6..b07ca729515e6 100644 --- a/api/src/utils/is-url-allowed.ts +++ b/api/src/utils/is-url-allowed.ts @@ -1,5 +1,6 @@ import { toArray } from '@directus/shared/utils'; import logger from '../logger'; +import { URL } from 'url'; /** * Check if url matches allow list either exactly or by domain+path diff --git a/api/src/utils/sanitize-query.ts b/api/src/utils/sanitize-query.ts index de35923a925f2..3e6d7ebc7c001 100644 --- a/api/src/utils/sanitize-query.ts +++ b/api/src/utils/sanitize-query.ts @@ -2,7 +2,7 @@ import { flatten, get, merge, set } from 'lodash'; import logger from '../logger'; import { Aggregate, Filter, Meta, Query, Sort } from '../types'; import { Accountability } from '@directus/shared/types'; -import { parseFilter } from '@directus/shared/utils'; +import { parseFilter, deepMap } from '@directus/shared/utils'; export function sanitizeQuery(rawQuery: Record, accountability?: Accountability | null): Query { const query: Query = {}; @@ -19,8 +19,8 @@ export function sanitizeQuery(rawQuery: Record, accountability?: Ac query.fields = sanitizeFields(rawQuery.fields); } - if (rawQuery.group) { - query.group = sanitizeFields(rawQuery.group); + if (rawQuery.groupBy) { + query.group = sanitizeFields(rawQuery.groupBy); } if (rawQuery.aggregate) { @@ -123,6 +123,14 @@ function sanitizeFilter(rawFilter: any, accountability: Accountability | null) { } } + filters = deepMap(filters, (val) => { + try { + return JSON.parse(val); + } catch { + return val; + } + }); + filters = parseFilter(filters, accountability); return filters; diff --git a/api/src/utils/validate-query.ts b/api/src/utils/validate-query.ts index 74b68e800ba15..ba572388bbcc6 100644 --- a/api/src/utils/validate-query.ts +++ b/api/src/utils/validate-query.ts @@ -2,6 +2,7 @@ import Joi from 'joi'; import { isPlainObject } from 'lodash'; import { InvalidQueryException } from '../exceptions'; import { Query } from '../types'; +import { stringify } from 'wellknown'; const querySchema = Joi.object({ fields: Joi.array().items(Joi.string()), @@ -43,8 +44,6 @@ function validateFilter(filter: Query['filter']) { for (const [key, nested] of Object.entries(filter)) { if (key === '_and' || key === '_or') { nested.forEach(validateFilter); - } else if (isPlainObject(nested)) { - validateFilter(nested); } else if (key.startsWith('_')) { const value = nested; @@ -76,8 +75,17 @@ function validateFilter(filter: Query['filter']) { case '_nempty': validateBoolean(value, key); break; + + case '_intersects': + case '_nintersects': + case '_intersects_bbox': + case '_nintersects_bbox': + validateGeometry(value, key); + break; } - } else if (isPlainObject(nested) === false && Array.isArray(nested) === false) { + } else if (isPlainObject(nested)) { + validateFilter(nested); + } else if (Array.isArray(nested) === false) { validateFilterPrimitive(nested, '_eq'); } else { validateFilter(nested); @@ -123,3 +131,13 @@ function validateBoolean(value: any, key: string) { return true; } + +function validateGeometry(value: any, key: string) { + try { + stringify(value); + } catch { + throw new InvalidQueryException(`"${key}" has to be a valid GeoJSON object`); + } + + return true; +} diff --git a/api/src/utils/validate-storage.ts b/api/src/utils/validate-storage.ts new file mode 100644 index 0000000000000..12478c1dcec64 --- /dev/null +++ b/api/src/utils/validate-storage.ts @@ -0,0 +1,31 @@ +import env from '../env'; +import logger from '../logger'; +import { access } from 'fs-extra'; +import { constants } from 'fs'; +import path from 'path'; + +export async function validateStorage(): Promise { + if (env.DB_CLIENT === 'sqlite3') { + try { + await access(path.dirname(env.DB_FILENAME), constants.R_OK | constants.W_OK); + } catch { + logger.warn( + `Directory for SQLite database file (${path.resolve(path.dirname(env.DB_FILENAME))}) is not read/writeable!` + ); + } + } + + if (env.STORAGE_LOCATIONS.split(',').includes('local')) { + try { + await access(env.STORAGE_LOCAL_ROOT, constants.R_OK | constants.W_OK); + } catch { + logger.warn(`Upload directory (${path.resolve(env.STORAGE_LOCAL_ROOT)}) is not read/writeable!`); + } + } + + try { + await access(env.EXTENSIONS_PATH, constants.R_OK); + } catch { + logger.warn(`Extensions directory (${path.resolve(env.EXTENSIONS_PATH)}) is not readable!`); + } +} diff --git a/app/package.json b/app/package.json index ac0d88405f330..9b815e7455ebd 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@directus/app", - "version": "9.0.0-rc.88", + "version": "9.0.0-rc.90", "private": false, "description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases", "author": "Rijk van Zanten ", @@ -27,38 +27,46 @@ }, "gitHead": "24621f3934dc77eb23441331040ed13c676ceffd", "devDependencies": { - "@directus/docs": "9.0.0-rc.88", - "@directus/extension-sdk": "9.0.0-rc.88", - "@directus/format-title": "9.0.0-rc.88", - "@directus/shared": "9.0.0-rc.88", + "@directus/docs": "9.0.0-rc.90", + "@directus/extension-sdk": "9.0.0-rc.90", + "@directus/format-title": "9.0.0-rc.90", + "@directus/shared": "9.0.0-rc.90", "@fullcalendar/core": "5.9.0", "@fullcalendar/daygrid": "5.9.0", "@fullcalendar/interaction": "5.9.0", "@fullcalendar/list": "5.9.0", "@fullcalendar/timegrid": "5.9.0", + "@mapbox/mapbox-gl-draw": "1.3.0", + "@mapbox/mapbox-gl-draw-static-mode": "1.0.1", + "@mapbox/mapbox-gl-geocoder": "4.7.2", "@popperjs/core": "2.9.3", "@rollup/plugin-yaml": "3.1.0", "@sindresorhus/slugify": "2.1.0", "@tinymce/tinymce-vue": "4.0.4", + "@turf/meta": "6.5.0", "@types/base-64": "1.0.0", "@types/bytes": "3.1.1", "@types/codemirror": "5.60.2", "@types/color": "3.0.2", "@types/diff": "5.0.1", "@types/dompurify": "2.2.3", + "@types/geojson": "7946.0.8", "@types/lodash": "4.14.172", + "@types/mapbox__mapbox-gl-draw": "1.2.3", + "@types/mapbox__mapbox-gl-geocoder": "4.7.1", "@types/markdown-it": "12.0.3", "@types/marked": "2.0.4", "@types/mime-types": "2.1.0", "@types/ms": "0.7.31", "@types/qrcode": "1.4.1", - "@vitejs/plugin-vue": "1.3.0", + "@types/wellknown": "0.5.1", + "@vitejs/plugin-vue": "1.4.0", "@vue/cli-plugin-babel": "4.5.13", "@vue/cli-plugin-router": "4.5.13", "@vue/cli-plugin-typescript": "4.5.13", "@vue/cli-plugin-vuex": "4.5.13", "@vue/cli-service": "4.5.13", - "@vue/compiler-sfc": "3.1.5", + "@vue/compiler-sfc": "3.2.2", "axios": "0.21.1", "base-64": "1.0.0", "codemirror": "5.62.2", @@ -70,12 +78,14 @@ "front-matter": "4.0.2", "html-entities": "2.3.2", "jsonlint-mod": "1.7.6", + "maplibre-gl": "1.15.2", "marked": "2.1.3", "micromustache": "8.0.3", "mime": "2.5.2", "mitt": "3.0.0", - "nanoid": "3.1.23", - "pinia": "2.0.0-beta.5", + "nanoid": "3.1.25", + "p-queue": "7.1.0", + "pinia": "2.0.0-rc.4", "prettier": "2.3.2", "pretty-ms": "7.0.1", "qrcode": "1.4.4", @@ -84,12 +94,10 @@ "tinymce": "5.8.2", "typescript": "4.3.5", "vite": "2.4.4", - "vue": "3.1.5", + "vue": "3.2.2", "vue-i18n": "9.1.7", - "vue-router": "4.0.10", - "vuedraggable": "4.0.3" - }, - "dependencies": { - "p-queue": "^6.6.2" + "vue-router": "4.0.11", + "vuedraggable": "4.0.3", + "wellknown": "0.5.0" } } diff --git a/app/src/components/v-form/form-field-interface.vue b/app/src/components/v-form/form-field-interface.vue index ae5a3ef70f2b6..ebfa35f2c1cf0 100644 --- a/app/src/components/v-form/form-field-interface.vue +++ b/app/src/components/v-form/form-field-interface.vue @@ -23,6 +23,7 @@ :type="field.type" :collection="field.collection" :field="field.field" + :field-data="field" :primary-key="primaryKey" :length="field.schema && field.schema.max_length" @input="$emit('update:modelValue', $event)" diff --git a/app/src/displays/labels/labels.vue b/app/src/displays/labels/labels.vue index 37a4f3a6ecdfb..60a05605654ce 100644 --- a/app/src/displays/labels/labels.vue +++ b/app/src/displays/labels/labels.vue @@ -81,17 +81,29 @@ export default defineComponent({ return items.map((item) => { const choice = (props.choices || []).find((choice) => choice.value === item); + let itemStringValue: string; + + if (typeof item === 'object') { + itemStringValue = JSON.stringify(item); + } else { + if (props.format) { + itemStringValue = formatTitle(item); + } else { + itemStringValue = item; + } + } + if (choice === undefined) { return { value: item, - text: props.format ? formatTitle(item) : item, + text: itemStringValue, foreground: props.defaultForeground, background: props.defaultBackground, }; } else { return { value: item, - text: choice.text || (props.format ? formatTitle(item) : item), + text: choice.text || itemStringValue, foreground: choice.foreground || props.defaultForeground, background: choice.background || props.defaultBackground, }; diff --git a/app/src/displays/register.ts b/app/src/displays/register.ts index 0f66442800e8f..94ef6b5b9a477 100644 --- a/app/src/displays/register.ts +++ b/app/src/displays/register.ts @@ -15,9 +15,11 @@ export async function registerDisplays(app: App): Promise { : await import(/* @vite-ignore */ `${getRootPath()}extensions/displays/index.js`); displays.push(...customDisplays.default); - } catch { + } catch (err) { // eslint-disable-next-line no-console console.warn(`Couldn't load custom displays`); + // eslint-disable-next-line no-console + console.warn(err); } displaysRaw.value = displays; diff --git a/app/src/hydrate.ts b/app/src/hydrate.ts index 3b31c3032e70f..5af4461667f93 100644 --- a/app/src/hydrate.ts +++ b/app/src/hydrate.ts @@ -1,6 +1,7 @@ import { Language } from '@/lang'; import { setLanguage } from '@/lang/set-language'; import { register as registerModules, unregister as unregisterModules } from '@/modules/register'; +import { getBasemapSources } from '@/utils/geometry/basemap'; import { useAppStore, useCollectionsStore, @@ -63,6 +64,8 @@ export async function hydrate(stores = useStores()): Promise { await registerModules(); await setLanguage((userStore.currentUser?.language as Language) || 'en-US'); } + + appStore.basemap = getBasemapSources()[0].name; } catch (error) { appStore.error = error; } finally { diff --git a/app/src/interfaces/file/file.vue b/app/src/interfaces/file/file.vue index 01ae18a78476a..65681cf702f53 100644 --- a/app/src/interfaces/file/file.vue +++ b/app/src/interfaces/file/file.vue @@ -17,7 +17,7 @@ class="preview" :class="{ 'has-file': file, - 'is-svg': file && file.type.includes('svg'), + 'is-svg': file?.type?.includes('svg'), }" > diff --git a/app/src/interfaces/group-accordion/accordion-section.vue b/app/src/interfaces/group-accordion/accordion-section.vue index 8055edb179c26..3cd4faf5fef6c 100644 --- a/app/src/interfaces/group-accordion/accordion-section.vue +++ b/app/src/interfaces/group-accordion/accordion-section.vue @@ -7,7 +7,7 @@ -
+
{ return { ...item, - [relation.value.meta!.sort_field!]: index, + [relation.value.meta!.sort_field!]: index + 1, [relation.value.meta!.one_field!]: addSort(item[relation.value.meta!.one_field!]), }; }); @@ -227,7 +227,7 @@ export default defineComponent({ } function onDraggableChange() { - emit('input', stagedValues.value); + emitValue(stagedValues.value); } function useSelection() { diff --git a/app/src/interfaces/list/options.vue b/app/src/interfaces/list/options.vue index 3e62b4a6cb7fb..2899f38954a90 100644 --- a/app/src/interfaces/list/options.vue +++ b/app/src/interfaces/list/options.vue @@ -29,6 +29,7 @@ import Repeater from './list.vue'; import { Field, FieldMeta } from '@directus/shared/types'; import { fieldTypes } from '@/modules/settings/routes/data-model/field-detail/components/schema.vue'; import { DeepPartial } from '@directus/shared/types'; +import { translate } from '@/utils/translate-object-values'; export default defineComponent({ components: { Repeater }, @@ -110,7 +111,7 @@ export default defineComponent({ width: 'half', sort: 4, options: { - choices: fieldTypes, + choices: translate(fieldTypes), }, }, schema: null, diff --git a/app/src/interfaces/map/index.ts b/app/src/interfaces/map/index.ts new file mode 100644 index 0000000000000..d9893d8175fe7 --- /dev/null +++ b/app/src/interfaces/map/index.ts @@ -0,0 +1,14 @@ +import { defineInterface } from '@directus/shared/utils'; +import InterfaceMap from './map.vue'; +import Options from './options.vue'; + +export default defineInterface({ + id: 'map', + name: '$t:interfaces.map.map', + description: '$t:interfaces.map.description', + icon: 'map', + component: InterfaceMap, + types: ['geometry', 'json', 'string', 'text', 'binary', 'csv'], + options: Options, + recommendedDisplays: [], +}); diff --git a/app/src/interfaces/map/map.vue b/app/src/interfaces/map/map.vue new file mode 100644 index 0000000000000..e6594f9e0850a --- /dev/null +++ b/app/src/interfaces/map/map.vue @@ -0,0 +1,449 @@ + + + + + + + diff --git a/app/src/interfaces/map/options.vue b/app/src/interfaces/map/options.vue new file mode 100644 index 0000000000000..cd14a5ae25704 --- /dev/null +++ b/app/src/interfaces/map/options.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/app/src/interfaces/map/style.ts b/app/src/interfaces/map/style.ts new file mode 100644 index 0000000000000..6940e6f7af20f --- /dev/null +++ b/app/src/interfaces/map/style.ts @@ -0,0 +1,219 @@ +export default [ + { + id: 'directus-polygon-fill-inactive', + type: 'fill', + filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']], + paint: { + 'fill-color': '#3bb2d0', + 'fill-outline-color': '#3bb2d0', + 'fill-opacity': 0.1, + }, + }, + { + id: 'directus-polygon-fill-active', + type: 'fill', + filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']], + paint: { + 'fill-color': '#fbb03b', + 'fill-outline-color': '#fbb03b', + 'fill-opacity': 0.1, + }, + }, + { + id: 'directus-polygon-midpoint', + type: 'circle', + filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']], + paint: { + 'circle-radius': 3, + 'circle-color': '#fbb03b', + }, + }, + { + id: 'directus-polygon-stroke-inactive', + type: 'line', + filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']], + layout: { + 'line-cap': 'round', + 'line-join': 'round', + }, + paint: { + 'line-color': '#3bb2d0', + 'line-width': 2, + }, + }, + { + id: 'directus-polygon-stroke-active', + type: 'line', + filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']], + layout: { + 'line-cap': 'round', + 'line-join': 'round', + }, + paint: { + 'line-color': '#fbb03b', + 'line-dasharray': [0.2, 2], + 'line-width': 2, + }, + }, + { + id: 'directus-line-inactive', + type: 'line', + filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'LineString'], ['!=', 'mode', 'static']], + layout: { + 'line-cap': 'round', + 'line-join': 'round', + }, + paint: { + 'line-color': '#3bb2d0', + 'line-width': 2, + }, + }, + { + id: 'directus-line-active', + type: 'line', + filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']], + layout: { + 'line-cap': 'round', + 'line-join': 'round', + }, + paint: { + 'line-color': '#fbb03b', + 'line-dasharray': [0.2, 2], + 'line-width': 2, + }, + }, + { + id: 'directus-polygon-and-line-vertex-stroke-inactive', + type: 'circle', + filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']], + paint: { + 'circle-radius': 5, + 'circle-color': '#fff', + }, + }, + { + id: 'directus-polygon-and-line-vertex-inactive', + type: 'circle', + filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']], + paint: { + 'circle-radius': 3, + 'circle-color': '#fbb03b', + }, + }, + { + id: 'directus-points-shadow', + filter: [ + 'all', + ['==', 'active', 'false'], + ['==', '$type', 'Point'], + ['==', 'meta', 'feature'], + ['!=', 'meta', 'midpoint'], + ], + type: 'circle', + paint: { + 'circle-pitch-alignment': 'map', + 'circle-blur': 1, + 'circle-opacity': 0.5, + 'circle-radius': 6, + }, + }, + { + id: 'directus-point-inactive', + filter: [ + 'all', + ['==', '$type', 'Point'], + ['==', 'active', 'false'], + ['==', 'meta', 'feature'], + ['!=', 'meta', 'midpoint'], + ], + type: 'symbol', + layout: { + 'icon-image': 'place', + 'icon-anchor': 'bottom', + 'icon-allow-overlap': true, + 'icon-size': 2, + 'icon-offset': [0, 3], + }, + paint: { + 'icon-color': '#3bb2d0', + }, + }, + { + id: 'directus-point-active', + filter: [ + 'all', + ['==', '$type', 'Point'], + ['==', 'active', 'true'], + ['==', 'meta', 'feature'], + ['!=', 'meta', 'midpoint'], + ], + type: 'symbol', + layout: { + 'icon-image': 'place', + 'icon-anchor': 'bottom', + 'icon-allow-overlap': true, + 'icon-size': 2, + 'icon-offset': [0, 3], + }, + paint: { + 'icon-color': '#fbb03b', + }, + }, + { + id: 'directus-point-static', + type: 'symbol', + filter: [ + 'all', + ['==', '$type', 'Point'], + ['==', 'mode', 'static'], + ['==', 'meta', 'feature'], + ['!=', 'meta', 'midpoint'], + ], + layout: { + 'icon-image': 'place', + 'icon-anchor': 'bottom', + 'icon-allow-overlap': true, + 'icon-size': 2, + 'icon-offset': [0, 3], + }, + paint: { + 'icon-color': '#404040', + }, + }, + { + id: 'directus-polygon-fill-static', + type: 'fill', + filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']], + paint: { + 'fill-color': '#404040', + 'fill-outline-color': '#404040', + 'fill-opacity': 0.1, + }, + }, + { + id: 'directus-polygon-stroke-static', + type: 'line', + filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']], + layout: { + 'line-cap': 'round', + 'line-join': 'round', + }, + paint: { + 'line-color': '#404040', + 'line-width': 2, + }, + }, + { + id: 'directus-line-static', + type: 'line', + filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'LineString']], + layout: { + 'line-cap': 'round', + 'line-join': 'round', + }, + paint: { + 'line-color': '#404040', + 'line-width': 2, + }, + }, +]; diff --git a/app/src/interfaces/presentation-links/presentation-links.vue b/app/src/interfaces/presentation-links/presentation-links.vue index 2ca7321c25202..ef35b551329f1 100644 --- a/app/src/interfaces/presentation-links/presentation-links.vue +++ b/app/src/interfaces/presentation-links/presentation-links.vue @@ -64,35 +64,32 @@ export default defineComponent({ .action { &.info { - --v-button-icon-color: var(--white); - --v-button-background-color: var(--primary); - --v-button-background-color-hover: var(--primary-110); - --v-button-color: var(--white); - --v-button-color-hover: var(--white); + --v-button-background-color: var(--blue); + --v-button-background-color-hover: var(--blue-125); + --v-button-color: var(--blue-alt); + --v-button-color-hover: var(--blue-alt); } &.success { - --v-button-icon-color: var(--white); --v-button-background-color: var(--success); - --v-button-background-color-hover: var(--success-110); - --v-button-color: var(--white); - --v-button-color-hover: var(--white); + --v-button-background-color-hover: var(--success-125); + --v-button-color: var(--success-alt); + --v-button-color-hover: var(--success-alt); } &.warning { - --v-button-icon-color: var(--white); --v-button-background-color: var(--warning); - --v-button-background-color-hover: var(--warning-110); - --v-button-color: var(--white); - --v-button-color-hover: var(--white); + --v-button-background-color-hover: var(--warning-125); + --v-button-color: var(--warning-alt); + --v-button-color-hover: var(--warning-alt); } &.danger { --v-button-icon-color: var(--white); --v-button-background-color: var(--danger); - --v-button-background-color-hover: var(--danger-110); - --v-button-color: var(--white); - --v-button-color-hover: var(--white); + --v-button-background-color-hover: var(--danger-125); + --v-button-color: var(--danger-alt); + --v-button-color-hover: var(--danger-alt); } } diff --git a/app/src/interfaces/register.ts b/app/src/interfaces/register.ts index 779b3654ef149..d5a6d46ae0948 100644 --- a/app/src/interfaces/register.ts +++ b/app/src/interfaces/register.ts @@ -16,9 +16,11 @@ export async function registerInterfaces(app: App): Promise { : await import(/* @vite-ignore */ `${getRootPath()}extensions/interfaces/index.js`); interfaces.push(...customInterfaces.default); - } catch { + } catch (err) { // eslint-disable-next-line no-console console.warn(`Couldn't load custom interfaces`); + // eslint-disable-next-line no-console + console.warn(err); } interfacesRaw.value = interfaces; diff --git a/app/src/lang/translations/af-ZA.yaml b/app/src/lang/translations/af-ZA.yaml index 8543c3113c0e9..7ad871672915b 100644 --- a/app/src/lang/translations/af-ZA.yaml +++ b/app/src/lang/translations/af-ZA.yaml @@ -80,6 +80,8 @@ fields: display_template: Modelvorm directus_roles: description: Beskrywing + directus_webhooks: + status: Status comment: Kommentaar description: Beskrywing email: E-pos diff --git a/app/src/lang/translations/ar-SA.yaml b/app/src/lang/translations/ar-SA.yaml index a58d2fec3a3b1..717991791b77e 100644 --- a/app/src/lang/translations/ar-SA.yaml +++ b/app/src/lang/translations/ar-SA.yaml @@ -700,6 +700,7 @@ fields: theme: السمة tfa_secret: التحقق بخطوتين status: حالة + status_active: مفعل role: الدور last_page: آخر صفحة last_access: آخر وصول @@ -720,6 +721,9 @@ fields: icon: أيقونة الدور description: التفاصيل users: المستخدمون في الدور + directus_webhooks: + name: الاسم + status: حالة field_options: directus_collections: track_activity_revisions: تتبع النشاط والتنقيحات @@ -868,6 +872,8 @@ interfaces: display_template: عرض القالب input-rich-text-md: description: أدخل و معاينة markdown + map: + zoom: تكبير/تصغير list-o2m: description: حدد عدة عناصر ذات صلة system-folder: diff --git a/app/src/lang/translations/bg-BG.yaml b/app/src/lang/translations/bg-BG.yaml index 11efc65260059..bffc7f417cdec 100644 --- a/app/src/lang/translations/bg-BG.yaml +++ b/app/src/lang/translations/bg-BG.yaml @@ -390,6 +390,7 @@ display_template_not_setup: Опциите за показване на шабл collection_field_not_setup: Опциите на полето от колекцията са неправилно конфигурирани select_a_collection: Избор на колекция active: Активен +inactive: Неактивен users: Потребители activity: Активности webhooks: Уеб-куки @@ -402,6 +403,7 @@ documentation: Документация sidebar: Страничен панел duration: Продължителност charset: Набор символи +second: секунда file_moved: Файлът е преместен collection_created: Колекцията е създадена modified_on: Променен на @@ -443,6 +445,7 @@ errors: UNPROCESSABLE_ENTITY: Необработен обект INTERNAL_SERVER_ERROR: Неочаквана грешка NOT_NULL_VIOLATION: Стойността не трябва да е null +security: Сигурност value_hashed: Хеширана стойност bookmark_name: Име на отметка... create_bookmark: Създаване на отметка @@ -563,6 +566,7 @@ user: Потребител no_presets: Няма заготовки no_presets_copy: Все още няма запазени заготовки или отметки. no_presets_cta: Добавяне на заготовка +presets_only: Само заготовки create: Създаване on_create: При създаване on_update: При промяна @@ -578,6 +582,7 @@ label: Етикет image_url: URL на изображение alt_text: Алтернативен текст media: Медия +quality: Качество width: Ширина height: Височина source: Източник @@ -772,12 +777,23 @@ fields: title: Заглавие description: Описание tags: Тагове + user_preferences: Потребителски настройки language: Език theme: Тема + theme_auto: Автоматично (според системата) + theme_light: Светъл режим + theme_dark: Тъмен режим tfa_secret: Двуфакторна автентикация + admin_options: Административни настройки status: Статус + status_draft: Чернова + status_invited: Неактивен + status_active: Активен + status_suspended: Замразен + status_archived: Архивиран role: Роля token: Токен + token_placeholder: Въвеждане на сигурен токен... last_page: Последна страница last_access: Последно посещение directus_settings: @@ -785,13 +801,17 @@ fields: project_url: URL на проекта project_color: Цвят на проекта project_logo: Лого на проекта + public_pages: Публични страници public_foreground: Публично изображение public_background: Публичен фон public_note: Публично съобщение auth_password_policy: Политика за пароли auth_login_attempts: Брой за опити за вход + files_and_thumbnails: Файлове и картинки + storage_default_folder: Папка по подразбиране storage_asset_presets: Заготовки за съхранените файлове storage_asset_transform: Трансформации на съхранените файлове + overrides: Отмяна custom_css: Потребителски CSS directus_fields: collection: Име на колекцията @@ -812,6 +832,14 @@ fields: users: Потребители към роля module_list: Модули collection_list: Навигация в колекции + directus_webhooks: + name: Име + method: Метод + status: Статус + data: Данни + data_label: Изпращане на данните + triggers: Тригери + actions: Действия field_options: directus_collections: track_activity_revisions: Проследяване на активността и ревизиите @@ -892,17 +920,22 @@ sort_direction: Ред на подредба sort_asc: Възходящо сортиране sort_desc: Низходящо сортиране template: Шаблон +require_value_to_be_set: Изисква стойност при задаване translation: Превод value: Стойност view_project: Преглед на проект report_error: Докладване на грешка +start: Начало interfaces: group-accordion: name: Акордеон description: Показване на полетата или групите по секции, като акордеон + start: Начало all_closed: Всички са затворени first_opened: Първият е отворен all_opened: Всички са отворени + accordion_mode: Акордионен режим + max_one_section_open: До 1 отворена секция presentation-links: presentation-links: Бутони с връзки links: Връзки @@ -923,6 +956,8 @@ interfaces: description: Избор измежду множество опции чрез вложени отметки value_combining: Комбинация от стойности value_combining_note: Контрол върху стойността за запазване при направен избор. + show_all: Показване на всички + show_selected: Показване на избраното input-code: code: Код description: Писане или споделяне на кодови откъси @@ -1016,6 +1051,8 @@ interfaces: box: Блоков / Вложен imageToken: Токън за изображенията imageToken_label: Какъв (статичен) токън да се добави, към адресите на изображенията + map: + zoom: Мащабиране presentation-notice: notice: Пояснение description: Показване на кратко пояснение @@ -1107,6 +1144,17 @@ interfaces: value_path: Път до стойността trigger: Задействане rate: Оценка + group-raw: + name: Обикновена група + description: Изобразяване както е + group-detail: + name: Детайлна група + description: Изобразяване на полетата с възможност за свиване + show_header: Показване на заглавието + header_icon: Икона на заглавието + header_color: Цвят на заглавието + start_open: Отворена в началото + start_closed: Затворена в началото displays: boolean: boolean: Булев diff --git a/app/src/lang/translations/ca-ES.yaml b/app/src/lang/translations/ca-ES.yaml index 661947d8802b9..23d37537e9178 100644 --- a/app/src/lang/translations/ca-ES.yaml +++ b/app/src/lang/translations/ca-ES.yaml @@ -161,6 +161,8 @@ fields: name: Nom del Rol description: Descripció users: Usuaris al rol + directus_webhooks: + status: Estat comment: Comentari description: Descripció done: Fet diff --git a/app/src/lang/translations/cs-CZ.yaml b/app/src/lang/translations/cs-CZ.yaml index 2a595bc31bd35..dc49d3c73d9a7 100644 --- a/app/src/lang/translations/cs-CZ.yaml +++ b/app/src/lang/translations/cs-CZ.yaml @@ -315,6 +315,9 @@ fields: directus_roles: name: Jméno role description: Popis + directus_webhooks: + name: Jméno + status: Stav comment: Komentář delete_field_are_you_sure: >- Opravdu chcete smazat pole "{field}"? Tuto akci nelze vrátit zpět. diff --git a/app/src/lang/translations/da-DK.yaml b/app/src/lang/translations/da-DK.yaml index beeff6166d234..8cf8157dfcac9 100644 --- a/app/src/lang/translations/da-DK.yaml +++ b/app/src/lang/translations/da-DK.yaml @@ -320,6 +320,9 @@ fields: directus_roles: name: Rollenavn description: Beskrivelse + directus_webhooks: + name: Navn + status: Status comment: Kommenter delete_field_are_you_sure: >- Er du sikker på du vil slette feltet "{field}"? Denne handling kan ikke fortrydes. diff --git a/app/src/lang/translations/de-DE.yaml b/app/src/lang/translations/de-DE.yaml index 91cdc90433f8d..8395acd1361e6 100644 --- a/app/src/lang/translations/de-DE.yaml +++ b/app/src/lang/translations/de-DE.yaml @@ -759,6 +759,7 @@ fields: theme: Design tfa_secret: Zwei-Faktor-Authentifizierung status: Status + status_active: Aktiv role: Rolle token: Token last_page: Letzte Seite @@ -795,6 +796,9 @@ fields: users: Benutzer in Rolle module_list: Modulnavigation collection_list: Sammlungsnavigation + directus_webhooks: + name: Name + status: Status field_options: directus_collections: track_activity_revisions: Verfolge Aktivitäten und Änderungen @@ -985,6 +989,8 @@ interfaces: box: Block / Inline imageToken: Bild-Token imageToken_label: Welcher (statischer) Token, der an Bildquellen angehängt werden soll + map: + zoom: Zoom presentation-notice: notice: Anmerkung description: Kurze Anmerkung anzeigen diff --git a/app/src/lang/translations/el-GR.yaml b/app/src/lang/translations/el-GR.yaml index 1d06cd55d4c76..3243563ac0e6e 100644 --- a/app/src/lang/translations/el-GR.yaml +++ b/app/src/lang/translations/el-GR.yaml @@ -222,6 +222,9 @@ fields: directus_roles: name: Όνομα Ρόλου description: Περιγραφή + directus_webhooks: + name: Όνομα + status: Κατάσταση comment: Σχόλιο delete_field_are_you_sure: >- Είσαι σίγουρος ότι θέλεις να διαγράψεις το πεδίο "{field}"; Η ενέργεια αυτή δεν μπορεί να αναιρεθεί. diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml index 1ed848dff6b9b..8c2fff109c05d 100644 --- a/app/src/lang/translations/en-US.yaml +++ b/app/src/lang/translations/en-US.yaml @@ -396,6 +396,7 @@ display_template_not_setup: The display template option is misconfigured collection_field_not_setup: The collection field option is misconfigured select_a_collection: Select a Collection active: Active +inactive: Inactive users: Users activity: Activity webhooks: Webhooks @@ -450,6 +451,7 @@ errors: UNPROCESSABLE_ENTITY: Unprocessable entity INTERNAL_SERVER_ERROR: Unexpected Error NOT_NULL_VIOLATION: Value can't be null +security: Security value_hashed: Value Securely Hashed bookmark_name: Bookmark name... create_bookmark: Create Bookmark @@ -571,6 +573,7 @@ user: User no_presets: No Presets no_presets_copy: No presets or bookmarks have been saved yet. no_presets_cta: Add Preset +presets_only: Presets Only create: Create on_create: On Create on_update: On Update @@ -586,6 +589,7 @@ label: Label image_url: Image Url alt_text: Alternative Text media: Media +quality: Quality width: Width height: Height source: Source @@ -786,12 +790,23 @@ fields: title: Title description: Description tags: Tags + user_preferences: User Preferences language: Language theme: Theme + theme_auto: Automatic (Based on System) + theme_light: Light Mode + theme_dark: Dark Mode tfa_secret: Two-Factor Authentication + admin_options: Admin Options status: Status + status_draft: Draft + status_invited: Inactive + status_active: Active + status_suspended: Suspended + status_archived: Archived role: Role token: Token + token_placeholder: Enter a secure access token... last_page: Last Page last_access: Last Access directus_settings: @@ -799,13 +814,17 @@ fields: project_url: Project URL project_color: Project Color project_logo: Project Logo + public_pages: Public Pages public_foreground: Public Foreground public_background: Public Background public_note: Public Note auth_password_policy: Auth Password Policy auth_login_attempts: Auth Login Attempts + files_and_thumbnails: Files & Thumbnails + storage_default_folder: Storage Default Folder storage_asset_presets: Storage Asset Presets storage_asset_transform: Storage Asset Transform + overrides: App Overrides custom_css: Custom CSS directus_fields: collection: Collection Name @@ -826,6 +845,14 @@ fields: users: Users in Role module_list: Module Navigation collection_list: Collection Navigation + directus_webhooks: + name: Name + method: Method + status: Status + data: Data + data_label: Send Event Data + triggers: Triggers + actions: Actions field_options: directus_collections: track_activity_revisions: Track Activity & Revisions diff --git a/app/src/lang/translations/es-419.yaml b/app/src/lang/translations/es-419.yaml index 1a65adc81f111..9f09acc955d62 100644 --- a/app/src/lang/translations/es-419.yaml +++ b/app/src/lang/translations/es-419.yaml @@ -71,6 +71,7 @@ delete_bookmark_copy: >- logoutReason: SIGN_OUT: Cerrar sesión SESSION_EXPIRED: Sesión expirada +public_label: Público public_description: Control de datos disponibles por API sin autentificación. not_allowed: No permitido directus_version: Versión de Directus @@ -308,6 +309,7 @@ drag_mode: Modo arrastrar cancel_crop: Cancelar corte original: Original url: URL +import_label: Importar file_details: Detalles del archivo dimensions: Dimensiones size: Tamaño @@ -397,6 +399,7 @@ documentation: Documentación sidebar: Barra Lateral duration: Duración charset: Codificación +second: segundo file_moved: Archivo Movido collection_created: Colección Creada modified_on: Modificado en @@ -435,6 +438,7 @@ errors: USER_SUSPENDED: Usuario Suspendido CONTAINS_NULL_VALUES: El campo contiene valores nulos UNKNOWN: Error Inesperado + UNPROCESSABLE_ENTITY: Entidad no procesable INTERNAL_SERVER_ERROR: Error Inesperado NOT_NULL_VIOLATION: El valor no puede ser nulo value_hashed: Valor Hasheado de Manera Segura @@ -504,6 +508,10 @@ operators: nnull: No es Nulo contains: Contiene ncontains: No contiene + starts_with: Comienza con + nstarts_with: No comienza con + ends_with: Termina con + nends_with: No termina con between: Está Entre nbetween: No está Entre empty: Está vacío @@ -548,6 +556,7 @@ no_results_copy: Ajustar o limpiar filtros de búsqueda para ver los resultados. clear_filters: Limpiar Filtros saves_automatically: Guardar Automáticamente role: Rol +rule: Regla user: Usuario no_presets: Sin Predefinidos no_presets_copy: Los Predefinidos o Marcadores no se han guardado aún. @@ -765,6 +774,7 @@ fields: theme: Tema tfa_secret: Autenticación de dos pasos status: Estatus + status_active: Activo role: Rol token: Token last_page: Última página @@ -801,6 +811,9 @@ fields: users: Usuarios en el Rol module_list: Navegación en Módulo collection_list: Navegación en Colección + directus_webhooks: + name: Nombre + status: Estatus field_options: directus_collections: track_activity_revisions: Rastrear actividad y revisiones @@ -819,11 +832,17 @@ relational_triggers: Disparadores Relacionales referential_action_field_label_m2o: Al eliminar {collection}... referential_action_field_label_o2m: Al deseleccionar {collection}... referential_action_no_action: Evitar la eliminación +referential_action_cascade: Eliminar el item {collection} (cascada) referential_action_set_null: Anular el campo {field} referential_action_set_default: Establecer {field} a su valor predeterminado choose_action: Elegir acción +continue_label: Continuar +continue_as: >- + Actualmente {name} ha iniciado sesión. Si reconoce esta cuenta, presione continuar. editing_role: 'Rol {role}' creating_webhook: Creando Webhook +default_label: Por defecto +delete_label: Eliminar delete_are_you_sure: >- Esta acción es permanente y no puede ser revertida. ¿Realmente le gustaría proceder? delete_field_are_you_sure: >- @@ -875,11 +894,18 @@ sort_direction: Dirección de Ordenamiento sort_asc: Ordenar ascendente sort_desc: Orden descendente template: Plantilla +require_value_to_be_set: Requiere valor para ser establecido translation: Traducción value: Valor view_project: Ver Proyecto report_error: Reportar Error +start: Iniciar interfaces: + group-accordion: + start: Iniciar + all_closed: Todo Cerrado + first_opened: Primero Abierto + all_opened: Todo Abierto presentation-links: presentation-links: Botón de enlace links: Enlaces @@ -988,8 +1014,20 @@ interfaces: box: Bloque / Alineación imageToken: Token de Imagen imageToken_label: Qué token (estático) añadir a las fuentes de la imagen + map: + zoom: Lupa + presentation-notice: + notice: Aviso + list-o2m: + one-to-many: Uno a Muchos + no_collection: No se encontró la colección system-folder: folder: Carpeta + description: Seleccione una carpeta + root_name: Raíz de la Biblioteca de Archivos + system_default: Valores por defecto del sistema + list: + repeater: Repetidor slider: slider: Deslizador description: Seleccionar un número usando un deslizador @@ -1010,8 +1048,18 @@ interfaces: add_tags: Agregar etiquetas... input: mask: Enmascarado + mask_label: Ocultar el valor real + clear: Valor Limpiado + clear_label: Guardar como cadena vacía + minimum_value: Valor Mínimo + maximum_value: Valor Máximo + step_interval: Intervalo de Pasos + slug: Slugify + input-multiline: + textarea: Área de Texto boolean: toggle: Alternar + description: Alternar entre Activado y Desactivado label_default: Habilitado translations: display_template: Plantilla de Presentación @@ -1024,6 +1072,13 @@ interfaces: auto: Automático dropdown: Lista Desplegable modal: Diálogo + input-rich-text-html: + wysiwyg: WYSIWYG + toolbar: Barra de Herramientas + custom_formats: Formatos Personalizados + options_override: Sobrescribir Opciones + input-autocomplete-api: + results_path: Ruta de Resultados displays: boolean: boolean: Booleano @@ -1126,3 +1181,7 @@ layouts: comfortable: Cómoda compact: Compacto cozy: Cómoda + calendar: + calendar: Calendario + start_date_field: Campo Fecha de Inicio + end_date_field: Campo Fecha de Fin diff --git a/app/src/lang/translations/es-CL.yaml b/app/src/lang/translations/es-CL.yaml index 0d8127239dc1d..d6ac80d33ae34 100644 --- a/app/src/lang/translations/es-CL.yaml +++ b/app/src/lang/translations/es-CL.yaml @@ -309,6 +309,7 @@ drag_mode: Modo Arrastrar cancel_crop: No Cortar original: Original url: URL +import_label: Importar file_details: Detalles de Archivo dimensions: Dimensiones size: Tamaño @@ -398,6 +399,7 @@ documentation: Documentación sidebar: Barra Lateral duration: Duración charset: Codificación +second: segundo file_moved: Archivo Movido collection_created: Colección Creada modified_on: Modificado en @@ -436,6 +438,7 @@ errors: USER_SUSPENDED: Usuario Suspendido CONTAINS_NULL_VALUES: El campo contiene valores nulos UNKNOWN: Error Inesperado + UNPROCESSABLE_ENTITY: Entidad no procesable INTERNAL_SERVER_ERROR: Error Inesperado NOT_NULL_VIOLATION: El valor no puede ser nulo value_hashed: Valor Hasheado de Manera Segura @@ -505,6 +508,10 @@ operators: nnull: No es Nulo contains: Contiene ncontains: No contiene + starts_with: Comienza con + nstarts_with: No comienza con + ends_with: Termina con + nends_with: No termina con between: Está Entre nbetween: No está Entre empty: Esta vacío @@ -549,6 +556,7 @@ no_results_copy: Ajustar o limpiar filtros de búsqueda para ver los resultados. clear_filters: Limpiar Filtros saves_automatically: Guardar Automáticamente role: Rol +rule: Regla user: Usuario no_presets: Sin Predefinidos no_presets_copy: Los Predefinidos o Marcadores no se han guardado aún. @@ -766,6 +774,7 @@ fields: theme: Tema tfa_secret: Autenticación de dos factores status: Estado + status_active: Activo role: Rol token: Token last_page: Última Página @@ -802,6 +811,9 @@ fields: users: Usuarios en el Rol module_list: Navegación en Módulo collection_list: Navegación en Colección + directus_webhooks: + name: Nombre + status: Estado field_options: directus_collections: track_activity_revisions: Rastrear actividad y revisiones @@ -820,11 +832,17 @@ relational_triggers: Disparadores Relacionales referential_action_field_label_m2o: Al eliminar {collection}... referential_action_field_label_o2m: Al deseleccionar {collection}... referential_action_no_action: Evitar la eliminación +referential_action_cascade: Eliminar el item {collection} (cascada) referential_action_set_null: Anular el campo {field} referential_action_set_default: Establecer {field} a su valor predeterminado choose_action: Elegir acción +continue_label: Continuar +continue_as: >- + Actualmente {name} ha iniciado sesión. Si reconoce esta cuenta, presione continuar. editing_role: 'Rol {role}' creating_webhook: Creando Webhook +default_label: Por defecto +delete_label: Eliminar delete_are_you_sure: >- Esta acción es permanente y no puede ser revertida. ¿Realmente le gustaría proceder? delete_field_are_you_sure: >- @@ -876,11 +894,18 @@ sort_direction: Dirección de Ordenamiento sort_asc: Ordenar ascendente sort_desc: Orden descendente template: Plantilla +require_value_to_be_set: Requiere valor para ser establecido translation: Traducción value: Valor view_project: Ver Proyecto report_error: Reportar Error +start: Iniciar interfaces: + group-accordion: + start: Iniciar + all_closed: Todo Cerrado + first_opened: Primero Abierto + all_opened: Todo Abierto presentation-links: presentation-links: Botón de enlace links: Enlaces @@ -989,8 +1014,20 @@ interfaces: box: Bloque / Alineación imageToken: Token de Imagen imageToken_label: Qué token (estático) añadir a las fuentes de la imagen + map: + zoom: Zoom + presentation-notice: + notice: Aviso + list-o2m: + one-to-many: Uno a Muchos + no_collection: No se encontró la colección system-folder: folder: Directorio + description: Seleccione una carpeta + root_name: Raíz de la Biblioteca de Archivos + system_default: Valores por defecto del sistema + list: + repeater: Repetidor slider: slider: Deslizador description: Seleccionar un número usando un deslizador @@ -1011,8 +1048,18 @@ interfaces: add_tags: Agregar etiquetas... input: mask: Enmascarado + mask_label: Ocultar el valor real + clear: Valor Limpiado + clear_label: Guardar como cadena vacía + minimum_value: Valor Mínimo + maximum_value: Valor Máximo + step_interval: Intervalo de Pasos + slug: Slugify + input-multiline: + textarea: Área de Texto boolean: toggle: Alternar + description: Alternar entre Activado y Desactivado label_default: Habilitado translations: display_template: Plantilla de Presentación @@ -1025,6 +1072,13 @@ interfaces: auto: Automático dropdown: Lista Desplegable modal: Diálogo + input-rich-text-html: + wysiwyg: WYSIWYG + toolbar: Barra de Herramientas + custom_formats: Formatos Personalizados + options_override: Sobrescribir Opciones + input-autocomplete-api: + results_path: Ruta de Resultados displays: boolean: boolean: Booleano @@ -1127,3 +1181,7 @@ layouts: comfortable: Cómoda compact: Compactar cozy: Cómoda + calendar: + calendar: Calendario + start_date_field: Campo Fecha de Inicio + end_date_field: Campo Fecha de Fin diff --git a/app/src/lang/translations/es-ES.yaml b/app/src/lang/translations/es-ES.yaml index dc5133ee8420e..65eb4c4ee2736 100644 --- a/app/src/lang/translations/es-ES.yaml +++ b/app/src/lang/translations/es-ES.yaml @@ -36,6 +36,7 @@ role_name: Nombre del Rol branch: Rama leaf: Hoja indeterminate: Indeterminado +exclusive: Exclusivo db_only_click_to_configure: 'Solo Base de Datos: Clic para Configurar ' show_archived_items: Mostrar Elementos Archivados edited: Valor Editado @@ -47,6 +48,7 @@ create_role: Crear Rol create_user: Crear Usuario create_webhook: Crear Webhook invite_users: Invitar Usuarios +email_examples: "admin{'@'}ejemplo.com, usuario{'@'}ejemplo.com..." invite: Invitar email_already_invited: El correo electrónico "{email}" ya ha sido invitado emails: Correos Electrónicos @@ -68,6 +70,7 @@ delete_bookmark_copy: >- logoutReason: SIGN_OUT: Sesión terminada SESSION_EXPIRED: Sesión expirada +public_label: Público public_description: Controla qué datos de la API estarán disponibles sin requerir autenticarse not_allowed: No Permitido directus_version: Versión de Directus @@ -305,6 +308,7 @@ drag_mode: Modo Arrastrar cancel_crop: No Cortar original: Original url: URL +import_label: Importar file_details: Detalles de Archivo dimensions: Dimensiones size: Tamaño @@ -394,6 +398,7 @@ documentation: Documentación sidebar: Barra Lateral duration: Duración charset: Codificación +second: segundo file_moved: Archivo Movido collection_created: Colección Creada modified_on: Modificado en @@ -432,6 +437,7 @@ errors: USER_SUSPENDED: Usuario Suspendido CONTAINS_NULL_VALUES: El campo contiene valores nulos UNKNOWN: Error Inesperado + UNPROCESSABLE_ENTITY: Entidad no procesable INTERNAL_SERVER_ERROR: Error Inesperado NOT_NULL_VIOLATION: El valor no puede ser nulo value_hashed: Valor Hasheado de Manera Segura @@ -501,6 +507,10 @@ operators: nnull: No es Nulo contains: Contiene ncontains: No contiene + starts_with: Comienza con + nstarts_with: No comienza con + ends_with: Termina con + nends_with: No termina con between: Está Entre nbetween: No está Entre empty: Está vacío @@ -545,6 +555,7 @@ no_results_copy: Ajustar o limpiar filtros de búsqueda para ver los resultados. clear_filters: Limpiar Filtros saves_automatically: Guardar Automáticamente role: Rol +rule: Regla user: Usuario no_presets: Sin Predefinidos no_presets_copy: Los Predefinidos o Marcadores no se han guardado aún. @@ -762,6 +773,7 @@ fields: theme: Tema tfa_secret: Autentificación en 2 Pasos status: Estado + status_active: Activo role: Rol token: Token last_page: Última Página @@ -798,6 +810,9 @@ fields: users: Usuarios en el Rol module_list: Navegación en Módulo collection_list: Navegación en Colección + directus_webhooks: + name: Nombre + status: Estado field_options: directus_collections: track_activity_revisions: Rastrear actividad y revisiones @@ -816,11 +831,17 @@ relational_triggers: Disparadores Relacionales referential_action_field_label_m2o: Al eliminar {collection}... referential_action_field_label_o2m: Al deseleccionar {collection}... referential_action_no_action: Evitar la eliminación +referential_action_cascade: Eliminar el item {collection} (cascada) referential_action_set_null: Anular el campo {field} referential_action_set_default: Establecer {field} a su valor predeterminado choose_action: Elegir acción +continue_label: Continuar +continue_as: >- + Actualmente {name} ha iniciado sesión. Si reconoce esta cuenta, presione continuar. editing_role: 'Rol {role}' creating_webhook: Creando Webhook +default_label: Por defecto +delete_label: Eliminar delete_are_you_sure: >- Esta acción es permanente y no puede ser revertida. ¿Realmente le gustaría proceder? delete_field_are_you_sure: >- @@ -872,11 +893,18 @@ sort_direction: Dirección de Ordenamiento sort_asc: Ordenar ascendente sort_desc: Orden descendente template: Plantilla +require_value_to_be_set: Requiere valor para ser establecido translation: Traducción value: Valor view_project: Ver Proyecto report_error: Reportar Error +start: Iniciar interfaces: + group-accordion: + start: Iniciar + all_closed: Todo Cerrado + first_opened: Primero Abierto + all_opened: Todo Abierto presentation-links: presentation-links: Botón de enlace links: Enlaces @@ -985,8 +1013,20 @@ interfaces: box: Bloque / Alineación imageToken: Token de Imagen imageToken_label: Qué token (estático) añadir a las fuentes de la imagen + map: + zoom: Zoom + presentation-notice: + notice: Aviso + list-o2m: + one-to-many: Uno a Muchos + no_collection: No se encontró la colección system-folder: folder: Directorio + description: Seleccione una carpeta + root_name: Raíz de la Biblioteca de Archivos + system_default: Valores por defecto del sistema + list: + repeater: Repetidor slider: slider: Deslizador description: Seleccionar un número usando un deslizador @@ -1007,8 +1047,18 @@ interfaces: add_tags: Agregar etiquetas... input: mask: Enmascarado + mask_label: Ocultar el valor real + clear: Valor Limpiado + clear_label: Guardar como cadena vacía + minimum_value: Valor Mínimo + maximum_value: Valor Máximo + step_interval: Intervalo de Pasos + slug: Slugify + input-multiline: + textarea: Área de Texto boolean: toggle: Alternar + description: Alternar entre Activado y Desactivado label_default: Habilitado translations: display_template: Plantilla de Presentación @@ -1021,6 +1071,13 @@ interfaces: auto: Automático dropdown: Lista Desplegable modal: Diálogo + input-rich-text-html: + wysiwyg: WYSIWYG + toolbar: Barra de Herramientas + custom_formats: Formatos Personalizados + options_override: Sobrescribir Opciones + input-autocomplete-api: + results_path: Ruta de Resultados displays: boolean: boolean: Booleano @@ -1123,3 +1180,7 @@ layouts: comfortable: Cómoda compact: Compacto cozy: Cómoda + calendar: + calendar: Calendario + start_date_field: Campo Fecha de Inicio + end_date_field: Campo Fecha de Fin diff --git a/app/src/lang/translations/et-EE.yaml b/app/src/lang/translations/et-EE.yaml index 7aabfac7b2da3..9d66eb2f70480 100644 --- a/app/src/lang/translations/et-EE.yaml +++ b/app/src/lang/translations/et-EE.yaml @@ -70,7 +70,7 @@ os_type: OS Tüüp os_version: OS Versioon os_uptime: OS Ülevaloleku aeg os_totalmem: OS Mälu -archive: Arhiveeri +archive: Arhiiv archive_confirm: Oled kindel, et soovid eemaldada selle üksuse? archive_confirm_count: >- Üksuseid pole valitud | Oled kindel, et soovid arhiveerida seda üksust? | Oled kindel, et soovid arhiveerida neid {count} üksuseid? @@ -712,7 +712,7 @@ fields: hidden: Peidetud singleton: Üksik translations: Andmekogu nimetuste tõlked - archive_app_filter: Arhiveeri rakenduse filter + archive_app_filter: Arhiveerimisfilter archive_value: Arhiveeri väärtus unarchive_value: Aktiveeri väärtus sort_field: Sorteerimisväli @@ -755,6 +755,8 @@ fields: theme: Teema tfa_secret: Kaheastmeline autentimine status: Staatus + status_active: aktiivne + status_archived: Arhiveeritud role: Roll token: Kontrollkood last_page: Viimane leht @@ -791,6 +793,9 @@ fields: users: Kasutajad selles rollis module_list: Moodulite näitamine collection_list: Andmekogude näitamine + directus_webhooks: + name: Nimi + status: Staatus field_options: directus_collections: track_activity_revisions: Salvesta muudatuste statistika @@ -979,6 +984,8 @@ interfaces: box: Plokk / Tekstisisene imageToken: Pildi kood imageToken_label: Milline (staatiline) kood lisada piltide allikatele + map: + zoom: Suurenda presentation-notice: notice: Teade description: Näita lühikest teadet diff --git a/app/src/lang/translations/fi-FI.yaml b/app/src/lang/translations/fi-FI.yaml index 31cd04da956f3..ea47a52f0d395 100644 --- a/app/src/lang/translations/fi-FI.yaml +++ b/app/src/lang/translations/fi-FI.yaml @@ -753,6 +753,7 @@ fields: theme: Teema tfa_secret: Kaksivaiheinen todennus status: Tila + status_active: Aktiivinen role: Rooli token: Token last_page: Viimeinen sivu @@ -789,6 +790,9 @@ fields: users: Käyttäjät roolissa module_list: Moduulien navigointi collection_list: Kokoelman navigointi + directus_webhooks: + name: Nimi + status: Tila field_options: directus_collections: track_activity_revisions: Seuraa tapahtumia ja versioita @@ -972,6 +976,8 @@ interfaces: box: Lohko / rivillä imageToken: Kuvatunnus imageToken_label: Mikä (staattinen) tunnus lisätään kuvien lähteisiin + map: + zoom: Lähennä presentation-notice: notice: Ilmoitus description: Näytä lyhyt ilmoitus diff --git a/app/src/lang/translations/fr-FR.yaml b/app/src/lang/translations/fr-FR.yaml index 42c3a3a5edefa..7ec726b715a3d 100644 --- a/app/src/lang/translations/fr-FR.yaml +++ b/app/src/lang/translations/fr-FR.yaml @@ -771,6 +771,7 @@ fields: theme: Thème tfa_secret: Authentification à deux facteurs status: État + status_active: Actif role: Rôle token: Token last_page: Dernière page @@ -807,6 +808,9 @@ fields: users: Membres avec le rôle module_list: Module de Navigation collection_list: Navigation de la collection + directus_webhooks: + name: Nom + status: État field_options: directus_collections: track_activity_revisions: Enregistrer les activités et l'historique @@ -1002,6 +1006,8 @@ interfaces: box: Bloc / Inline imageToken: Token d'image imageToken_label: Quel token (statique) ajouter aux sources d'images + map: + zoom: Zoom presentation-notice: notice: Remarque description: Afficher une courte remarque diff --git a/app/src/lang/translations/he-IL.yaml b/app/src/lang/translations/he-IL.yaml index 0b049436baa7d..69ec77658bfef 100644 --- a/app/src/lang/translations/he-IL.yaml +++ b/app/src/lang/translations/he-IL.yaml @@ -89,6 +89,8 @@ fields: directus_roles: name: שם תפקיד description: תיאור + directus_webhooks: + status: סטטוס comment: הערה description: תיאור email: דוא"ל diff --git a/app/src/lang/translations/hi-IN.yaml b/app/src/lang/translations/hi-IN.yaml index ca918eaca9d5e..2adeff2c5ec65 100644 --- a/app/src/lang/translations/hi-IN.yaml +++ b/app/src/lang/translations/hi-IN.yaml @@ -238,6 +238,8 @@ fields: translation: फ़ील्ड नाम अनुवाद directus_roles: name: भूमिका नाम + directus_webhooks: + name: नाम interfaces: presentation-links: primary: प्राइमरी diff --git a/app/src/lang/translations/hu-HU.yaml b/app/src/lang/translations/hu-HU.yaml index 629168a90190f..240ac31938d8d 100644 --- a/app/src/lang/translations/hu-HU.yaml +++ b/app/src/lang/translations/hu-HU.yaml @@ -557,6 +557,7 @@ fields: theme: Téma tfa_secret: Kétlépcsős hitelesítés status: Állapot + status_active: aktív role: Szerepkör token: Token last_page: Utolsó oldal @@ -585,6 +586,9 @@ fields: admin_access: Admin hozzáférés ip_access: IP cím collection_list: Gyűjtemény navigáció + directus_webhooks: + name: Név + status: Állapot no_fields_in_collection: 'A(z) {collection} még nem tartalmaz mezőt' do_nothing: Ne tegyen semmit block: Blokk @@ -678,6 +682,8 @@ interfaces: interface: Kezelőfelület select-dropdown-m2o: display_template: Megjelenítési sablon + map: + zoom: Nagyítás system-folder: folder: Könyvtár slider: diff --git a/app/src/lang/translations/id-ID.yaml b/app/src/lang/translations/id-ID.yaml index fa11ffb4d22f2..cc0421097e50b 100644 --- a/app/src/lang/translations/id-ID.yaml +++ b/app/src/lang/translations/id-ID.yaml @@ -536,6 +536,9 @@ fields: directus_roles: name: Nama Peran description: Deskripsi + directus_webhooks: + name: Nama + status: Status comment: Komentar delete_field_are_you_sure: >- Apakah Anda yakin Anda ingin menghapus bidang "{field}" ini? Tindakan ini tidak dapat dikembalikan. @@ -604,6 +607,8 @@ interfaces: interface: Antarmuka select-dropdown-m2o: display_template: Tampilan Template + map: + zoom: Perbesar system-folder: folder: Direktori boolean: diff --git a/app/src/lang/translations/it-IT.yaml b/app/src/lang/translations/it-IT.yaml index 0cf360c9a33ef..99890cdefd9ad 100644 --- a/app/src/lang/translations/it-IT.yaml +++ b/app/src/lang/translations/it-IT.yaml @@ -390,6 +390,7 @@ display_template_not_setup: L'opzione del modello di visualizzazione non è conf collection_field_not_setup: L'opzione del campo della raccolta non è configurato correttamente select_a_collection: Seleziona una Raccolta active: Attivo +inactive: Disattivato users: Utenti activity: Attività webhooks: Webhooks @@ -444,6 +445,7 @@ errors: UNPROCESSABLE_ENTITY: Entità non elaborabile INTERNAL_SERVER_ERROR: Errore Imprevisto NOT_NULL_VIOLATION: Il valore non può essere nullo +security: Sicurezza value_hashed: Valore cifrato in sicurezza bookmark_name: Nome segnalibro... create_bookmark: Crea Segnalibro @@ -564,6 +566,7 @@ user: Utente no_presets: Nessuna Preset no_presets_copy: Nessun preset o segnalibro sono stati ancora salvati. no_presets_cta: Aggiungi Preset +presets_only: Solo Preset create: Creare on_create: Alla Creazione on_update: In aggiornamento @@ -579,6 +582,7 @@ label: Etichetta image_url: URL immagine alt_text: Testo alternativo media: Media +quality: Qualità width: Larghezza height: Altezza source: Sorgente @@ -773,12 +777,23 @@ fields: title: Titolo description: Descrizione tags: Tags + user_preferences: Preferenze utente language: Linguaggio theme: Tema + theme_auto: Automatico (in base al sistema) + theme_light: Tema chiaro + theme_dark: Tema scuro tfa_secret: Autenticazione a due fattori + admin_options: Opzioni Amministratore status: Stato + status_draft: Bozza + status_invited: Disattivato + status_active: Attivo + status_suspended: Sospeso + status_archived: Archiviato role: Ruolo token: Token + token_placeholder: Inserisci un token di accesso sicuro... last_page: Ultima Pagina last_access: Ultimo Acesso directus_settings: @@ -786,13 +801,17 @@ fields: project_url: URL del Progetto project_color: Colore del Progetto project_logo: Logo del Progetto + public_pages: Pagine pubbliche public_foreground: Primo Piano Pubblico public_background: Sfondo Pubblico public_note: Nota Pubblica auth_password_policy: Criteri Password di accesso auth_login_attempts: Tentativi di accesso + files_and_thumbnails: File e miniature + storage_default_folder: Cartella predefinita di archiviazione storage_asset_presets: Preimpostazioni salvataggio risorse storage_asset_transform: Trasformazione salvataggio risorse + overrides: Personalizzazioni App custom_css: CSS personalizzato directus_fields: collection: Nome della Raccolta @@ -813,6 +832,14 @@ fields: users: Utenti nel ruolo module_list: Navigazione del modulo collection_list: Navigazione Raccolte + directus_webhooks: + name: Nome + method: Metodo + status: Stato + data: Dati + data_label: Invia dati evento + triggers: Trigger + actions: Azioni field_options: directus_collections: track_activity_revisions: Traccia Attività e Revisioni @@ -897,13 +924,13 @@ require_value_to_be_set: Richiedi di impostare il valore translation: Traduzione value: Valore view_project: Visualizza Progetto -weeks: { } report_error: Segnala l'errore start: Inizio interfaces: group-accordion: name: Accordion description: Mostra i campi o i gruppi come sezioni in un accordion + start: Inizio all_closed: Tutto chiuso first_opened: Apri la prima sezione all_opened: Tutto aperto @@ -1024,6 +1051,8 @@ interfaces: box: Blocco / In linea imageToken: Token Immagine imageToken_label: Token (statico) da aggiungere alle sorgenti delle immagini + map: + zoom: Zoom presentation-notice: notice: Avviso description: Mostra un avviso breve @@ -1113,7 +1142,7 @@ interfaces: description: Una ricerca durante la digitazione per valori API esterni. results_path: Percorso dei risultati value_path: Percorso del valore - trigger: Attivazione + trigger: Trigger rate: Intervallo group-raw: name: Gruppo grezzo diff --git a/app/src/lang/translations/ja-JP.yaml b/app/src/lang/translations/ja-JP.yaml index d934f5f6ddd64..c3764b514b464 100644 --- a/app/src/lang/translations/ja-JP.yaml +++ b/app/src/lang/translations/ja-JP.yaml @@ -379,6 +379,9 @@ fields: description: 説明 ip_access: IP アクセス enforce_tfa: 二段階認証が必要 + directus_webhooks: + name: 名前 + status: ステータス no_fields_in_collection: 'まだ「{collection}」にフィールドがありません' do_nothing: 何もしない save_current_user_role: 現在のユーザーロールを保存 diff --git a/app/src/lang/translations/ka-GE.yaml b/app/src/lang/translations/ka-GE.yaml index 6f57046cf6e60..6730648cca753 100644 --- a/app/src/lang/translations/ka-GE.yaml +++ b/app/src/lang/translations/ka-GE.yaml @@ -54,6 +54,8 @@ fields: role: როლი directus_roles: description: აღწერა + directus_webhooks: + status: სტატუსი comment: კომენტარი description: აღწერა email: ელ-ფოსტა diff --git a/app/src/lang/translations/lt-LT.yaml b/app/src/lang/translations/lt-LT.yaml index 94c2022cb306e..e250416392dad 100644 --- a/app/src/lang/translations/lt-LT.yaml +++ b/app/src/lang/translations/lt-LT.yaml @@ -699,6 +699,7 @@ fields: theme: Tema tfa_secret: Dviejų lygių autentifikavimas status: Būsena + status_active: Aktyvus role: Vaidmuo last_page: Paskutinis puslapis last_access: Paskutinė prieiga @@ -733,6 +734,9 @@ fields: users: Vartotojai grupėje module_list: Modulių navigacija collection_list: Kolekcijų navigacija + directus_webhooks: + name: Pavadinimas + status: Būsena no_fields_in_collection: 'Kolekcijoje "{collection}" dar nėra elementų' do_nothing: Nieko nedaryti generate_and_save_uuid: Generuoti ir išsaugoti UUID @@ -844,6 +848,8 @@ interfaces: interface: Sąsaja select-dropdown-m2o: display_template: Rodymo šablonas + map: + zoom: Padidinti system-folder: folder: Aplankas tags: diff --git a/app/src/lang/translations/ms-MY.yaml b/app/src/lang/translations/ms-MY.yaml index 39c52b2dd1b49..895aa81225d45 100644 --- a/app/src/lang/translations/ms-MY.yaml +++ b/app/src/lang/translations/ms-MY.yaml @@ -147,6 +147,9 @@ fields: note: Nota directus_roles: description: Penerangan + directus_webhooks: + name: Nama + status: Status comment: Komen delete_field_are_you_sure: >- Adakah anda pasti ingin memadam medan "{field}"? Tindakan ini tidak dapat dibatalkan. diff --git a/app/src/lang/translations/nl-NL.yaml b/app/src/lang/translations/nl-NL.yaml index eb5cc94407aaf..ff0373545a858 100644 --- a/app/src/lang/translations/nl-NL.yaml +++ b/app/src/lang/translations/nl-NL.yaml @@ -774,6 +774,7 @@ fields: theme: Thema tfa_secret: 2FA (Twee-Factor Authenticatie) status: Status + status_active: Actief role: Rol token: Token last_page: Laatste pagina @@ -810,6 +811,9 @@ fields: users: Gebruikers in rol module_list: Module Navigatie collection_list: Collectie navigatie + directus_webhooks: + name: Naam + status: Status field_options: directus_collections: track_activity_revisions: Track Activiteiten & Revisies @@ -954,6 +958,8 @@ interfaces: customSyntax: Aangepaste blokken box: Blok / Inline imageToken: Afbeeldingstoken + map: + zoom: Zoom presentation-notice: notice: Melding description: Een korte melding weergeven diff --git a/app/src/lang/translations/no-NO.yaml b/app/src/lang/translations/no-NO.yaml index 347fe0e185ee3..823e71fbb7833 100644 --- a/app/src/lang/translations/no-NO.yaml +++ b/app/src/lang/translations/no-NO.yaml @@ -423,6 +423,7 @@ fields: tags: Etiketter language: Språk status: Status + status_active: Aktiv last_page: Siste side last_access: Siste tilgang directus_settings: @@ -434,6 +435,9 @@ fields: directus_roles: name: Rollenavn description: Beskrivelse + directus_webhooks: + name: Navn + status: Status comment: Kommenter delete_field_are_you_sure: >- Er du sikker på at du vil slette feltet "{field}"? Handlingen kan ikke angres.. diff --git a/app/src/lang/translations/pl-PL.yaml b/app/src/lang/translations/pl-PL.yaml index 3e675cf4e4793..32b46240becd3 100644 --- a/app/src/lang/translations/pl-PL.yaml +++ b/app/src/lang/translations/pl-PL.yaml @@ -767,6 +767,7 @@ fields: theme: Motyw tfa_secret: Uwierzytelnianie dwuetapowe status: Status + status_active: Aktywne role: Rola token: Token last_page: Ostatnia strona @@ -803,6 +804,9 @@ fields: users: Użytkownicy w roli module_list: Moduł Nawigacji collection_list: Kolekcja Nawigacji + directus_webhooks: + name: Nazwa + status: Status field_options: directus_collections: track_activity_revisions: Śledź aktywność i rewizje @@ -998,6 +1002,8 @@ interfaces: box: Blok / Inline imageToken: Token obrazu imageToken_label: Jaki (statyczny) token dodać do źródeł obrazów + map: + zoom: Powiększ presentation-notice: notice: Informacja description: Wyświetl krótką informacje diff --git a/app/src/lang/translations/pt-BR.yaml b/app/src/lang/translations/pt-BR.yaml index 33baefec5f551..b87ef80d69229 100644 --- a/app/src/lang/translations/pt-BR.yaml +++ b/app/src/lang/translations/pt-BR.yaml @@ -767,6 +767,7 @@ fields: theme: Tema tfa_secret: Autenticação em Duas Etapas status: Status + status_active: Ativo role: Função token: Token last_page: Última página @@ -803,6 +804,9 @@ fields: users: Usuários no cargo module_list: Navegação do Módulo collection_list: Navegação da Coleção + directus_webhooks: + name: Nome + status: Status field_options: directus_collections: track_activity_revisions: Rastrear Atividades e Revisões @@ -998,6 +1002,8 @@ interfaces: box: Bloco / Em linha imageToken: Token da imagem imageToken_label: Qual token (estático) a ser adicionado nas imagens + map: + zoom: Zoom presentation-notice: notice: Aviso description: Mostrar um breve aviso diff --git a/app/src/lang/translations/pt-PT.yaml b/app/src/lang/translations/pt-PT.yaml index b97e02ca4aa66..be757ad0ed108 100644 --- a/app/src/lang/translations/pt-PT.yaml +++ b/app/src/lang/translations/pt-PT.yaml @@ -605,6 +605,7 @@ fields: theme: Tema tfa_secret: Autenticação em dois passos status: Estado + status_active: Ativar role: Perfil token: Token last_page: Última página @@ -632,6 +633,9 @@ fields: users: Utilizadores na função module_list: Navegação do Modulo collection_list: Navegação da coleção + directus_webhooks: + name: Nome + status: Estado field_options: directus_collections: track_activity_revisions: Rastrear Atividades e Revisões @@ -676,6 +680,8 @@ interfaces: interface: Interface select-dropdown-m2o: display_template: Templates de Exibição + map: + zoom: Zoom system-folder: folder: Pasta tags: diff --git a/app/src/lang/translations/ro-RO.yaml b/app/src/lang/translations/ro-RO.yaml index a4abc5b9fe86f..76cd76eda2929 100644 --- a/app/src/lang/translations/ro-RO.yaml +++ b/app/src/lang/translations/ro-RO.yaml @@ -160,6 +160,8 @@ fields: directus_roles: name: Nume de rol description: Descriere + directus_webhooks: + status: Stare comment: Comentariu delete_field_are_you_sure: >- Sunteţi sigur că doriţi să ştergeţi câmpul "{field}"? Această acţiune nu poate fi modificată ulterior. diff --git a/app/src/lang/translations/ru-RU.yaml b/app/src/lang/translations/ru-RU.yaml index 02e4948f10cfc..edd2687788160 100644 --- a/app/src/lang/translations/ru-RU.yaml +++ b/app/src/lang/translations/ru-RU.yaml @@ -20,12 +20,13 @@ #'SIMD', 'ArrayBuffer', 'DataView', 'JSON', 'Promise', 'Generator', 'GeneratorFunction', 'Reflect', #'Proxy', 'Intl' edit_field: Редактировать поле -item_revision: Версия Элемента -duplicate_field: Дубликат Поля -half_width: Половина Ширины -full_width: Полная ширина +conditions: Условия +item_revision: Редакция +duplicate_field: Клонировать поле +half_width: Полширины +full_width: Вся ширина group: Группа -fill_width: Полная Ширина +fill_width: В ширину field_name_translations: Переводы Названия Поля enter_password_to_enable_tfa: Введите свой пароль для включения Двухфакторной Аутентификации add_field: Добавить поле @@ -34,15 +35,15 @@ branch: Ветка children: Дочерние элементы db_only_click_to_configure: 'Только База данных: Нажмите для Настройки ' show_archived_items: Показать элементы в архиве -edited: Изменённое значение +edited: Значение изменено required: Необходимые required_for_app_access: Требуется для доступа к приложению requires_value: Требуется значение create_preset: Создать Пресет -create_role: Создать Роль -create_user: Создать Пользователя +create_role: Создать роль +create_user: Создать пользователя create_webhook: Создать веб-хук -invite_users: Пригласить Пользователей +invite_users: Пригласить пользователей invite: Пригласить email_already_invited: На адрес "{email}" уже было отправлено приглашение emails: Email-адреса @@ -64,6 +65,7 @@ delete_bookmark_copy: >- logoutReason: SIGN_OUT: Вы вышли SESSION_EXPIRED: Сессия истекла +public_label: Публичный public_description: Управляет какие данные доступны по API без аутентификации. not_allowed: Не Разрешено directus_version: Версия Directus @@ -108,6 +110,7 @@ no_access: Нет Доступа use_custom: Использовать Свой nullable: Разрешить NULL значение allow_null_value: Разрешить NULL значение +allow_multiple: Разрешить несколько field_standard: Стандарт field_presentation: Представление и Алиасы field_file: Один Файл @@ -297,6 +300,7 @@ drag_mode: Режим Перетаскивания cancel_crop: Отменить Обрезку original: Исходный url: URL +import_label: Импорт file_details: Информация о Файле dimensions: Размеры size: Размер @@ -335,9 +339,11 @@ manual_string: Введенная вручную строка save_and_create_new: Сохранить и Создать Новый save_and_stay: Сохранить и Остаться save_as_copy: Сохранить и Копировать -add_existing: Добавить Существующий +add_existing: Добавить creating_items: Создание элементов +enable_create_button: С кнопкой «Создать» selecting_items: Выбор элементов +enable_select_button: С кнопкой «Выбрать» comments: Комментарии no_comments: Комментариев Пока Нет click_to_expand: Нажмите, чтобы Раскрыть @@ -420,7 +426,9 @@ errors: ROUTE_NOT_FOUND: Не найдено RECORD_NOT_UNIQUE: Обнаружен дубликат значения USER_SUSPENDED: Пользователь заблокирован + CONTAINS_NULL_VALUES: Поле содержит значения null UNKNOWN: Неожиданная Ошибка + UNPROCESSABLE_ENTITY: Необрабатываемый объект INTERNAL_SERVER_ERROR: Неожиданная Ошибка NOT_NULL_VIOLATION: Значение не может быть null value_hashed: Значение Безопасно Хэшировано @@ -534,6 +542,7 @@ no_results_copy: Настройте или сбросьте фильтры по clear_filters: Сбросить Фильтры saves_automatically: Сохраняется Автоматически role: Роль +rule: Правило user: Пользователь no_presets: Нет Пресетов no_presets_copy: Пресеты или закладки пока не были сохранены. @@ -676,7 +685,7 @@ page_help_users_item: >- **Карточка Пользователя** — Управляйте данными вашего аккаунта или просматривайте данные других пользователей. activity_feed: Лента Активности add_new: Создать -create_new: Создать Новый +create_new: Создать all: Все none: Нет no_layout_collection_selected_yet: Макет/коллекция пока не выбраны @@ -749,6 +758,7 @@ fields: theme: Тема tfa_secret: Двухфакторная аутентификация status: Статус + status_active: Активный role: Роль token: Жетон last_page: Последняя Страница @@ -785,6 +795,9 @@ fields: users: Пользователи в Роли module_list: Навигация по Модулям collection_list: Навигация по Коллекциям + directus_webhooks: + name: Имя + status: Статус field_options: directus_collections: track_activity_revisions: Отслеживание активности и изменений @@ -805,8 +818,11 @@ referential_action_no_action: Предотвратить удаление referential_action_set_null: Обнулить поле {field} referential_action_set_default: Установить полю {field} значение по умолчанию choose_action: Выбрать действие +continue_label: Продолжить editing_role: '{role} Роль' creating_webhook: Создание Веб-хука +default_label: По умолчанию +delete_label: Удалить delete_are_you_sure: >- Это действие является необратимым и не может быть отменено. Вы уверены, что хотите продолжить? delete_field_are_you_sure: >- @@ -862,10 +878,21 @@ translation: Перевод value: Значение view_project: Просмотр Проекта report_error: Сообщить об Ошибке +start: Начало interfaces: + group-accordion: + name: Аккордеон + description: Отображать поля или группы как секции аккордеона + start: Начало + all_closed: Все закрыты + first_opened: Первая открыта + all_opened: Все открыты + accordion_mode: Вид аккордеона + max_one_section_open: 1 открытая секция presentation-links: - presentation-links: Ссылки кнопок + presentation-links: Кнопки-ссылки links: Ссылки + description: Настраиваемые кнопки-ссылки для динамических URL-адресов style: Стиль primary: Первичный link: Ссылки @@ -882,6 +909,8 @@ interfaces: description: Выбор с помощью дерева чекбоксов value_combining: Объединение значений value_combining_note: Определяет, какое значение сохраняется при выборе вложенных вариантов. + show_all: Показывать все + show_selected: Показать выбранные input-code: code: Код description: Написать или поделиться фрагментами кода @@ -899,6 +928,7 @@ interfaces: color: Цвет description: Введите или выберите значение цвета placeholder: Выберите цвет... + preset_colors: Предустановленные цвета preset_colors_add_label: Добавить новый цвет... name_placeholder: Введите название цвета... datetime: @@ -916,6 +946,7 @@ interfaces: divider: Разделитель title_placeholder: Введите название... inline_title: Строчный заголовок + inline_title_label: Показать заголовок внутри строки margin_top: Отступ сверху margin_top_label: Увеличить отступ сверху select-dropdown: @@ -929,6 +960,7 @@ interfaces: choices_value_placeholder: Введите значение... select-multiple-dropdown: select-multiple-dropdown: Выпадающий список (несколько) + description: Выбор нескольких элементов из выпадающего списка file: file: Файл description: Выберите или загрузите файл @@ -938,6 +970,7 @@ interfaces: input-hash: hash: Хэш description: Введите значение для хеширования + masked: С маской masked_label: Скрывать настоящие значения select-icon: icon: Иконка @@ -967,10 +1000,20 @@ interfaces: customSyntax_add: Добавить произвольный синтаксис box: Блок / Строчный элемент imageToken: Ключ изображения + map: + zoom: Масштаб + presentation-notice: + notice: Уведомление + description: Показать короткое уведомление + text: Введите содержание уведомления здесь... list-o2m: one-to-many: Один ко многим + no_collection: Коллекция не найдена system-folder: folder: Папка + description: Выбрать папку + root_name: Корень файловой библиотеки + system_default: Системные настройки по умолчанию select-radio: radio-buttons: Радиокнопки description: Выбор одного из нескольких вариантов @@ -978,6 +1021,8 @@ interfaces: repeater: Повторитель edit_fields: Редактировать поля add_label: 'Ярлык «Создать новый»' + field_name_placeholder: Введите название поля... + field_note_placeholder: Примечание к полю... slider: slider: Слайдер description: Выберите номер с помощью слайдера @@ -1001,18 +1046,21 @@ interfaces: description: Ввести значение вручную trim: Обрезать trim_label: Обрезать начало и конец + mask: С маской mask_label: Скрыть настоящее значение clear: Очищенное значение clear_label: Сохранить как пустую строку minimum_value: Минимальное значение maximum_value: Максимальное значение step_interval: Интервал шага + slug: Форматировать для URL slug_label: Сделать введённое значение безопасным для URL input-multiline: textarea: Область текста description: Введите простой текст (несколько строк) boolean: toggle: Переключить + description: Переключение вкл / выкл label_placeholder: Введите метку... label_default: Включен translations: @@ -1034,7 +1082,12 @@ interfaces: options_override: Переопределение параметров input-autocomplete-api: input-autocomplete-api: Автоматическое дополнение ввода (API) + value_path: Путь значения trigger: Триггер + group-detail: + show_header: Показывать заголовок группы + header_icon: Иконка заголовка + header_color: Цвет заголовка displays: boolean: boolean: Логическое @@ -1125,7 +1178,7 @@ layouts: image_source: Источник изображения image_fit: Подгон Изображения crop: Обрезать - contain: Содержать + contain: Вписать title: Заголовок subtitle: Подзаголовок tabular: diff --git a/app/src/lang/translations/si-LK.yaml b/app/src/lang/translations/si-LK.yaml index d1607fd96a412..3c7bcbba33947 100644 --- a/app/src/lang/translations/si-LK.yaml +++ b/app/src/lang/translations/si-LK.yaml @@ -1,4 +1,5 @@ --- +group: සමූහය all: සියල්ල cancel: අවලංගු කරන්න layouts: diff --git a/app/src/lang/translations/sl-SI.yaml b/app/src/lang/translations/sl-SI.yaml index c389e5180860c..4ba81cc0d2033 100644 --- a/app/src/lang/translations/sl-SI.yaml +++ b/app/src/lang/translations/sl-SI.yaml @@ -390,6 +390,7 @@ display_template_not_setup: Oblika prikaza ni nastavljena collection_field_not_setup: Polje zbirke ni nastavljeno select_a_collection: Izberite zbirko active: Aktivno +inactive: Neaktivno users: Uporabniki activity: Aktivnost webhooks: Prožilci @@ -402,6 +403,7 @@ documentation: Dokumentacija sidebar: Stranska vrstica duration: Trajanje charset: Nabor znakov +second: sekunda file_moved: Datoteka premaknjena collection_created: Zbirka ustvarjena modified_on: Spremenjeno dne @@ -443,6 +445,7 @@ errors: UNPROCESSABLE_ENTITY: Obdelava nemogoča INTERNAL_SERVER_ERROR: Nepričakovana napaka NOT_NULL_VIOLATION: Vrednost ne sme biti nična +security: Varnost value_hashed: Vrednost varno zgoščena bookmark_name: Ime zaznamka ... create_bookmark: Ustvari zaznamek @@ -563,6 +566,7 @@ user: Uporabnik no_presets: Ni prednastavitev no_presets_copy: Nimate nobenih prednastavitev ali zaznamkov. no_presets_cta: Dodaj prednastavitev +presets_only: Samo prednastavitve create: Ustvari on_create: Pri ustvarjanju on_update: Pri urejanju @@ -578,6 +582,7 @@ label: Oznaka image_url: URL naslov slike alt_text: Nadomestno besedilo media: Medijska datoteka +quality: Kakovost width: Širina height: Višina source: Vir @@ -772,12 +777,23 @@ fields: title: Naslov description: Opis tags: Značke + user_preferences: Uporabniške nastavitve language: Jezik theme: Tema + theme_auto: Avtomatsko (sistemsko) + theme_light: Svetla tema + theme_dark: Temna tema tfa_secret: Dvojno preverjanja pristnosti + admin_options: Administracijske opcije status: Stanje + status_draft: Osnutek + status_invited: Neaktivno + status_active: Aktivno + status_suspended: Zaustavljeno + status_archived: Arhivirano role: Vloga token: Žeton + token_placeholder: Vnesite žeton ... last_page: Zadnja stran last_access: Zadnji dostop directus_settings: @@ -785,13 +801,17 @@ fields: project_url: Spletni naslov (URL) projekta project_color: Barva projekta project_logo: Logo projekta + public_pages: Javne strani public_foreground: Barva teksta v javnem delu public_background: Barva ozadja v javnem delu public_note: Sporočilo v javnem delu auth_password_policy: Pravila gesel auth_login_attempts: Število poskusov prijave + files_and_thumbnails: Datoteke & sličice + storage_default_folder: Privzeta mapa za shranjevanje storage_asset_presets: Prednastavitve shrambe storage_asset_transform: Pretvorba elementa + overrides: Aplikacijske preglasitve custom_css: CSS po meri directus_fields: collection: Ime zbirke @@ -812,6 +832,14 @@ fields: users: Uporabniki v vlogi module_list: Krmarjenje po modulu collection_list: Krmarjenje po zbirki + directus_webhooks: + name: Ime + method: Metoda + status: Stanje + data: Podatki + data_label: Pošlji podatke dogodka + triggers: Sprožilci + actions: Dejanja field_options: directus_collections: track_activity_revisions: Sledi aktivnosti in revizije @@ -897,13 +925,17 @@ translation: Prevod value: Vrednost view_project: Poglej projekt report_error: Sporoči napako +start: Začetek interfaces: group-accordion: name: Harmonika description: Prikaži polja ali skupine kot segmente harmonike + start: Začetek all_closed: Vse zaprto first_opened: Prvo odprto all_opened: Vse odprto + accordion_mode: Način harmonike + max_one_section_open: Največ ena odprta sekcija presentation-links: presentation-links: Povezave links: Povezave @@ -1019,6 +1051,8 @@ interfaces: box: Blok / v vrstici imageToken: Žeton slike imageToken_label: Kateri statični žeton se naj doda k sliki + map: + zoom: Povečaj presentation-notice: notice: Obvestilo description: Pokaži kratko obvestilo @@ -1110,6 +1144,17 @@ interfaces: value_path: Vrednosti trigger: Sprožilec rate: Oceni + group-raw: + name: Osnovna skupina + description: Prikaži originalno vsebino polja + group-detail: + name: Skupina podrobnosti + description: Prikaži polje kot zložljiv odsek + show_header: Pokaži naslov skupine + header_icon: Ikona glave + header_color: Barva glave + start_open: Začni odprto + start_closed: Začni zaprto displays: boolean: boolean: Boolean diff --git a/app/src/lang/translations/sr-CS.yaml b/app/src/lang/translations/sr-CS.yaml index 7c555f706a6e0..c2f9db4acd14f 100644 --- a/app/src/lang/translations/sr-CS.yaml +++ b/app/src/lang/translations/sr-CS.yaml @@ -756,6 +756,7 @@ fields: theme: Tema tfa_secret: Dvostruka potvrda Autentičnosti status: Status + status_active: Aktivan role: Uloga token: Token last_page: Posljednja Stranica @@ -792,6 +793,9 @@ fields: users: Svi korisnici ove Uloge module_list: Navigacija Modula collection_list: Navigacija Kolekcije + directus_webhooks: + name: Ime + status: Status field_options: directus_collections: track_activity_revisions: Prati Aktivnost & Revizije @@ -980,6 +984,8 @@ interfaces: box: Blok / Linijski imageToken: Token Fotografije imageToken_label: Koji (statični) token prikačiti na izvore slika + map: + zoom: Uvеćanjе presentation-notice: notice: Napomеna description: Prikaži kratku napomenu diff --git a/app/src/lang/translations/sr-SP.yaml b/app/src/lang/translations/sr-SP.yaml index 61c1ba8d32e82..a86f54f2b8d6f 100644 --- a/app/src/lang/translations/sr-SP.yaml +++ b/app/src/lang/translations/sr-SP.yaml @@ -136,6 +136,9 @@ fields: display_template: Шаблон directus_roles: description: Опис + directus_webhooks: + name: Име + status: Стање comment: Коментар description: Опис done: Завршено diff --git a/app/src/lang/translations/sv-SE.yaml b/app/src/lang/translations/sv-SE.yaml index f8d556209234d..204a946ef040c 100644 --- a/app/src/lang/translations/sv-SE.yaml +++ b/app/src/lang/translations/sv-SE.yaml @@ -732,6 +732,7 @@ fields: theme: Tema tfa_secret: Tvåfaktorsautentisering status: Status + status_active: Aktiv role: Roll token: Token last_page: Sista sidan @@ -768,6 +769,9 @@ fields: users: Användare i rollen module_list: Navigering för modul collection_list: Navigering för kollektion + directus_webhooks: + name: Namn + status: Status no_fields_in_collection: 'Det finns inga fält i "{collection}" ännu' do_nothing: Gör ingenting generate_and_save_uuid: Generera och spara UUID @@ -879,6 +883,8 @@ interfaces: interface: Gränssnitt select-dropdown-m2o: display_template: Visningsmall + map: + zoom: Zooma system-folder: folder: Mapp slider: diff --git a/app/src/lang/translations/th-TH.yaml b/app/src/lang/translations/th-TH.yaml index 6085ffd2635c9..ba96ae7daefbf 100644 --- a/app/src/lang/translations/th-TH.yaml +++ b/app/src/lang/translations/th-TH.yaml @@ -776,6 +776,7 @@ fields: theme: ธีม tfa_secret: การยืนยันตัวตนแบบสองปัจจัย status: สถานะ + status_active: เปิดใช้งาน role: บทบาท token: โทเค็น last_page: หน้าสุดท้าย @@ -812,6 +813,9 @@ fields: users: ผู้ใช้ในบทบาทนี้ module_list: รายการโมดูล collection_list: รายการคอลเลกชัน + directus_webhooks: + name: ชื่อ + status: สถานะ field_options: directus_collections: track_activity_revisions: ติดตามกิจกรรมและการแก้ไข @@ -1001,6 +1005,8 @@ interfaces: box: กล่อง/บรรทัดเดียวกัน imageToken: Image Token imageToken_label: Image Token ที่จะถูกต่อเข้าไปที่แห่งที่มาของรูปภาพ + map: + zoom: ซูม presentation-notice: notice: แจ้งให้ทราบ description: แสดงการแจ้งให้ทราบแบบย่อ diff --git a/app/src/lang/translations/tr-TR.yaml b/app/src/lang/translations/tr-TR.yaml index 761d159f77af9..882fc8ecc1f43 100644 --- a/app/src/lang/translations/tr-TR.yaml +++ b/app/src/lang/translations/tr-TR.yaml @@ -604,6 +604,7 @@ fields: theme: Tema tfa_secret: İki Faktörlü Kimlik Doğrulama status: Durum + status_active: Aktif role: Rol last_page: Son Sayfa last_access: Son Erişim @@ -626,6 +627,9 @@ fields: ip_access: IP Erişimi enforce_tfa: 2FA Gerekli users: Rol'deki Kullanıcılar + directus_webhooks: + name: İsim + status: Durum do_nothing: Hiçbir Şey Yapma block: Blok comment: Yorum @@ -716,6 +720,8 @@ interfaces: interface: Arayüz select-dropdown-m2o: display_template: Görüntüleme Şablonu + map: + zoom: Yakınlaş system-folder: folder: Klasör slider: diff --git a/app/src/lang/translations/uk-UA.yaml b/app/src/lang/translations/uk-UA.yaml index 537bc1e4255cc..1fd38aa051a6c 100644 --- a/app/src/lang/translations/uk-UA.yaml +++ b/app/src/lang/translations/uk-UA.yaml @@ -221,6 +221,9 @@ fields: directus_roles: name: Назва ролі description: Опис + directus_webhooks: + name: Назва + status: Статус comment: Коментар delete_field_are_you_sure: >- Ви впевнені, що хочете видалити поле "{field}"? Цю дію не можливо скасувати. diff --git a/app/src/lang/translations/vi-VN.yaml b/app/src/lang/translations/vi-VN.yaml index 162b21c8a9020..de3021c665e8a 100644 --- a/app/src/lang/translations/vi-VN.yaml +++ b/app/src/lang/translations/vi-VN.yaml @@ -536,6 +536,7 @@ fields: theme: Theme tfa_secret: Xác thực hai yếu tố status: Trạng thái + status_active: Kích hoạt role: Phân quyền directus_settings: project_name: Tên Dự Án @@ -548,6 +549,9 @@ fields: directus_roles: name: Tên Quyền description: Mô tả + directus_webhooks: + name: Tên + status: Trạng thái comment: Bình luận delete_field_are_you_sure: >- Bạn có chắc chắn muốn xóa trường "{field}"? Dữ liệu đã xóa sẽ không thể phục hồi lại được. @@ -622,6 +626,8 @@ interfaces: interface: Giao diện select-dropdown-m2o: display_template: Mẫu hiển thị + map: + zoom: Thu phóng system-folder: folder: Thư mục slider: diff --git a/app/src/lang/translations/zh-CN.yaml b/app/src/lang/translations/zh-CN.yaml index 7220618a5a995..286fa6672dafc 100644 --- a/app/src/lang/translations/zh-CN.yaml +++ b/app/src/lang/translations/zh-CN.yaml @@ -772,6 +772,7 @@ fields: theme: 主题 tfa_secret: 2FA(双因素身份验证) status: 状态 + status_active: 激活 role: 角色 token: Token last_page: 尾页 @@ -808,6 +809,9 @@ fields: users: 角色中的用户 module_list: 模块导航 collection_list: 集合导航 + directus_webhooks: + name: 名称 + status: 状态 field_options: directus_collections: track_activity_revisions: 跟踪活动和历史修改版本 @@ -1006,6 +1010,8 @@ interfaces: box: Block / Inline imageToken: 图像Token imageToken_label: 添加到源图片的Token + map: + zoom: 缩放 presentation-notice: notice: 提示 description: 显示一个快捷提示 diff --git a/app/src/lang/translations/zh-TW.yaml b/app/src/lang/translations/zh-TW.yaml index cd9448ea97ffb..6d1bc1b86e8cd 100644 --- a/app/src/lang/translations/zh-TW.yaml +++ b/app/src/lang/translations/zh-TW.yaml @@ -541,6 +541,9 @@ fields: description: 描述說明 enforce_tfa: 必須兩步驟登入 users: 角色中的使用者 + directus_webhooks: + name: 名稱 + status: 狀態 no_fields_in_collection: '"{collection}" 中還沒有任何欄位' do_nothing: 無動作 generate_and_save_uuid: 產生並儲存 UUID @@ -633,6 +636,8 @@ interfaces: interface: 介面 select-dropdown-m2o: display_template: 顯示模板 + map: + zoom: 縮放 system-folder: folder: 資料夾 slider: diff --git a/app/src/layouts/map/actions.vue b/app/src/layouts/map/actions.vue new file mode 100644 index 0000000000000..1fde62e0099f4 --- /dev/null +++ b/app/src/layouts/map/actions.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/app/src/layouts/map/components/map.vue b/app/src/layouts/map/components/map.vue new file mode 100644 index 0000000000000..80eb817df7e67 --- /dev/null +++ b/app/src/layouts/map/components/map.vue @@ -0,0 +1,511 @@ + + + + + + + + diff --git a/app/src/layouts/map/index.ts b/app/src/layouts/map/index.ts new file mode 100644 index 0000000000000..aa523711d2aea --- /dev/null +++ b/app/src/layouts/map/index.ts @@ -0,0 +1,363 @@ +import { defineLayout } from '@directus/shared/utils'; +import MapLayout from './map.vue'; +import MapOptions from './options.vue'; +import MapSidebar from './sidebar.vue'; +import MapActions from './actions.vue'; + +import { useI18n } from 'vue-i18n'; +import { toRefs, computed, ref, watch, Ref } from 'vue'; + +import { CameraOptions, AnyLayer } from 'maplibre-gl'; +import { GeometryOptions, toGeoJSON } from '@/utils/geometry'; +import { layers } from './style'; +import { useRouter } from 'vue-router'; +import { Filter } from '@directus/shared/types'; +import useCollection from '@/composables/use-collection/'; +import useItems from '@/composables/use-items'; +import { getFieldsFromTemplate } from '@/utils/get-fields-from-template'; +import type { Field, GeometryFormat } from '@directus/shared/types'; + +import { cloneDeep, merge } from 'lodash'; + +type LayoutQuery = { + fields: string[]; + sort: string; + limit: number; + page: number; +}; + +type LayoutOptions = { + cameraOptions?: CameraOptions & { bbox: any }; + customLayers?: Array; + geometryFormat?: GeometryFormat; + geometryField?: string; + fitDataToView?: boolean; + clusterData?: boolean; + animateOptions?: any; +}; + +export default defineLayout({ + id: 'map', + name: '$t:layouts.map.map', + icon: 'map', + smallHeader: true, + component: MapLayout, + slots: { + options: MapOptions, + sidebar: MapSidebar, + actions: MapActions, + }, + setup(props) { + const { t, n } = useI18n(); + const router = useRouter(); + + const { collection, searchQuery, selection, layoutOptions, layoutQuery, filters } = toRefs(props); + const { info, primaryKeyField, fields: fieldsInCollection } = useCollection(collection); + + const page = syncOption(layoutQuery, 'page', 1); + const limit = syncOption(layoutQuery, 'limit', 1000); + const sort = syncOption(layoutQuery, 'sort', fieldsInCollection.value[0].field); + + const customLayerDrawerOpen = ref(false); + const layoutElement = ref(null); + + const cameraOptions = syncOption(layoutOptions, 'cameraOptions', undefined); + const customLayers = syncOption(layoutOptions, 'customLayers', layers); + const fitDataToView = syncOption(layoutOptions, 'fitDataToView', true); + const clusterData = syncOption(layoutOptions, 'clusterData', false); + const geometryField = syncOption(layoutOptions, 'geometryField', undefined); + const geometryFormat = computed({ + get: () => layoutOptions.value?.geometryFormat, + set(newValue: GeometryFormat | undefined) { + layoutOptions.value = { + ...(layoutOptions.value || {}), + geometryFormat: newValue, + geometryField: undefined, + }; + }, + }); + + const geometryFields = computed(() => { + return (fieldsInCollection.value as Field[]).filter( + ({ type, meta }) => type == 'geometry' || meta?.interface == 'map' + ); + }); + + watch( + () => geometryFields.value, + (fields) => { + if (!geometryField.value && fields.length > 0) { + geometryField.value = fields[0].field; + } + }, + { immediate: true } + ); + + const geometryOptions = computed(() => { + const field = fieldsInCollection.value.filter((field: Field) => field.field == geometryField.value)[0]; + if (!field) return undefined; + if (field.type == 'geometry') { + return { + geometryField: field.field, + geometryFormat: 'native', + geometryType: field.schema?.geometry_type, + } as GeometryOptions; + } + if (field.meta && field.meta.interface == 'map' && field.meta.options) { + return { + geometryField: field.field, + geometryFormat: field.meta.options.geometryFormat, + geometryType: field.meta.options.geometryType, + } as GeometryOptions; + } + return undefined; + }); + + watch( + () => geometryOptions.value, + (options, _) => { + if (options?.geometryFormat !== 'native') { + fitDataToView.value = false; + } + } + ); + + const template = computed(() => { + if (info.value?.meta?.display_template) return info.value?.meta?.display_template; + return `{{ ${primaryKeyField.value?.field} }}`; + }); + + const queryFields = computed(() => { + return [geometryField.value, ...getFieldsFromTemplate(template.value)] + .concat(primaryKeyField.value?.field) + .filter((e) => !!e) as string[]; + }); + + const viewBoundsFilter = computed(() => { + if (!geometryField.value || !cameraOptions.value) { + return; + } + const bbox = cameraOptions.value?.bbox; + const bboxPolygon = [ + [bbox[0], bbox[1]], + [bbox[2], bbox[1]], + [bbox[2], bbox[3]], + [bbox[0], bbox[3]], + [bbox[0], bbox[1]], + ]; + return { + key: 'bbox-filter', + field: geometryField.value, + operator: 'intersects_bbox', + value: { + type: 'Polygon', + coordinates: [bboxPolygon], + } as any, + } as Filter; + }); + + const shouldUpdateCamera = ref(false); + const _filters = computed(() => { + if (geometryOptions.value?.geometryFormat === 'native' && fitDataToView.value) { + return filters.value.concat(viewBoundsFilter.value ?? []); + } + return filters.value; + }); + + const { items, loading, error, totalPages, itemCount, totalCount, getItems } = useItems(collection, { + sort, + limit, + page, + searchQuery, + fields: queryFields, + filters: _filters, + }); + + const geojson = ref({ type: 'FeatureCollection', features: [] }); + const geojsonBounds = ref(); + const geojsonError = ref(); + const geojsonLoading = ref(false); + + watch( + () => cameraOptions.value, + () => { + shouldUpdateCamera.value = false; + } + ); + + watch(() => searchQuery.value, onQueryChange); + watch(() => collection.value, onQueryChange); + watch(() => limit.value, onQueryChange); + watch(() => sort.value, onQueryChange); + watch(() => items.value, updateGeojson); + + watch( + () => geometryField.value, + () => (shouldUpdateCamera.value = true) + ); + + function onQueryChange() { + shouldUpdateCamera.value = true; + geojsonLoading.value = false; + page.value = 1; + } + + function updateGeojson() { + if (geometryOptions.value) { + try { + geojson.value = { type: 'FeatureCollection', features: [] }; + geojsonLoading.value = true; + geojsonError.value = null; + geojson.value = toGeoJSON(items.value, geometryOptions.value, template.value); + geojsonLoading.value = false; + if (!cameraOptions.value || shouldUpdateCamera.value) { + geojsonBounds.value = geojson.value.bbox; + } + } catch (error) { + geojsonLoading.value = false; + geojsonError.value = error; + geojson.value = { type: 'FeatureCollection', features: [] }; + } + } else { + geojson.value = { type: 'FeatureCollection', features: [] }; + } + } + + const directusLayers = ref(layers); + const directusSource = ref({ + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [], + }, + }); + + watch(() => clusterData.value, updateSource, { immediate: true }); + updateLayers(); + + function updateLayers() { + customLayerDrawerOpen.value = false; + directusLayers.value = customLayers.value ?? []; + } + + function resetLayers() { + directusLayers.value = cloneDeep(layers); + customLayers.value = directusLayers.value; + } + + function updateSource() { + directusSource.value = merge({}, directusSource.value, { + cluster: clusterData.value, + }); + } + + function updateSelection(selected: Array | null) { + if (selected) { + selection.value = Array.from(new Set(selection.value.concat(selected))); + } else { + selection.value = []; + } + } + + const featureId = computed(() => { + return props.readonly ? null : primaryKeyField.value?.field; + }); + + function handleClick(key: number | string) { + if (props.selectMode) { + updateSelection([key]); + } else { + router.push(`/collections/${collection.value}/${key}`); + } + } + + const showingCount = computed(() => { + if ((itemCount.value || 0) < (totalCount.value || 0)) { + if (itemCount.value === 1) { + return t('one_filtered_item'); + } + return t('start_end_of_count_filtered_items', { + start: n((+page.value - 1) * limit.value + 1), + end: n(Math.min(page.value * limit.value, itemCount.value || 0)), + count: n(itemCount.value || 0), + }); + } + if (itemCount.value === 1) { + return t('one_item'); + } + return t('start_end_of_count_items', { + start: n((+page.value - 1) * limit.value + 1), + end: n(Math.min(page.value * limit.value, itemCount.value || 0)), + count: n(itemCount.value || 0), + }); + }); + + const activeFilterCount = computed(() => { + return filters.value.filter((filter) => !filter.locked).length; + }); + + return { + template, + selection, + geojson, + directusSource, + directusLayers, + customLayers, + updateLayers, + resetLayers, + featureId, + geojsonBounds, + geojsonLoading, + geojsonError, + geometryOptions, + handleClick, + geometryFormat, + geometryField, + cameraOptions, + fitDataToView, + clusterData, + updateSelection, + items, + loading, + error, + totalPages, + page, + toPage, + itemCount, + fieldsInCollection, + limit, + primaryKeyField, + sort, + info, + showingCount, + layoutElement, + activeFilterCount, + refresh, + resetPresetAndRefresh, + geometryFields, + customLayerDrawerOpen, + }; + + async function resetPresetAndRefresh() { + await props?.resetPreset?.(); + refresh(); + } + + function refresh() { + getItems(); + } + + function toPage(newPage: number) { + page.value = newPage; + } + + function syncOption(ref: Ref, key: T, defaultValue: R[T]) { + return computed({ + get: () => ref.value?.[key] ?? defaultValue, + set: (value: R[T]) => { + ref.value = Object.assign({}, ref.value, { [key]: value }) as R; + }, + }); + } + }, +}); diff --git a/app/src/layouts/map/map.vue b/app/src/layouts/map/map.vue new file mode 100644 index 0000000000000..1a5c82d099938 --- /dev/null +++ b/app/src/layouts/map/map.vue @@ -0,0 +1,255 @@ + + + + + + + + + diff --git a/app/src/layouts/map/options.vue b/app/src/layouts/map/options.vue new file mode 100644 index 0000000000000..8e20efc69220c --- /dev/null +++ b/app/src/layouts/map/options.vue @@ -0,0 +1,110 @@ + + + diff --git a/app/src/layouts/map/sidebar.vue b/app/src/layouts/map/sidebar.vue new file mode 100644 index 0000000000000..2199ae9d3297d --- /dev/null +++ b/app/src/layouts/map/sidebar.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/src/layouts/map/style.ts b/app/src/layouts/map/style.ts new file mode 100644 index 0000000000000..532cad3d31ff2 --- /dev/null +++ b/app/src/layouts/map/style.ts @@ -0,0 +1,81 @@ +import { AnyLayer, Expression } from 'maplibre-gl'; + +const baseColor = '#09f'; +const selectColor = '#FFA500'; +const fill: Expression = ['case', ['boolean', ['feature-state', 'selected'], false], selectColor, baseColor]; +const outline: Expression = [ + 'case', + ['boolean', ['feature-state', 'selected'], false], + selectColor, + ['boolean', ['feature-state', 'hovered'], false], + selectColor, + baseColor, +]; + +export const layers: AnyLayer[] = [ + { + id: '__directus_polygons', + type: 'fill', + source: '__directus', + filter: ['all', ['!has', 'point_count'], ['==', '$type', 'Polygon']], + paint: { + 'fill-color': fill, + 'fill-opacity': 0.15, + }, + }, + { + id: '__directus_polygons_outline', + type: 'line', + source: '__directus', + filter: ['all', ['!has', 'point_count'], ['==', '$type', 'Polygon']], + paint: { + 'line-color': outline, + 'line-width': 2, + }, + }, + { + id: '__directus_lines', + type: 'line', + source: '__directus', + filter: ['all', ['!has', 'point_count'], ['==', '$type', 'LineString']], + paint: { + 'line-color': outline, + 'line-width': 2, + }, + }, + { + id: '__directus_points', + type: 'circle', + source: '__directus', + filter: ['all', ['!has', 'point_count'], ['==', '$type', 'Point']], + layout: {}, + paint: { + 'circle-radius': 5, + 'circle-color': fill, + 'circle-stroke-color': outline, + 'circle-stroke-width': 3, + }, + }, + { + id: '__directus_clusters', + type: 'circle', + source: '__directus', + filter: ['has', 'point_count'], + paint: { + 'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'], + 'circle-radius': ['step', ['get', 'point_count'], 20, 100, 30, 750, 40], + }, + }, + { + id: '__directus_cluster_count', + type: 'symbol', + source: '__directus', + filter: ['has', 'point_count'], + layout: { + 'text-field': '{point_count_abbreviated}', + // 'text-font': ['Open Sans Semibold'], + 'text-font': ['Noto Sans Regular'], + 'text-size': ['step', ['get', 'point_count'], 15, 100, 17, 750, 19], + }, + }, +]; diff --git a/app/src/layouts/register.ts b/app/src/layouts/register.ts index f6b6e7d2c21b9..92fabe8538bb4 100644 --- a/app/src/layouts/register.ts +++ b/app/src/layouts/register.ts @@ -16,9 +16,11 @@ export async function registerLayouts(app: App): Promise { : await import(/* @vite-ignore */ `${getRootPath()}extensions/layouts/index.js`); layouts.push(...customLayouts.default); - } catch { + } catch (err) { // eslint-disable-next-line no-console console.warn(`Couldn't load custom layouts`); + // eslint-disable-next-line no-console + console.warn(err); } layoutsRaw.value = layouts; diff --git a/app/src/modules/collections/routes/collection.vue b/app/src/modules/collections/routes/collection.vue index 476222417439c..418a5db42dadf 100644 --- a/app/src/modules/collections/routes/collection.vue +++ b/app/src/modules/collections/routes/collection.vue @@ -1,6 +1,10 @@