diff --git a/.dockerignore b/.dockerignore index a3096e7d40883..e182865ae0afd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,6 +22,7 @@ open-api/typescript-sdk/node_modules/ server/coverage/ server/node_modules/ server/upload/ +server/src/queries server/dist/ server/www/ diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000000..c7519a4684f8f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ['https://buy.immich.app'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 12ffc89ea2b5a..346c6e60f2e21 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -83,7 +83,6 @@ body: 2. 3. ... - render: bash validations: required: true diff --git a/.github/release.yml b/.github/release.yml index 4463555deb541..1d9764194c740 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -2,28 +2,28 @@ changelog: categories: - title: 🚨 Breaking Changes labels: - - breaking-change + - changelog:breaking-change - title: 🔒 Security labels: - - security + - changelog:security - title: 🚀 Features labels: - - feature + - changelog:feature - title: 🌟 Enhancements labels: - - enhancement + - changelog:enhancement - title: 🐛 Bug fixes labels: - - bugfix + - changelog:bugfix - title: 📚 Documentation labels: - - documentation + - changelog:documentation - title: 🌐 Translations labels: - - translation + - changelog:translation diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml new file mode 100644 index 0000000000000..1557b3d15cfba --- /dev/null +++ b/.github/workflows/pr-label-validation.yml @@ -0,0 +1,21 @@ +name: PR Label Validation + +on: + pull_request_target: + types: [opened, labeled, unlabeled, synchronize] + +jobs: + validate-release-label: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read + steps: + - name: Require PR to have a changelog label + uses: mheap/github-action-required-labels@v5 + with: + mode: exactly + count: 1 + use_regex: true + labels: "changelog:.*" + add_comment: true diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 9d50f6f8f9100..fc03b24d085b7 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -29,10 +29,17 @@ jobs: ref: ${{ steps.push-tag.outputs.commit_long_sha }} steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} + private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + - name: Checkout uses: actions/checkout@v4 with: - token: ${{ secrets.ORG_RELEASE_TOKEN }} + token: ${{ steps.generate-token.outputs.token }} - name: Install Poetry run: pipx install poetry @@ -44,10 +51,8 @@ jobs: id: push-tag uses: EndBug/add-and-commit@v9 with: - author_name: Alex The Bot - author_email: alex.tran1502@gmail.com - default_author: user_info - message: 'Version ${{ env.IMMICH_VERSION }}' + default_author: github_actions + message: 'chore: version ${{ env.IMMICH_VERSION }}' tag: ${{ env.IMMICH_VERSION }} push: true diff --git a/cli/.nvmrc b/cli/.nvmrc index 8ce7030825b5e..3516580bbbc04 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/cli/Dockerfile b/cli/Dockerfile index 2c4aaf87186c0..e3cce6d448249 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.16.0-alpine3.20@sha256:eb8101caae9ac02229bd64c024919fe3d4504ff7f329da79ca60a04db08cef52 AS core +FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 AS core WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/cli/README.md b/cli/README.md index a570a55239af1..8fa2ace483251 100644 --- a/cli/README.md +++ b/cli/README.md @@ -4,8 +4,18 @@ Please see the [Immich CLI documentation](https://immich.app/docs/features/comma # For developers +Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder: + + $ npm install + $ npm run build + +Then, to build the open-api client run the following in the open-api folder: + + $ ./bin/generate-open-api.sh + To run the Immich CLI from source, run the following in the cli folder: + $ npm install $ npm run build $ ts-node . @@ -17,3 +27,4 @@ You can also build and install the CLI using $ npm run build $ npm install -g . +**** diff --git a/cli/eslint.config.mjs b/cli/eslint.config.mjs index 3f724506a3c8e..9115a1feb79e5 100644 --- a/cli/eslint.config.mjs +++ b/cli/eslint.config.mjs @@ -55,6 +55,7 @@ export default [ 'unicorn/import-style': 'off', curly: 2, 'prettier/prettier': 0, + 'object-shorthand': ['error', 'always'], }, }, ]; diff --git a/cli/package-lock.json b/cli/package-lock.json index cdba2036c4b8b..a8a636b3b6527 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.15", + "version": "2.2.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.15", + "version": "2.2.16", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -52,14 +52,14 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "typescript": "^5.3.3" } }, @@ -825,9 +825,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", "dev": true, "license": "MIT", "engines": { @@ -1054,169 +1054,224 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.1.tgz", + "integrity": "sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.1.tgz", + "integrity": "sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.1.tgz", + "integrity": "sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.1.tgz", + "integrity": "sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.1.tgz", + "integrity": "sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.1.tgz", + "integrity": "sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.1.tgz", + "integrity": "sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.1.tgz", + "integrity": "sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.1.tgz", + "integrity": "sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.1.tgz", + "integrity": "sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.1.tgz", + "integrity": "sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.1.tgz", + "integrity": "sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.1.tgz", + "integrity": "sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.1.tgz", + "integrity": "sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.1.tgz", + "integrity": "sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.1.tgz", + "integrity": "sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1269,13 +1324,13 @@ } }, "node_modules/@types/node": { - "version": "20.14.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", - "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "version": "20.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", + "integrity": "sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/normalize-package-data": { @@ -1285,17 +1340,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", + "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/type-utils": "8.2.0", + "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1319,16 +1374,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", + "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4" }, "engines": { @@ -1348,14 +1403,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1366,14 +1421,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", + "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/utils": "8.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1391,9 +1446,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", "dev": true, "license": "MIT", "engines": { @@ -1405,14 +1460,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1434,16 +1489,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1457,13 +1512,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/types": "8.2.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2080,9 +2135,9 @@ } }, "node_modules/eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "license": "MIT", "dependencies": { @@ -2090,7 +2145,7 @@ "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -2129,6 +2184,14 @@ }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -3709,10 +3772,11 @@ } }, "node_modules/rollup": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", - "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.1.tgz", + "integrity": "sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "1.0.5" }, @@ -3724,19 +3788,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.0", - "@rollup/rollup-android-arm64": "4.13.0", - "@rollup/rollup-darwin-arm64": "4.13.0", - "@rollup/rollup-darwin-x64": "4.13.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", - "@rollup/rollup-linux-arm64-gnu": "4.13.0", - "@rollup/rollup-linux-arm64-musl": "4.13.0", - "@rollup/rollup-linux-riscv64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-musl": "4.13.0", - "@rollup/rollup-win32-arm64-msvc": "4.13.0", - "@rollup/rollup-win32-ia32-msvc": "4.13.0", - "@rollup/rollup-win32-x64-msvc": "4.13.0", + "@rollup/rollup-android-arm-eabi": "4.21.1", + "@rollup/rollup-android-arm64": "4.21.1", + "@rollup/rollup-darwin-arm64": "4.21.1", + "@rollup/rollup-darwin-x64": "4.21.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.1", + "@rollup/rollup-linux-arm-musleabihf": "4.21.1", + "@rollup/rollup-linux-arm64-gnu": "4.21.1", + "@rollup/rollup-linux-arm64-musl": "4.21.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.1", + "@rollup/rollup-linux-riscv64-gnu": "4.21.1", + "@rollup/rollup-linux-s390x-gnu": "4.21.1", + "@rollup/rollup-linux-x64-gnu": "4.21.1", + "@rollup/rollup-linux-x64-musl": "4.21.1", + "@rollup/rollup-win32-arm64-msvc": "4.21.1", + "@rollup/rollup-win32-ia32-msvc": "4.21.1", + "@rollup/rollup-win32-x64-msvc": "4.21.1", "fsevents": "~2.3.2" } }, @@ -4151,10 +4218,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.0.13", @@ -4206,15 +4274,15 @@ } }, "node_modules/vite": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", - "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", + "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.40", - "rollup": "^4.13.0" + "postcss": "^8.4.41", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" diff --git a/cli/package.json b/cli/package.json index c3f2f708e2ba8..316f8fb37a43d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.15", + "version": "2.2.16", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -67,6 +67,6 @@ "lodash-es": "^4.17.21" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index 4774e1cacfe40..096177bb05366 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.38.0" - constraints = "4.38.0" + version = "4.40.0" + constraints = "4.40.0" hashes = [ - "h1:+27KAHKHBDvv3dqyJv5vhtdKQZJzoZXoMqIyronlHNw=", - "h1:/uV9RgOUhkxElkHhWs8fs5ZbX9vj6RCBfP0oJO0JF30=", - "h1:1DNAdMugJJOAWD/XYiZenYYZLy7fw2ctjT4YZmkRCVQ=", - "h1:1wn4PmCLdT7mvd74JkCGmJDJxTQDkcxc+1jNbmwnMHA=", - "h1:BIHB4fBxHg2bA9KbL92njhyctxKC8b6hNDp60y5QBss=", - "h1:HCQpvKPsMsR4HO5eDqt+Kao7T7CYeEH7KZIO7xMcC6M=", - "h1:HTomuzocukpNLwtWzeSF3yteCVsyVKbwKmN66u9iPac=", - "h1:YDxsUBhBAwHSXLzVwrSlSBOwv1NvLyry7s5SfCV7VqQ=", - "h1:dchVhxo+Acd1l2RuZ88tW9lWj4422QMfgtxKvKCjYrw=", - "h1:eypa+P4ZpsEGMPFuCE+6VkRefu0TZRFmVBOpK+PDOPY=", - "h1:f3yjse2OsRZj7ZhR7BLintJMlI4fpyt8HyDP/zcEavw=", - "h1:mSJ7xj8K+xcnEmGg7lH0jjzyQb157wH94ULTAlIV+HQ=", - "h1:tt+2J2Ze8VIdDq2Hr6uHlTJzAMBRpErBwTYx0uD5ilE=", - "h1:uQW8SKxmulqrAisO+365mIf2FueINAp5PY28bqCPCug=", - "zh:171ab67cccceead4514fafb2d39e4e708a90cce79000aaf3c29aab7ed4457071", - "zh:18aa7228447baaaefc49a43e8eff970817a7491a63d8937e796357a3829dd979", - "zh:2cbaab6092e81ba6f41fa60a50f14e980c8ec327ee11d0b21f16a478be4b7567", - "zh:53b8e49c06f5b31a8c681f8c0669cf43e78abe71657b8182a221d096bb514965", - "zh:6037cfc60b4b647aabae155fcb46d649ed7c650e0287f05db52b2068f1e27c8a", - "zh:62460982ce1a869eebfca675603fbbd50416cf6b69459fb855bfbe5ae2b97607", - "zh:65f6f3a8470917b6398baa5eb4f74b3932b213eac7c0202798bfad6fd1ee17df", + "h1:GP2N1tXrmpxu+qEDvFAmkfv9aeZNhag3bchyJpGpYbU=", + "h1:HDJKZBQkVU0kQl4gViQ5L7EcFLn9hB0iuvO+ORJiDS4=", + "h1:KrbeEsZoCJOnnX68yNI5h3QhMjc5bBCQW4yvYaEFq3s=", + "h1:LelwnzU0OVn6g2+T9Ub9XdpC+vbheraIL/qgXhWBs/k=", + "h1:TIq9CynfWrKgCxKL97Akj89cYlvJKn/AL4UXogd8/FM=", + "h1:Uoy5oPdm1ipDG7yIMCUN1IXMpsTGXahPw3I0rVA/6wA=", + "h1:Wunfpm+IZhENdoimrh4iXiakVnCsfKOHo80yJUjMQXM=", + "h1:cRdCuahMOFrNyldnCInqGQRBT1DTkRPSfPnaf5r05iw=", + "h1:k+zpXg8BO7gdbTIfSGyQisHhs5aVWQVbPLa5uUdr2UA=", + "h1:kWNrzZ8Rh0OpHikexkmwJIIucD6SMZPi4oGyDsKJitw=", + "h1:lomfTTjK78BdSEVTFcJUBQRy7IQHuGQImMaPWaYpfgQ=", + "h1:oWcWlZe52ZRyLQciNe94RaWzhHifSTu03nlK0uL7rlM=", + "h1:p3JJrhGEPlPQP7Uwy9FNMdvqCyD8tuT4lnXuJ+pSF/M=", + "h1:wtB0sKxG2K/H41hWJI4uJdImWquuaP34Sip5LmfE410=", + "zh:01742e5946f936548f8e42120287ffc757abf97e7cbbe34e25c266a438fb54fd", + "zh:08d81f5a5aab4cc269f983b8c6b5be0e278105136aca9681740802619577371f", + "zh:0d75131ba70902cfc94a7a5900369bdde56528b2aad6e10b164449cc97d57396", + "zh:3890a715a012e197541daacdacb8cceec6d364814daa4640ddfe98a8ba9036cb", + "zh:58254ce5ebe1faed4664df86210c39d660bcdc60280f17b25fe4d4dbea21ea8c", + "zh:6b0abc1adbc2edee79368ce9f7338ebcb5d0bf941e8d7d9ac505b750f20f80a2", + "zh:81cc415d1477174a1ca288d25fdb57e5ee488c2d7f61f265ef995b255a53b0ce", + "zh:8680140c7fe5beaefe61c5cfa471bf88422dc0c0f05dad6d3cb482d4ffd22be4", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8b5cebe64bf04105a49178a165b6a8800a9a33bae6767143a47fe4977755f805", - "zh:a5596635db0993ee3c3060fbc2227d91b239466e96d2d82642625a5aa2486988", - "zh:b3a9c63038441f13c311fd4b2c7e69e571445e5a7365a20c7cc9046b7e6c8aba", - "zh:b585e7e4d7648a540b14b9182819214896ca9337729eeb1f2034833b17db754d", - "zh:d2c3c545318ac8542369e9fc8228e29ee585febdf203a450fad3e0eded71ce02", - "zh:e95dd2d6c3525073af47d47b763cb81b6a51b20cabf76f789c69328922da9ecf", - "zh:eee6e590b36d6c6168a7daae8afa74a8721fd7aa9f62a710f04a311975100722", + "zh:a491d26236122ccb83dac8cb490d2c0aa1f4d3a0b4abe99300fd49b1a624f42f", + "zh:a70d9c469dc8d55715ba77c9d1a4ede1fdebf79e60ee18438a0844868db54e0d", + "zh:a7fcb7d5c4222e14ec6d9a15adf8b9a083d84b102c3d0e4a0d102df5a1360b62", + "zh:b4f9677174fabd199c8ebd2e9e5eb3528cf887e700569a4fb61eef4e070cec5e", + "zh:c27f0f7519221d75dae4a3787a59e05acd5cc9a0d30a390eff349a77d20d52e6", + "zh:db00d8605dbf43ca42fe1481a6c67fdcaa73debb7d2a0f613cb95ae5c5e7150e", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index b7c70f1c21719..63c96fc49805b 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.38.0" + version = "4.40.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index 4774e1cacfe40..096177bb05366 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.38.0" - constraints = "4.38.0" + version = "4.40.0" + constraints = "4.40.0" hashes = [ - "h1:+27KAHKHBDvv3dqyJv5vhtdKQZJzoZXoMqIyronlHNw=", - "h1:/uV9RgOUhkxElkHhWs8fs5ZbX9vj6RCBfP0oJO0JF30=", - "h1:1DNAdMugJJOAWD/XYiZenYYZLy7fw2ctjT4YZmkRCVQ=", - "h1:1wn4PmCLdT7mvd74JkCGmJDJxTQDkcxc+1jNbmwnMHA=", - "h1:BIHB4fBxHg2bA9KbL92njhyctxKC8b6hNDp60y5QBss=", - "h1:HCQpvKPsMsR4HO5eDqt+Kao7T7CYeEH7KZIO7xMcC6M=", - "h1:HTomuzocukpNLwtWzeSF3yteCVsyVKbwKmN66u9iPac=", - "h1:YDxsUBhBAwHSXLzVwrSlSBOwv1NvLyry7s5SfCV7VqQ=", - "h1:dchVhxo+Acd1l2RuZ88tW9lWj4422QMfgtxKvKCjYrw=", - "h1:eypa+P4ZpsEGMPFuCE+6VkRefu0TZRFmVBOpK+PDOPY=", - "h1:f3yjse2OsRZj7ZhR7BLintJMlI4fpyt8HyDP/zcEavw=", - "h1:mSJ7xj8K+xcnEmGg7lH0jjzyQb157wH94ULTAlIV+HQ=", - "h1:tt+2J2Ze8VIdDq2Hr6uHlTJzAMBRpErBwTYx0uD5ilE=", - "h1:uQW8SKxmulqrAisO+365mIf2FueINAp5PY28bqCPCug=", - "zh:171ab67cccceead4514fafb2d39e4e708a90cce79000aaf3c29aab7ed4457071", - "zh:18aa7228447baaaefc49a43e8eff970817a7491a63d8937e796357a3829dd979", - "zh:2cbaab6092e81ba6f41fa60a50f14e980c8ec327ee11d0b21f16a478be4b7567", - "zh:53b8e49c06f5b31a8c681f8c0669cf43e78abe71657b8182a221d096bb514965", - "zh:6037cfc60b4b647aabae155fcb46d649ed7c650e0287f05db52b2068f1e27c8a", - "zh:62460982ce1a869eebfca675603fbbd50416cf6b69459fb855bfbe5ae2b97607", - "zh:65f6f3a8470917b6398baa5eb4f74b3932b213eac7c0202798bfad6fd1ee17df", + "h1:GP2N1tXrmpxu+qEDvFAmkfv9aeZNhag3bchyJpGpYbU=", + "h1:HDJKZBQkVU0kQl4gViQ5L7EcFLn9hB0iuvO+ORJiDS4=", + "h1:KrbeEsZoCJOnnX68yNI5h3QhMjc5bBCQW4yvYaEFq3s=", + "h1:LelwnzU0OVn6g2+T9Ub9XdpC+vbheraIL/qgXhWBs/k=", + "h1:TIq9CynfWrKgCxKL97Akj89cYlvJKn/AL4UXogd8/FM=", + "h1:Uoy5oPdm1ipDG7yIMCUN1IXMpsTGXahPw3I0rVA/6wA=", + "h1:Wunfpm+IZhENdoimrh4iXiakVnCsfKOHo80yJUjMQXM=", + "h1:cRdCuahMOFrNyldnCInqGQRBT1DTkRPSfPnaf5r05iw=", + "h1:k+zpXg8BO7gdbTIfSGyQisHhs5aVWQVbPLa5uUdr2UA=", + "h1:kWNrzZ8Rh0OpHikexkmwJIIucD6SMZPi4oGyDsKJitw=", + "h1:lomfTTjK78BdSEVTFcJUBQRy7IQHuGQImMaPWaYpfgQ=", + "h1:oWcWlZe52ZRyLQciNe94RaWzhHifSTu03nlK0uL7rlM=", + "h1:p3JJrhGEPlPQP7Uwy9FNMdvqCyD8tuT4lnXuJ+pSF/M=", + "h1:wtB0sKxG2K/H41hWJI4uJdImWquuaP34Sip5LmfE410=", + "zh:01742e5946f936548f8e42120287ffc757abf97e7cbbe34e25c266a438fb54fd", + "zh:08d81f5a5aab4cc269f983b8c6b5be0e278105136aca9681740802619577371f", + "zh:0d75131ba70902cfc94a7a5900369bdde56528b2aad6e10b164449cc97d57396", + "zh:3890a715a012e197541daacdacb8cceec6d364814daa4640ddfe98a8ba9036cb", + "zh:58254ce5ebe1faed4664df86210c39d660bcdc60280f17b25fe4d4dbea21ea8c", + "zh:6b0abc1adbc2edee79368ce9f7338ebcb5d0bf941e8d7d9ac505b750f20f80a2", + "zh:81cc415d1477174a1ca288d25fdb57e5ee488c2d7f61f265ef995b255a53b0ce", + "zh:8680140c7fe5beaefe61c5cfa471bf88422dc0c0f05dad6d3cb482d4ffd22be4", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8b5cebe64bf04105a49178a165b6a8800a9a33bae6767143a47fe4977755f805", - "zh:a5596635db0993ee3c3060fbc2227d91b239466e96d2d82642625a5aa2486988", - "zh:b3a9c63038441f13c311fd4b2c7e69e571445e5a7365a20c7cc9046b7e6c8aba", - "zh:b585e7e4d7648a540b14b9182819214896ca9337729eeb1f2034833b17db754d", - "zh:d2c3c545318ac8542369e9fc8228e29ee585febdf203a450fad3e0eded71ce02", - "zh:e95dd2d6c3525073af47d47b763cb81b6a51b20cabf76f789c69328922da9ecf", - "zh:eee6e590b36d6c6168a7daae8afa74a8721fd7aa9f62a710f04a311975100722", + "zh:a491d26236122ccb83dac8cb490d2c0aa1f4d3a0b4abe99300fd49b1a624f42f", + "zh:a70d9c469dc8d55715ba77c9d1a4ede1fdebf79e60ee18438a0844868db54e0d", + "zh:a7fcb7d5c4222e14ec6d9a15adf8b9a083d84b102c3d0e4a0d102df5a1360b62", + "zh:b4f9677174fabd199c8ebd2e9e5eb3528cf887e700569a4fb61eef4e070cec5e", + "zh:c27f0f7519221d75dae4a3787a59e05acd5cc9a0d30a390eff349a77d20d52e6", + "zh:db00d8605dbf43ca42fe1481a6c67fdcaa73debb7d2a0f613cb95ae5c5e7150e", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index b7c70f1c21719..63c96fc49805b 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.38.0" + version = "4.40.0" } } } diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index fd4ed4f1c958e..733905e01dad7 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -79,7 +79,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:cafe963e591c872d38f3ea41ff8eb22cee97917b7c97b5c0ccd43a419f11f613 + image: prom/prometheus@sha256:f6639335d34a77d9d9db382b92eeb7fc00934be8eae81dbc03b31cfe90411a94 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus @@ -91,7 +91,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.1.3-ubuntu@sha256:e10453733015f31103cb530425f32c994816b50102886fa885dafea2c50a711c + image: grafana/grafana:11.1.4-ubuntu@sha256:8e74fb7eed4d59fb5595acd0576c21411167f6b6401426ae29f2e8f9f71b68f6 volumes: - grafana-data:/var/lib/grafana diff --git a/docs/.nvmrc b/docs/.nvmrc index 8ce7030825b5e..3516580bbbc04 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 117ca74c037ca..b1a24e1788a2f 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -52,14 +52,25 @@ On iOS (iPhone and iPad), the operating system determines if a particular app ca - Disable Background App Refresh for apps that don't need background tasks to run. This will reduce the competition for background task invocation for Immich. - Use the Immich app more often. +### Why are features not working with a self-signed cert or mTLS? + +Due to limitations in the upstream app/video library, using a self-signed TLS certificate or mutual TLS may break video playback or asset upload (both foreground and/or background). +We recommend using a real SSL certificate from a free provider, for example [Let's Encrypt](https://letsencrypt.org/). + --- ## Assets ### Does Immich change the file? -No, Immich does not touch the original file under any circumstances, -all edited metadata are saved in the companion sidecar file and the database. +No, Immich does not modify the original files. +All edited metadata is saved in companion `.xmp` sidecar files and the database. +However, Immich will delete original files that have been trashed when the trash is emptied in the Immich UI. + +### Why do my file names appear as a random string in the file manager? + +When Storage Template is off (default) Immich saves the file names in a random string (also known as random UUIDs) to prevent duplicate file names. To retrieve the original file names, you must enable the Storage Template and then run the STORAGE TEMPLATE MIGRATION job. +It is recommended to read about [Storage Template](https://immich.app/docs/administration/storage-template) before activation. ### Can I add my existing photo library? @@ -157,6 +168,19 @@ We haven't implemented an official mechanism for creating albums from external l Duplicate checking only exists for upload libraries, using the file hash. Furthermore, duplicate checking is not global, but _per library_. Therefore, a situation where the same file appears twice in the timeline is possible, especially for external libraries. +### Why are my edits to files not being saved in read-only external libraries? + +Images in read-write external libraries (the default) can be edited as normal. +In read-only libraries (`:ro` in the `docker-compose.yml`), Immich is unable to create the `.xmp` sidecar files to store edited file metadata. +For this reason, the metadata (timestamp, location, description, star rating, etc.) cannot be edited for files in read-only external libraries. + +### How are deletions of files handled in external libraries? + +Immich will attempt to delete original files that have been trashed when the trash is emptied. +In read-write external libraries (the default), Immich will delete the original file. +In read-only libraries (`:ro` in the `docker-compose.yml`), files can still be trashed in the UI. +However, when the trash is emptied, the files will re-appear in the main timeline since Immich is unable to delete the original file. + --- ## Machine Learning diff --git a/docs/docs/administration/img/admin-jobs.png b/docs/docs/administration/img/admin-jobs.png deleted file mode 100644 index 096bce4354f0f..0000000000000 Binary files a/docs/docs/administration/img/admin-jobs.png and /dev/null differ diff --git a/docs/docs/administration/img/admin-jobs.webp b/docs/docs/administration/img/admin-jobs.webp new file mode 100644 index 0000000000000..15580c3c9cc8c Binary files /dev/null and b/docs/docs/administration/img/admin-jobs.webp differ diff --git a/docs/docs/administration/jobs-workers.md b/docs/docs/administration/jobs-workers.md index ff74ea4673f7c..fb5ca7c059165 100644 --- a/docs/docs/administration/jobs-workers.md +++ b/docs/docs/administration/jobs-workers.md @@ -52,4 +52,4 @@ Additionally, some jobs run on a schedule, which is every night at midnight. Thi Storage Migration job can be run after changing the [Storage Template](/docs/administration/storage-template.mdx), in order to apply the change to the existing library. ::: - + diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index ab317787bc09c..2aba658933b24 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -3,7 +3,7 @@ This page contains details about using OAuth in Immich. :::tip -Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI](#mobile-redirect-uri) for an alternative solution. +Unable to set `app.immich:///oauth-callback` as a valid redirect URI? See [Mobile Redirect URI](#mobile-redirect-uri) for an alternative solution. ::: ## Overview @@ -30,7 +30,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured The **Sign-in redirect URIs** should include: - - `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx) + - `app.immich:///oauth-callback` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx) - `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client - `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client @@ -38,7 +38,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured Mobile - - `app.immich:/` (You **MUST** include this for iOS and Android mobile apps to work properly) + - `app.immich:///oauth-callback` (You **MUST** include this for iOS and Android mobile apps to work properly) Localhost @@ -96,16 +96,16 @@ When Auto Launch is enabled, the login page will automatically redirect the user ## Mobile Redirect URI -The redirect URI for the mobile app is `app.immich:/`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following: +The redirect URI for the mobile app is `app.immich:///oauth-callback`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following: -1. Configure an http(s) endpoint to forwards requests to `app.immich:/` +1. Configure an http(s) endpoint to forwards requests to `app.immich:///oauth-callback` 2. Whitelist the new endpoint as a valid redirect URI with your provider. 3. Specify the new endpoint as the `Mobile Redirect URI Override`, in the OAuth settings. With these steps in place, you should be able to use OAuth from the [Mobile App](/docs/features/mobile-app.mdx) without a custom scheme redirect URI. :::info -Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to forward requests to `app.immich:/`, and can be used for step 1. +Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to forward requests to `app.immich:///oauth-callback`, and can be used for step 1. ::: ## Example Configuration @@ -154,21 +154,21 @@ Configuration of Authorised redirect URIs (Google Console) Configuration of OAuth in Immich System Settings -| Setting | Value | -| ---------------------------- | ------------------------------------------------------------------------------------------------------ | -| Issuer URL | [https://accounts.google.com](https://accounts.google.com) | -| Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com | -| Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO | -| Scope | openid email profile | -| Signing Algorithm | RS256 | -| Storage Label Claim | preferred_username | -| Storage Quota Claim | immich_quota | -| Default Storage Quota (GiB) | 0 (0 for unlimited quota) | -| Button Text | Sign in with Google (optional) | -| Auto Register | Enabled (optional) | -| Auto Launch | Enabled | -| Mobile Redirect URI Override | Enabled (required) | -| Mobile Redirect URI | [https://demo.immich.app/api/oauth/mobile-redirect](https://demo.immich.app/api/oauth/mobile-redirect) | +| Setting | Value | +| ---------------------------- | ---------------------------------------------------------------------------------------------------- | +| Issuer URL | `https://accounts.google.com` | +| Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com | +| Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO | +| Scope | openid email profile | +| Signing Algorithm | RS256 | +| Storage Label Claim | preferred_username | +| Storage Quota Claim | immich_quota | +| Default Storage Quota (GiB) | 0 (0 for unlimited quota) | +| Button Text | Sign in with Google (optional) | +| Auto Register | Enabled (optional) | +| Auto Launch | Enabled | +| Mobile Redirect URI Override | Enabled (required) | +| Mobile Redirect URI | `https://example.immich.app/api/oauth/mobile-redirect` | diff --git a/docs/docs/administration/system-settings.md b/docs/docs/administration/system-settings.md index ab8abe05c9416..9f35ed1010e2f 100644 --- a/docs/docs/administration/system-settings.md +++ b/docs/docs/administration/system-settings.md @@ -104,7 +104,7 @@ You can choose to disable a certain type of machine learning, for example smart ### Smart Search -The smart search settings are designed to allow the search tool to be used using [CLIP](https://openai.com/research/clip) models that [can be changed](/docs/FAQ#can-i-use-a-custom-clip-model), different models will necessarily give better results but may consume more processing power, when changing a model it is mandatory to re-run the +The [smart search](/docs/features/smart-search) settings are designed to allow the search tool to be used using [CLIP](https://openai.com/research/clip) models that [can be changed](/docs/FAQ#can-i-use-a-custom-clip-model), different models will necessarily give better results but may consume more processing power, when changing a model it is mandatory to re-run the Smart Search job on all images to fully apply the change. :::info Internet connection @@ -113,15 +113,23 @@ After downloading, there is no need for Immich to connect to the network Unless version checking has been enabled in the settings. ::: +### Duplicate Detection + +Use CLIP embeddings to find likely duplicates. The maximum detection distance can be configured in order to improve / reduce the level of accuracy. + +- **Maximum detection distance -** Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives. + ### Facial Recognition Under these settings, you can change the facial recognition settings Editable settings: -- **Facial Recognition Model -** Models are listed in descending order of size. Larger models are slower and use more memory, but produce better results. Note that you must re-run the Face Detection job for all images upon changing a model. -- **Min Detection Score -** Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives. -- **Max Recognition Distance -** Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible. -- **Min Recognized Faces -** The minimum number of recognized faces for a person to be created (AKA: Core face). Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person. +- **Facial Recognition Model** +- **Min Detection Score** +- **Max Recognition Distance** +- **Min Recognized Faces** + +You can learn more about these options on the [Facial Recognition page](/docs/features/facial-recognition#how-face-detection-works) :::info When changing the values in Min Detection Score, Max Recognition Distance, and Min Recognized Faces. @@ -153,7 +161,7 @@ SMTP server setup, for user creation notifications, new albums, etc. More inform ### External Domain -When set, will override the domain name used when viewing and copying a shared link. +Overrides the domain name in shared links and email notifications. The URL should not include a trailing slash. ### Welcome Message diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 94cbff6ebe393..cdea1a11a51e1 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -104,8 +104,8 @@ The `immich-server` container will need access to the gallery. Modify your docke immich-server: volumes: - ${UPLOAD_LOCATION}:/usr/src/app/upload -+ - /mnt/nas/christmas-trip:/mnt/nas/christmas-trip:ro -+ - /home/user/old-pics:/home/user/old-pics:ro ++ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro ++ - /home/user/old-pics:/mnt/media/old-pics:ro + - /mnt/media/videos:/mnt/media/videos:ro + - /mnt/media/videos2:/mnt/media/videos2 # the files in this folder can be deleted, as it does not end with :ro + - "C:/Users/user_name/Desktop/my media:/mnt/media/my-media:ro" # import path in Windows system. diff --git a/docs/docs/features/shared-albums.md b/docs/docs/features/shared-albums.md index 2684acfd9c5be..dcf884bc9bbe5 100644 --- a/docs/docs/features/shared-albums.md +++ b/docs/docs/features/shared-albums.md @@ -16,7 +16,7 @@ When sharing shared albums, whats shared is: - Download all assets as zip file (Web only). :::info Archive size limited. - If the size of the album exceeds 4GB, the archive files will be divided into 4GB each. + If the size of the album exceeds 4GB, the archive files will by default be divided into 4GB each. This can be changed on the user settings page. ::: - Add a description to the album (Web only). - Slideshow view (Web only). @@ -152,7 +152,7 @@ Some of the features are not available on mobile, to understand what the full fe ## Sharing Between Users -#### Add or remove users from the album. +#### Add or remove users from the album :::info remove user(s) When a user is removed from the album, the photos he uploaded will still appear in the album. diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index 20b841f4027dc..2b4f27cfceaa5 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -23,7 +23,7 @@ SELECT * FROM "assets" WHERE "originalFileName" LIKE '%_2023_%'; -- all files wi ``` ```sql title="Find by path" -SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_20230903_232542848.jpg'; +SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_2023.jpg'; SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%'; ``` @@ -37,6 +37,12 @@ SELECT * FROM "assets" WHERE "checksum" = decode('69de19c87658c4c15d9cacb9967b8e SELECT * FROM "assets" WHERE "checksum" = '\x69de19c87658c4c15d9cacb9967b8e033bf74dd1'; -- alternate notation ``` +```sql title="Find duplicate assets with identical checksum (SHA-1) (excluding trashed files)" +SELECT T1."checksum", array_agg(T2."id") ids FROM "assets" T1 + INNER JOIN "assets" T2 ON T1."checksum" = T2."checksum" AND T1."id" != T2."id" AND T2."deletedAt" IS NULL + WHERE T1."deletedAt" IS NULL GROUP BY T1."checksum"; +``` + ```sql title="Live photos" SELECT * FROM "assets" WHERE "livePhotoVideoId" IS NOT NULL; ``` @@ -79,8 +85,7 @@ SELECT "assets"."type", COUNT(*) FROM "assets" GROUP BY "assets"."type"; ```sql title="Count by type (per user)" SELECT "users"."email", "assets"."type", COUNT(*) FROM "assets" JOIN "users" ON "assets"."ownerId" = "users"."id" - GROUP BY "assets"."type", "users"."email" - ORDER BY "users"."email"; + GROUP BY "assets"."type", "users"."email" ORDER BY "users"."email"; ``` ```sql title="Failed file movements" diff --git a/docs/docs/guides/remote-access.md b/docs/docs/guides/remote-access.md index cd8bf66f14cc9..1ea068c3a0a79 100644 --- a/docs/docs/guides/remote-access.md +++ b/docs/docs/guides/remote-access.md @@ -11,13 +11,13 @@ Never forward port 2283 directly to the internet without additional configuratio You may use a VPN service to open an encrypted connection to your Immich instance. OpenVPN and Wireguard are two popular VPN solutions. Here is a guide on setting up VPN access to your server - [Pihole documentation](https://docs.pi-hole.net/guides/vpn/wireguard/overview/) -### Pros: +### Pros - Simple to set up and very secure. - Single point of potential failure, i.e., the VPN software itself. Even if there is a zero-day vulnerability on Immich, you will not be at risk. - Both Wireguard and OpenVPN are independently security-audited, so the risk of serious zero-day exploits are minimal. -### Cons: +### Cons - If you don't have a static IP address, you would need to set up a [Dynamic DNS](https://www.cloudflare.com/learning/dns/glossary/dynamic-dns/). [DuckDNS](https://www.duckdns.org/) is a free DDNS provider. - VPN software needs to be installed and active on both server-side and client-side. @@ -27,6 +27,10 @@ You may use a VPN service to open an encrypted connection to your Immich instanc If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation). +:::tip Video toturial +You can learn how to set up Tailscale together with Immich with the [tutorial video](https://www.youtube.com/watch?v=Vt4PDUXB_fg) they created. +::: + ### Pros - Minimal configuration needed on server and client sides. @@ -44,7 +48,7 @@ A reverse proxy is a service that sits between web servers and clients. A revers If you're hosting your own reverse proxy, [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) is a great option. An example configuration for Nginx is provided [here](/docs/administration/reverse-proxy.md). -You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accesible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser. +You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accessible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser. A remote reverse proxy like [Cloudflare](https://www.cloudflare.com/learning/cdn/glossary/reverse-proxy/) increases security by hiding the server IP address, which makes targeted attacks like [DDoS](https://www.cloudflare.com/learning/ddos/what-is-a-ddos-attack/) harder. diff --git a/docs/docs/guides/remote-machine-learning.md b/docs/docs/guides/remote-machine-learning.md index e4186e1697db3..4dbb72a408f16 100644 --- a/docs/docs/guides/remote-machine-learning.md +++ b/docs/docs/guides/remote-machine-learning.md @@ -11,6 +11,10 @@ To alleviate [performance issues on low-memory systems](/docs/FAQ.mdx#why-is-imm Smart Search and Face Detection will use this feature, but Facial Recognition is handled in the server. ::: +:::danger +When using remote machine learning, the thumbnails are sent to the remote machine learning container. Use this option carefully when running this on a public computer or a paid processing cloud. +::: + ```yaml name: immich_remote_ml diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index 0b69bd8639838..9ef63523a05ec 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -109,7 +109,7 @@ Immich is currently under heavy development, which means you can expect [breakin [compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml [env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env [watchtower]: https://containrrr.dev/watchtower/ -[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Abreaking-change+sort%3Adate_created +[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created [container-auth]: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry [releases]: https://github.com/immich-app/immich/releases [docker-repo]: https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 78cd16cf1b7c2..9a4b0b9360b78 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -159,26 +159,29 @@ Redis (Sentinel) URL example JSON before encoding: ## Machine Learning -| Variable | Description | Default | Containers | -| :----------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------------: | :--------------- | -| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | -| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning | -| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | -| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | -| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | -| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | -| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | -| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO image) | machine learning | -| `MACHINE_LEARNING_PRELOAD__CLIP` | Name of a CLIP model to be preloaded and kept in cache | | machine learning | -| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION` | Name of a facial recognition model to be preloaded and kept in cache | | machine learning | -| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | -| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | -| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | +| Variable | Description | Default | Containers | +| :-------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------------: | :--------------- | +| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | +| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning | +| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | +| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | +| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | +| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | +| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | +| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning | +| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO image) | machine learning | +| `MACHINE_LEARNING_PRELOAD__CLIP` | Name of a CLIP model to be preloaded and kept in cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION` | Name of a facial recognition model to be preloaded and kept in cache | | machine learning | +| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | +| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | +| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | \*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. \*2: Since each process duplicates models in memory, changing this is not recommended unless you have abundant memory to go around. +\*3: For scenarios like HPA in K8S. https://github.com/immich-app/immich/discussions/12064 + :::info Other machine learning parameters can be tuned from the admin UI. diff --git a/docs/package-lock.json b/docs/package-lock.json index e5fb9f8b2aae7..c67c2b64fcb4e 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -2155,9 +2155,9 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.4.0.tgz", - "integrity": "sha512-g+0wwmN2UJsBqy2fQRQ6fhXruoEa62JDeEa5d8IdTJlMoaDaEDfHh7WjwGRn4opuTQWpjAwP/fbcgyHKlE+64w==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.5.2.tgz", + "integrity": "sha512-4Z1WkhCSkX4KO0Fw5m/Vuc7Q3NxBG53NE5u59Rs96fWkMPZVSrzEPP16/Nk6cWb/shK7xXPndTmalJtw7twL/w==", "license": "MIT", "dependencies": { "@babel/core": "^7.23.3", @@ -2170,12 +2170,12 @@ "@babel/runtime": "^7.22.6", "@babel/runtime-corejs3": "^7.22.6", "@babel/traverse": "^7.22.8", - "@docusaurus/cssnano-preset": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/cssnano-preset": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "autoprefixer": "^10.4.14", "babel-loader": "^9.1.3", "babel-plugin-dynamic-import-node": "^2.3.3", @@ -2236,14 +2236,15 @@ "node": ">=18.0" }, "peerDependencies": { + "@mdx-js/react": "^3.0.0", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.4.0.tgz", - "integrity": "sha512-qwLFSz6v/pZHy/UP32IrprmH5ORce86BGtN0eBtG75PpzQJAzp9gefspox+s8IEOr0oZKuQ/nhzZ3xwyc3jYJQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.5.2.tgz", + "integrity": "sha512-D3KiQXOMA8+O0tqORBrTOEQyQxNIfPm9jEaJoALjjSjc2M/ZAWcUfPQEnwr2JB2TadHw2gqWgpZckQmrVWkytA==", "license": "MIT", "dependencies": { "cssnano-preset-advanced": "^6.1.2", @@ -2256,9 +2257,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.4.0.tgz", - "integrity": "sha512-bZwkX+9SJ8lB9kVRkXw+xvHYSMGG4bpYHKGXeXFvyVc79NMeeBSGgzd4TQLHH+DYeOJoCdl8flrFJVxlZ0wo/Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.5.2.tgz", + "integrity": "sha512-LHC540SGkeLfyT3RHK3gAMK6aS5TRqOD4R72BEU/DE2M/TY8WwEUAMY576UUc/oNJXv8pGhBmQB6N9p3pt8LQw==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -2269,14 +2270,14 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.4.0.tgz", - "integrity": "sha512-kSSbrrk4nTjf4d+wtBA9H+FGauf2gCax89kV8SUSJu3qaTdSIKdWERlngsiHaCFgZ7laTJ8a67UFf+xlFPtuTw==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.5.2.tgz", + "integrity": "sha512-ku3xO9vZdwpiMIVd8BzWV0DCqGEbCP5zs1iHfKX50vw6jX8vQo0ylYo1YJMZyz6e+JFJ17HYHT5FzVidz2IflA==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/logger": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", @@ -2308,12 +2309,12 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.4.0.tgz", - "integrity": "sha512-A1AyS8WF5Bkjnb8s+guTDuYmUiwJzNrtchebBHpc0gz0PyHJNMaybUlSrmJjHVcGrya0LKI4YcR3lBDQfXRYLw==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.5.2.tgz", + "integrity": "sha512-Z+Xu3+2rvKef/YKTMxZHsEXp1y92ac0ngjDiExRdqGTmEKtCUpkbNYH8v5eXo5Ls+dnW88n6WTa+Q54kLOkwPg==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.4.0", + "@docusaurus/types": "3.5.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -2326,52 +2327,21 @@ "react-dom": "*" } }, - "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.4.0.tgz", - "integrity": "sha512-vv6ZAj78ibR5Jh7XBUT4ndIjmlAxkijM3Sx5MAAzC1gyv0vupDQNhzuFg1USQmQVj3P5I6bquk12etPV3LJ+Xw==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", - "cheerio": "^1.0.0-rc.12", - "feed": "^4.2.2", - "fs-extra": "^11.1.1", - "lodash": "^4.17.21", - "reading-time": "^1.5.0", - "srcset": "^4.0.0", - "tslib": "^2.6.0", - "unist-util-visit": "^5.0.0", - "utility-types": "^3.10.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.4.0.tgz", - "integrity": "sha512-HkUCZffhBo7ocYheD9oZvMcDloRnGhBMOZRyVcAQRFmZPmNqSyISlXA1tQCIxW+r478fty97XXAGjNYzBjpCsg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.5.2.tgz", + "integrity": "sha512-Bt+OXn/CPtVqM3Di44vHjE7rPCEsRCB/DMo2qoOuozB9f7+lsdrHvD0QCHdBs0uhz6deYJDppAr2VgqybKPlVQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/module-type-aliases": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/module-type-aliases": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", @@ -2389,38 +2359,15 @@ "react-dom": "^18.0.0" } }, - "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.4.0.tgz", - "integrity": "sha512-h2+VN/0JjpR8fIkDEAoadNjfR3oLzB+v1qSXbIAKjQ46JAHx3X22n9nqS+BWSQnTnp1AjkjSvZyJMekmcwxzxg==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", - "fs-extra": "^11.1.1", - "tslib": "^2.6.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/@docusaurus/plugin-debug": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.4.0.tgz", - "integrity": "sha512-uV7FDUNXGyDSD3PwUaf5YijX91T5/H9SX4ErEcshzwgzWwBtK37nUWPU3ZLJfeTavX3fycTOqk9TglpOLaWkCg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.5.2.tgz", + "integrity": "sha512-kBK6GlN0itCkrmHuCS6aX1wmoWc5wpd5KJlqQ1FyrF0cLDnvsYSnh7+ftdwzt7G6lGBho8lrVwkkL9/iQvaSOA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", "fs-extra": "^11.1.1", "react-json-view-lite": "^1.2.0", "tslib": "^2.6.0" @@ -2434,14 +2381,14 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.4.0.tgz", - "integrity": "sha512-mCArluxEGi3cmYHqsgpGGt3IyLCrFBxPsxNZ56Mpur0xSlInnIHoeLDH7FvVVcPJRPSQ9/MfRqLsainRw+BojA==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.5.2.tgz", + "integrity": "sha512-rjEkJH/tJ8OXRE9bwhV2mb/WP93V441rD6XnM6MIluu7rk8qg38iSxS43ga2V2Q/2ib53PcqbDEJDG/yWQRJhQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "tslib": "^2.6.0" }, "engines": { @@ -2453,14 +2400,14 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.4.0.tgz", - "integrity": "sha512-Dsgg6PLAqzZw5wZ4QjUYc8Z2KqJqXxHxq3vIoyoBWiLEEfigIs7wHR+oiWUQy3Zk9MIk6JTYj7tMoQU0Jm3nqA==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.5.2.tgz", + "integrity": "sha512-lm8XL3xLkTPHFKKjLjEEAHUrW0SZBSHBE1I+i/tmYMBsjCcUB5UJ52geS5PSiOCFVR74tbPGcPHEV/gaaxFeSA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@types/gtag.js": "^0.0.12", "tslib": "^2.6.0" }, @@ -2473,14 +2420,14 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.4.0.tgz", - "integrity": "sha512-O9tX1BTwxIhgXpOLpFDueYA9DWk69WCbDRrjYoMQtFHSkTyE7RhNgyjSPREUWJb9i+YUg3OrsvrBYRl64FCPCQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.5.2.tgz", + "integrity": "sha512-QkpX68PMOMu10Mvgvr5CfZAzZQFx8WLlOiUQ/Qmmcl6mjGK6H21WLT5x7xDmcpCoKA/3CegsqIqBR+nA137lQg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "tslib": "^2.6.0" }, "engines": { @@ -2492,17 +2439,17 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.4.0.tgz", - "integrity": "sha512-+0VDvx9SmNrFNgwPoeoCha+tRoAjopwT0+pYO1xAbyLcewXSemq+eLxEa46Q1/aoOaJQ0qqHELuQM7iS2gp33Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.5.2.tgz", + "integrity": "sha512-DnlqYyRAdQ4NHY28TfHuVk414ft2uruP4QWCH//jzpHjqvKyXjj2fmDtI8RPUBh9K8iZKFMHRnLtzJKySPWvFA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -2516,24 +2463,81 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.4.0.tgz", - "integrity": "sha512-Ohj6KB7siKqZaQhNJVMBBUzT3Nnp6eTKqO+FXO3qu/n1hJl3YLwVKTWBg28LF7MWrKu46UuYavwMRxud0VyqHg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.5.2.tgz", + "integrity": "sha512-3ihfXQ95aOHiLB5uCu+9PRy2gZCeSZoDcqpnDvf3B+sTrMvMTr8qRUzBvWkoIqc82yG5prCboRjk1SVILKx6sg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/plugin-content-blog": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/plugin-content-pages": "3.4.0", - "@docusaurus/plugin-debug": "3.4.0", - "@docusaurus/plugin-google-analytics": "3.4.0", - "@docusaurus/plugin-google-gtag": "3.4.0", - "@docusaurus/plugin-google-tag-manager": "3.4.0", - "@docusaurus/plugin-sitemap": "3.4.0", - "@docusaurus/theme-classic": "3.4.0", - "@docusaurus/theme-common": "3.4.0", - "@docusaurus/theme-search-algolia": "3.4.0", - "@docusaurus/types": "3.4.0" + "@docusaurus/core": "3.5.2", + "@docusaurus/plugin-content-blog": "3.5.2", + "@docusaurus/plugin-content-docs": "3.5.2", + "@docusaurus/plugin-content-pages": "3.5.2", + "@docusaurus/plugin-debug": "3.5.2", + "@docusaurus/plugin-google-analytics": "3.5.2", + "@docusaurus/plugin-google-gtag": "3.5.2", + "@docusaurus/plugin-google-tag-manager": "3.5.2", + "@docusaurus/plugin-sitemap": "3.5.2", + "@docusaurus/theme-classic": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/theme-search-algolia": "3.5.2", + "@docusaurus/types": "3.5.2" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/preset-classic/node_modules/@docusaurus/plugin-content-blog": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.5.2.tgz", + "integrity": "sha512-R7ghWnMvjSf+aeNDH0K4fjyQnt5L0KzUEnUhmf1e3jZrv3wogeytZNN6n7X8yHcMsuZHPOrctQhXWnmxu+IRRg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "cheerio": "1.0.0-rc.12", + "feed": "^4.2.2", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "reading-time": "^1.5.0", + "srcset": "^4.0.0", + "tslib": "^2.6.0", + "unist-util-visit": "^5.0.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/preset-classic/node_modules/@docusaurus/plugin-content-pages": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.5.2.tgz", + "integrity": "sha512-WzhHjNpoQAUz/ueO10cnundRz+VUtkjFhhaQ9jApyv1a46FPURO4cef89pyNIOMny1fjDz/NUN2z6Yi+5WUrCw==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0", + "webpack": "^5.88.1" }, "engines": { "node": ">=18.0" @@ -2544,27 +2548,27 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.4.0.tgz", - "integrity": "sha512-0IPtmxsBYv2adr1GnZRdMkEQt1YW6tpzrUPj02YxNpvJ5+ju4E13J5tB4nfdaen/tfR1hmpSPlTFPvTf4kwy8Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.5.2.tgz", + "integrity": "sha512-XRpinSix3NBv95Rk7xeMF9k4safMkwnpSgThn0UNQNumKvmcIYjfkwfh2BhwYh/BxMXQHJ/PdmNh22TQFpIaYg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/module-type-aliases": "3.4.0", - "@docusaurus/plugin-content-blog": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/plugin-content-pages": "3.4.0", - "@docusaurus/theme-common": "3.4.0", - "@docusaurus/theme-translations": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/module-type-aliases": "3.5.2", + "@docusaurus/plugin-content-blog": "3.5.2", + "@docusaurus/plugin-content-docs": "3.5.2", + "@docusaurus/plugin-content-pages": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/theme-translations": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", - "infima": "0.2.0-alpha.43", + "infima": "0.2.0-alpha.44", "lodash": "^4.17.21", "nprogress": "^0.2.0", "postcss": "^8.4.26", @@ -2583,19 +2587,73 @@ "react-dom": "^18.0.0" } }, - "node_modules/@docusaurus/theme-common": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.4.0.tgz", - "integrity": "sha512-0A27alXuv7ZdCg28oPE8nH/Iz73/IUejVaCazqu9elS4ypjiLhK3KfzdSQBnL/g7YfHSlymZKdiOHEo8fJ0qMA==", + "node_modules/@docusaurus/theme-classic/node_modules/@docusaurus/plugin-content-blog": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.5.2.tgz", + "integrity": "sha512-R7ghWnMvjSf+aeNDH0K4fjyQnt5L0KzUEnUhmf1e3jZrv3wogeytZNN6n7X8yHcMsuZHPOrctQhXWnmxu+IRRg==", "license": "MIT", "dependencies": { - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/module-type-aliases": "3.4.0", - "@docusaurus/plugin-content-blog": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/plugin-content-pages": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "cheerio": "1.0.0-rc.12", + "feed": "^4.2.2", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "reading-time": "^1.5.0", + "srcset": "^4.0.0", + "tslib": "^2.6.0", + "unist-util-visit": "^5.0.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/theme-classic/node_modules/@docusaurus/plugin-content-pages": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.5.2.tgz", + "integrity": "sha512-WzhHjNpoQAUz/ueO10cnundRz+VUtkjFhhaQ9jApyv1a46FPURO4cef89pyNIOMny1fjDz/NUN2z6Yi+5WUrCw==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/theme-common": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.5.2.tgz", + "integrity": "sha512-QXqlm9S6x9Ibwjs7I2yEDgsCocp708DrCrgHgKwg2n2AY0YQ6IjU0gAK35lHRLOvAoJUfCKpQAwUykB0R7+Eew==", + "license": "MIT", + "dependencies": { + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/module-type-aliases": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -2609,24 +2667,25 @@ "node": ">=18.0" }, "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.4.0.tgz", - "integrity": "sha512-aiHFx7OCw4Wck1z6IoShVdUWIjntC8FHCw9c5dR8r3q4Ynh+zkS8y2eFFunN/DL6RXPzpnvKCg3vhLQYJDmT9Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.5.2.tgz", + "integrity": "sha512-qW53kp3VzMnEqZGjakaV90sst3iN1o32PH+nawv1uepROO8aEGxptcq2R5rsv7aBShSRbZwIobdvSYKsZ5pqvA==", "license": "MIT", "dependencies": { "@docsearch/react": "^3.5.2", - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/theme-common": "3.4.0", - "@docusaurus/theme-translations": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/plugin-content-docs": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/theme-translations": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "algoliasearch": "^4.18.0", "algoliasearch-helper": "^3.13.3", "clsx": "^2.0.0", @@ -2645,9 +2704,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.4.0.tgz", - "integrity": "sha512-zSxCSpmQCCdQU5Q4CnX/ID8CSUUI3fvmq4hU/GNP/XoAWtXo9SAVnM3TzpU8Gb//H3WCsT8mJcTfyOk3d9ftNg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.5.2.tgz", + "integrity": "sha512-GPZLcu4aT1EmqSTmbdpVrDENGR2yObFEX8ssEFYTCiAIVc0EihNSdOIBTazUvgNqwvnoU1A8vIs1xyzc3LITTw==", "license": "MIT", "dependencies": { "fs-extra": "^11.1.1", @@ -2658,9 +2717,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.4.0.tgz", - "integrity": "sha512-4jcDO8kXi5Cf9TcyikB/yKmz14f2RZ2qTRerbHAsS+5InE9ZgSLBNLsewtFTcTOXSVcbU3FoGOzcNWAmU1TR0A==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.5.2.tgz", + "integrity": "sha512-N6GntLXoLVUwkZw7zCxwy9QiuEXIcTVzA9AkmNw16oc0AP3SXLrMmDMMBIfgqwuKWa6Ox6epHol9kMtJqekACw==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", @@ -2679,13 +2738,13 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.4.0.tgz", - "integrity": "sha512-fRwnu3L3nnWaXOgs88BVBmG1yGjcQqZNHG+vInhEa2Sz2oQB+ZjbEMO5Rh9ePFpZ0YDiDUhpaVjwmS+AU2F14g==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.5.2.tgz", + "integrity": "sha512-33QvcNFh+Gv+C2dP9Y9xWEzMgf3JzrpL2nW9PopidiohS1nDcyknKRx2DWaFvyVTTYIkkABVSr073VTj/NITNA==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.4.0", - "@docusaurus/utils-common": "3.4.0", + "@docusaurus/logger": "3.5.2", + "@docusaurus/utils-common": "3.5.2", "@svgr/webpack": "^8.1.0", "escape-string-regexp": "^4.0.0", "file-loader": "^6.2.0", @@ -2718,9 +2777,9 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.4.0.tgz", - "integrity": "sha512-NVx54Wr4rCEKsjOH5QEVvxIqVvm+9kh7q8aYTU5WzUU9/Hctd6aTrcZ3G0Id4zYJ+AeaG5K5qHA4CY5Kcm2iyQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.5.2.tgz", + "integrity": "sha512-i0AZjHiRgJU6d7faQngIhuHKNrszpL/SHQPgF1zH4H+Ij6E9NBYGy6pkcGWToIv7IVPbs+pQLh1P3whn0gWXVg==", "license": "MIT", "dependencies": { "tslib": "^2.6.0" @@ -2738,14 +2797,14 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.4.0.tgz", - "integrity": "sha512-hYQ9fM+AXYVTWxJOT1EuNaRnrR2WGpRdLDQG07O8UOpsvCPWUVOeo26Rbm0JWY2sGLfzAb+tvJ62yF+8F+TV0g==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.5.2.tgz", + "integrity": "sha512-m+Foq7augzXqB6HufdS139PFxDC5d5q2QKZy8q0qYYvGdI6nnlNsGH4cIGsgBnV7smz+mopl3g4asbSDvMV0jA==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", + "@docusaurus/logger": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -8767,9 +8826,10 @@ } }, "node_modules/infima": { - "version": "0.2.0-alpha.43", - "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.43.tgz", - "integrity": "sha512-2uw57LvUqW0rK/SWYnd/2rRfxNA5DDNOh33jxF7fy46VWoNhGxiUQyVZHbBMjQ33mQem0cjdDVwgWVAmlRfgyQ==", + "version": "0.2.0-alpha.44", + "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.44.tgz", + "integrity": "sha512-tuRkUSO/lB3rEhLJk25atwAjgLuzq070+pOW8XcvpHky/YbENnRRdPd85IBkyeTgttmOy5ah+yHYsK1HhUd4lQ==", + "license": "MIT", "engines": { "node": ">=12" } @@ -16020,9 +16080,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.9", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", - "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", + "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", diff --git a/docs/package.json b/docs/package.json index e32fe094996a6..cdcdf53446884 100644 --- a/docs/package.json +++ b/docs/package.json @@ -56,6 +56,6 @@ "node": ">=20" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/docs/src/components/community-guides.tsx b/docs/src/components/community-guides.tsx index 1c1ad7cabdaa8..6982853fade77 100644 --- a/docs/src/components/community-guides.tsx +++ b/docs/src/components/community-guides.tsx @@ -43,6 +43,11 @@ const guides: CommunityGuidesProps[] = [ description: 'Access your local Immich installation over the internet using your own domain', url: 'https://github.com/ppr88/immich-guides/blob/main/open-immich-custom-domain.md', }, + { + title: 'Nginx caching map server', + description: 'Increase privacy by using nginx as a caching proxy in front of a map tile server', + url: 'https://github.com/pcouy/pcouy.github.io/blob/main/_posts/2024-08-30-proxying-a-map-tile-server-for-increased-privacy.md', + }, ]; function CommunityGuide({ title, description, url }: CommunityGuidesProps): JSX.Element { diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index 0fd4cc25c1f01..d8273c67c2179 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -28,11 +28,6 @@ const projects: CommunityProjectProps[] = [ description: 'A simple way to remove orphaned offline assets from the Immich database', url: 'https://github.com/Thoroslives/immich_remove_offline_files', }, - { - title: 'Create albums from folders', - description: 'A Python script to create albums based on the folder structure of an external library.', - url: 'https://github.com/Salvoxia/immich-folder-album-creator', - }, { title: 'Immich-Tools', description: 'Provides scripts for handling problems on the repair page.', @@ -43,6 +38,11 @@ const projects: CommunityProjectProps[] = [ description: 'Lightroom plugin to publish photos from Lightroom collections to Immich albums.', url: 'https://github.com/midzelis/mi.Immich.Publisher', }, + { + title: 'Lightroom Immich Plugin: lrc-immich-plugin', + description: 'Another Lightroom plugin to publish or export photos from Lightroom to Immich.', + url: 'https://github.com/bmachek/lrc-immich-plugin', + }, { title: 'Immich Duplicate Finder', description: 'Webapp that uses machine learning to identify near-duplicate images.', @@ -58,6 +58,11 @@ const projects: CommunityProjectProps[] = [ description: 'Unofficial Immich Android TV app.', url: 'https://github.com/giejay/Immich-Android-TV', }, + { + title: 'Create albums from folders', + description: 'A Python script to create albums based on the folder structure of an external library.', + url: 'https://github.com/Salvoxia/immich-folder-album-creator', + }, { title: 'Powershell Module PSImmich', description: 'Powershell Module for the Immich API', @@ -68,6 +73,16 @@ const projects: CommunityProjectProps[] = [ description: 'Snap package for easy install and zero-care auto updates of Immich. Self-hosted photo management.', url: 'https://immich-distribution.nsg.cc', }, + { + title: 'Immich Kiosk', + description: 'Lightweight slideshow to run on kiosk devices and browsers.', + url: 'https://github.com/damongolding/immich-kiosk', + }, + { + title: 'Immich Power Tools', + description: 'Power tools for organizing your immich library.', + url: 'https://github.com/varun-raj/immich-power-tools', + }, ]; function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element { diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx index dae822f4f7303..b89a65c6e4ae9 100644 --- a/docs/src/components/version-switcher.tsx +++ b/docs/src/components/version-switcher.tsx @@ -1,4 +1,3 @@ -import '@docusaurus/theme-classic/lib/theme/Unlisted/index'; import { useWindowSize } from '@docusaurus/theme-common'; import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem'; import React, { useEffect, useState } from 'react'; diff --git a/docs/src/pages/roadmap.tsx b/docs/src/pages/roadmap.tsx index d8e5032e90e0b..b7c3c8af20b6c 100644 --- a/docs/src/pages/roadmap.tsx +++ b/docs/src/pages/roadmap.tsx @@ -15,6 +15,7 @@ import { mdiCloudUploadOutline, mdiCollage, mdiContentDuplicate, + mdiCrop, mdiDevices, mdiEmailOutline, mdiExpansionCard, @@ -26,6 +27,7 @@ import { mdiFileSearch, mdiFlash, mdiFolder, + mdiFolderMultiple, mdiForum, mdiHandshakeOutline, mdiHeart, @@ -36,6 +38,7 @@ import { mdiImageMultipleOutline, mdiImageSearch, mdiKeyboardSettingsOutline, + mdiLicense, mdiLockOutline, mdiMagnify, mdiMagnifyScan, @@ -55,25 +58,29 @@ import { mdiScaleBalance, mdiSecurity, mdiServer, + mdiShare, mdiShareAll, mdiShareCircle, mdiStar, + mdiStarOutline, mdiTableKey, mdiTag, + mdiTagMultiple, mdiText, mdiThemeLightDark, mdiTrashCanOutline, mdiVectorCombine, mdiVideo, mdiWeb, - mdiLicense, } from '@mdi/js'; import Layout from '@theme/Layout'; import React from 'react'; import { Item, Timeline } from '../components/timeline'; const releases = { - // TODO + 'v1.113.0': new Date(2024, 7, 30), + 'v1.112.0': new Date(2024, 7, 14), + 'v1.111.0': new Date(2024, 6, 26), 'v1.110.0': new Date(2024, 5, 11), 'v1.109.0': new Date(2024, 6, 18), 'v1.106.1': new Date(2024, 5, 11), @@ -224,6 +231,47 @@ const roadmap: Item[] = [ ]; const milestones: Item[] = [ + withRelease({ + icon: mdiTagMultiple, + iconColor: 'orange', + title: 'Tags', + description: 'Tag your photos and videos', + release: 'v1.113.0', + }), + withRelease({ + icon: mdiFolderMultiple, + iconColor: 'brown', + title: 'Folders', + description: 'View your photos and videos in folders', + release: 'v1.113.0', + }), + withRelease({ + icon: mdiPalette, + title: 'Theming (mobile)', + description: 'Pick a primary color for the mobile app', + release: 'v1.112.0', + }), + withRelease({ + icon: mdiStarOutline, + iconColor: 'gold', + title: 'Star rating', + description: 'Rate your photos and videos', + release: 'v1.112.0', + }), + withRelease({ + icon: mdiCrop, + iconColor: 'royalblue', + title: 'Editor (mobile)', + description: 'Crop and rotate on mobile', + release: 'v1.111.0', + }), + withRelease({ + icon: mdiMap, + iconColor: 'green', + title: 'Deploy tiles.immich.cloud', + description: 'Dedicated tile server for Immich', + release: 'v1.111.0', + }), { icon: mdiStar, iconColor: 'gold', @@ -231,6 +279,12 @@ const milestones: Item[] = [ description: 'Reached 40K Stars on GitHub!', getDateLabel: withLanguage(new Date(2024, 6, 21)), }, + withRelease({ + icon: mdiShare, + title: 'Deploy my.immich.app', + description: 'Url router for immich links', + release: 'v1.109.0', + }), withRelease({ icon: mdiLicense, iconColor: 'gold', diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index c2bce22893622..1dc2cd3a1f656 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.113.0", + "url": "https://v1.113.0.archive.immich.app" + }, { "label": "v1.112.1", "url": "https://v1.112.1.archive.immich.app" diff --git a/docs/tailwind.config.js b/docs/tailwind.config.js index d3ed1f3cda916..1ef26facbb621 100644 --- a/docs/tailwind.config.js +++ b/docs/tailwind.config.js @@ -4,7 +4,7 @@ module.exports = { corePlugins: { preflight: false, // disable Tailwind's reset }, - content: ['./src/**/*.{js,jsx,ts,tsx}', '../docs/**/*.mdx'], // my markdown stuff is in ../docs, not /src + content: ['./src/**/*.{js,jsx,ts,tsx}', './{docs,blog}/**/*.{md,mdx}'], // my markdown stuff is in ../docs, not /src darkMode: ['class', '[data-theme="dark"]'], // hooks into docusaurus' dark mode settigns theme: { extend: { diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 8ce7030825b5e..3516580bbbc04 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index b45ea4137f25a..cbeca0deca296 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -30,7 +30,7 @@ services: - redis - database ports: - - 2283:3001 + - 2285:3001 redis: image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e @@ -43,7 +43,7 @@ services: POSTGRES_USER: postgres POSTGRES_DB: immich ports: - - 5433:5432 + - 5435:5432 volumes: model-cache: diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs index 9a1bb9959851a..fd1e8a0af693d 100644 --- a/e2e/eslint.config.mjs +++ b/e2e/eslint.config.mjs @@ -59,6 +59,7 @@ export default [ 'unicorn/prefer-top-level-await': 'off', 'unicorn/prefer-event-target': 'off', 'unicorn/no-thenable': 'off', + 'object-shorthand': ['error', 'always'], }, }, ]; diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 855cd34bbaf47..3336c9774000a 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.112.1", + "version": "1.113.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.15", + "version": "2.2.16", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -92,14 +92,14 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "typescript": "^5.3.3" } }, @@ -799,9 +799,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", "dev": true, "license": "MIT", "engines": { @@ -1113,13 +1113,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz", - "integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==", + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.1.tgz", + "integrity": "sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.46.0" + "playwright": "1.46.1" }, "bin": { "playwright": "cli.js" @@ -1516,13 +1516,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.14.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", - "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "version": "20.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", + "integrity": "sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/normalize-package-data": { @@ -1532,10 +1532,11 @@ "dev": true }, "node_modules/@types/oidc-provider": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-8.5.1.tgz", - "integrity": "sha512-NS8tBPOj9GG6SxyrUHWBzglOtAYNDX41J4cRE45oeK0iSqI6V6tDW70aPWg25pJFNSC1evccXFm9evfwjxm7HQ==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-8.5.2.tgz", + "integrity": "sha512-NiD3VG49+cRCAAe8+uZLM4onOcX8y9+cwaml8JG1qlgc98rWoCRgsnOB4Ypx+ysays5jiwzfUgT0nWyXPB/9uQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/koa": "*", "@types/node": "*" @@ -1673,17 +1674,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", + "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/type-utils": "8.2.0", + "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1707,16 +1708,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", + "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4" }, "engines": { @@ -1736,14 +1737,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1754,14 +1755,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", + "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/utils": "8.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1779,9 +1780,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", "dev": true, "license": "MIT", "engines": { @@ -1793,14 +1794,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1848,16 +1849,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1871,13 +1872,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/types": "8.2.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2888,9 +2889,9 @@ } }, "node_modules/eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "license": "MIT", "dependencies": { @@ -2898,7 +2899,7 @@ "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -2937,6 +2938,14 @@ }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -3176,9 +3185,9 @@ } }, "node_modules/exiftool-vendored": { - "version": "28.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.0.tgz", - "integrity": "sha512-s2k92EB8LSeYjXv4agtpANeH8y1CsEThYqMm7AF1jP64PyFb40AoD0RGf69j28G6RqXkT5JGl4Xwk9kOy3IkjQ==", + "version": "28.2.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.1.tgz", + "integrity": "sha512-D3YsKErr3BbjKeJzUVsv6CVZ+SQNgAJKPFWVLXu0CBtr24FNuE3CZBXWKWysGu0MjzeDCNwQrQI5+bXUFeiYVA==", "dev": true, "license": "MIT", "dependencies": { @@ -3186,7 +3195,7 @@ "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", "he": "^1.2.0", - "luxon": "^3.4.4" + "luxon": "^3.5.0" }, "optionalDependencies": { "exiftool-vendored.exe": "12.91.0", @@ -4125,10 +4134,11 @@ } }, "node_modules/jose": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.6.3.tgz", - "integrity": "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.7.0.tgz", + "integrity": "sha512-3P9qfTYDVnNn642LCAqIKbTGb9a1TBxZ9ti5zEVEr48aDdflgRjhspWFb6WM4PzAfFbGMJYC4+803v8riCRAKw==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -4436,9 +4446,9 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -5178,13 +5188,13 @@ } }, "node_modules/playwright": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz", - "integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==", + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.1.tgz", + "integrity": "sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.46.0" + "playwright-core": "1.46.1" }, "bin": { "playwright": "cli.js" @@ -5197,9 +5207,9 @@ } }, "node_modules/playwright-core": { - "version": "1.46.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz", - "integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==", + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.1.tgz", + "integrity": "sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6339,10 +6349,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/e2e/package.json b/e2e/package.json index bf393e071ace6..063b01cff1258 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.112.1", + "version": "1.113.0", "description": "", "main": "index.js", "type": "module", @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -53,6 +53,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 65a9c78823f85..55032bd364bee 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ workers: 1, reporter: 'html', use: { - baseURL: 'http://127.0.0.1:2283', + baseURL: 'http://127.0.0.1:2285', trace: 'on-first-retry', }, @@ -54,7 +54,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { command: 'docker compose up --build -V --remove-orphans', - url: 'http://127.0.0.1:2283', + url: 'http://127.0.0.1:2285', reuseExistingServer: true, }, }); diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 2a35eb3c92d03..9e925c40210c1 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -344,16 +344,16 @@ describe('/albums', () => { }); }); - describe('GET /albums/count', () => { + describe('GET /albums/statistics', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/albums/count'); + const { status, body } = await request(app).get('/albums/statistics'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should return total count of albums the user has access to', async () => { const { status, body } = await request(app) - .get('/albums/count') + .get('/albums/statistics') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 4ee035ee956fe..82ce17865a565 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -7,7 +7,6 @@ import { SharedLinkType, getAssetInfo, getMyUser, - updateAssets, } from '@immich/sdk'; import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; @@ -67,11 +66,9 @@ describe('/asset', () => { let timeBucketUser: LoginResponseDto; let quotaUser: LoginResponseDto; let statsUser: LoginResponseDto; - let stackUser: LoginResponseDto; let user1Assets: AssetMediaResponseDto[]; let user2Assets: AssetMediaResponseDto[]; - let stackAssets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; let ratingAsset: AssetMediaResponseDto; @@ -79,14 +76,13 @@ describe('/asset', () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - [websocket, user1, user2, statsUser, quotaUser, timeBucketUser, stackUser] = await Promise.all([ + [websocket, user1, user2, statsUser, quotaUser, timeBucketUser] = await Promise.all([ utils.connectWebsocket(admin.accessToken), utils.userSetup(admin.accessToken, createUserDto.create('1')), utils.userSetup(admin.accessToken, createUserDto.create('2')), utils.userSetup(admin.accessToken, createUserDto.create('stats')), utils.userSetup(admin.accessToken, createUserDto.userQuota), utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), - utils.userSetup(admin.accessToken, createUserDto.create('stack')), ]); await utils.createPartner(user1.accessToken, user2.userId); @@ -149,20 +145,6 @@ describe('/asset', () => { }), ]); - // stacks - stackAssets = await Promise.all([ - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - ]); - - await updateAssets( - { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, - { headers: asBearerAuth(stackUser.accessToken) }, - ); - const person1 = await utils.createPerson(user1.accessToken, { name: 'Test Person', }); @@ -381,6 +363,8 @@ describe('/asset', () => { utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); + + await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration'); }); it('should require authentication', async () => { @@ -417,17 +401,14 @@ describe('/asset', () => { } }); - it.each(TEN_TIMES)( - 'should return 1 asset if there are 10 assets in the database but user 2 only has 1', - async () => { - const { status, body } = await request(app) - .get('/assets/random') - .set('Authorization', `Bearer ${user2.accessToken}`); + it.skip('should return 1 asset if there are 10 assets in the database but user 2 only has 1', async () => { + const { status, body } = await request(app) + .get('/assets/random') + .set('Authorization', `Bearer ${user2.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]); - }, - ); + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]); + }); it('should return error', async () => { const { status } = await request(app) @@ -826,145 +807,8 @@ describe('/asset', () => { expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); - - it('should require a valid parent id', async () => { - const { status, body } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); - }); - - it('should require access to the parent', async () => { - const { status, body } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should add stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); - }); - - it('should remove stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ removeParent: true, ids: [stackAssets[1].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[2].id }), - expect.objectContaining({ id: stackAssets[3].id }), - ]), - ); - }); - - it('should remove all stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).toBeUndefined(); - }); - - it('should merge stack children', async () => { - // create stack after previous test removed stack children - await updateAssets( - { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, - { headers: asBearerAuth(stackUser.accessToken) }, - ); - - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[0].id }), - expect.objectContaining({ id: stackAssets[1].id }), - expect.objectContaining({ id: stackAssets[2].id }), - ]), - ); - }); }); - describe('PUT /assets/stack/parent', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put('/assets/stack/parent'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require a valid id', async () => { - const { status, body } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); - }); - - it('should require access', async () => { - const { status, body } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should make old parent child of new parent', async () => { - const { status } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); - - expect(status).toBe(200); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - - // new parent - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[1].id }), - expect.objectContaining({ id: stackAssets[2].id }), - expect.objectContaining({ id: stackAssets[3].id }), - ]), - ); - }); - }); describe('POST /assets', () => { beforeAll(setupTests, 30_000); @@ -999,7 +843,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '8bit-sRGB.avif', - resized: true, exifInfo: { description: '', exifImageHeight: 1080, @@ -1015,7 +858,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'el_torcal_rocks.jpg', - resized: true, exifInfo: { dateTimeOriginal: '2012-08-05T11:39:59.000Z', exifImageWidth: 512, @@ -1039,7 +881,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '8bit-sRGB.jxl', - resized: true, exifInfo: { description: '', exifImageHeight: 1080, @@ -1055,7 +896,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'IMG_2682.heic', - resized: true, fileCreatedAt: '2019-03-21T16:04:22.348Z', exifInfo: { dateTimeOriginal: '2019-03-21T16:04:22.348Z', @@ -1080,7 +920,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'density_plot.png', - resized: true, exifInfo: { exifImageWidth: 800, exifImageHeight: 800, @@ -1095,7 +934,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'glarus.nef', - resized: true, fileCreatedAt: '2010-07-20T17:27:12.000Z', exifInfo: { make: 'NIKON CORPORATION', @@ -1117,7 +955,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'philadelphia.nef', - resized: true, fileCreatedAt: '2016-09-22T22:10:29.060Z', exifInfo: { make: 'NIKON CORPORATION', @@ -1140,7 +977,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '4_3.rw2', - resized: true, fileCreatedAt: '2018-05-10T08:42:37.842Z', exifInfo: { make: 'Panasonic', @@ -1164,7 +1000,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '12bit-compressed-(3_2).arw', - resized: true, fileCreatedAt: '2016-09-27T10:51:44.000Z', exifInfo: { make: 'SONY', @@ -1189,7 +1024,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '14bit-uncompressed-(3_2).arw', - resized: true, fileCreatedAt: '2016-01-08T14:08:01.000Z', exifInfo: { make: 'SONY', diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 59968f3b7942b..2f6274d1fca4d 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -353,7 +353,7 @@ describe('/libraries', () => { expect(assets.count).toBe(2); - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); await scan(admin.accessToken, library.id); await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 }); @@ -361,11 +361,11 @@ describe('/libraries', () => { const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); expect(newAssets.count).toBe(3); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); }); - it('should offline missing files', async () => { - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + it('should offline a file missing from disk', async () => { + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -374,7 +374,40 @@ describe('/libraries', () => { await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(3); + + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(newAssets.count).toBe(3); + + expect(newAssets.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + isOffline: true, + originalFileName: 'assetC.png', + }), + ]), + ); + }); + + it('should offline a file outside of import paths', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp`], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + await request(app) + .put(`/libraries/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ importPaths: [`${testAssetDirInternal}/temp/directoryA`] }); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); @@ -383,6 +416,45 @@ describe('/libraries', () => { expect(assets.items).toEqual( expect.arrayContaining([ + expect.objectContaining({ + isOffline: false, + originalFileName: 'assetA.png', + }), + expect.objectContaining({ + isOffline: true, + originalFileName: 'assetB.png', + }), + ]), + ); + }); + + it('should offline a file covered by an exclusion pattern', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp`], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + await request(app) + .put(`/libraries/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ exclusionPatterns: ['**/directoryB/**'] }); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(assets.count).toBe(2); + + expect(assets.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + isOffline: false, + originalFileName: 'assetA.png', + }), expect.objectContaining({ isOffline: true, originalFileName: 'assetB.png', @@ -434,6 +506,8 @@ describe('/libraries', () => { await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 }); expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true); + + utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); }); it('should scan new files', async () => { @@ -445,14 +519,14 @@ describe('/libraries', () => { await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + utils.createImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(3); expect(assets.items).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -460,6 +534,8 @@ describe('/libraries', () => { }), ]), ); + + utils.removeImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); }); describe('with refreshModifiedFiles=true', () => { @@ -559,10 +635,11 @@ describe('/libraries', () => { it('should remove offline files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline2`], + importPaths: [`${testAssetDirInternal}/temp/offline`], }); - utils.createImageFile(`${testAssetDir}/temp/offline2/assetA.png`); + utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); @@ -570,9 +647,9 @@ describe('/libraries', () => { const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id, }); - expect(initialAssets.count).toBe(1); + expect(initialAssets.count).toBe(2); - utils.removeImageFile(`${testAssetDir}/temp/offline2/assetA.png`); + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); @@ -593,7 +670,54 @@ describe('/libraries', () => { const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(0); + expect(assets.count).toBe(1); + + utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); + }); + + it('should remove offline files from trash', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + }); + + expect(initialAssets.count).toBe(2); + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + isOffline: true, + }); + expect(offlineAssets.count).toBe(1); + + const { status } = await request(app) + .post(`/libraries/${library.id}/removeOffline`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(assets.count).toBe(1); + expect(assets.items[0].isOffline).toBe(false); + expect(assets.items[0].originalPath).toEqual(`${testAssetDirInternal}/temp/offline/online.png`); + + utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); }); it('should not remove online files', async () => { diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts index 8ca17eba813d5..a37a9528c9a7b 100644 --- a/e2e/src/api/specs/oauth.e2e-spec.ts +++ b/e2e/src/api/specs/oauth.e2e-spec.ts @@ -92,14 +92,14 @@ describe(`/oauth`, () => { it('should return a redirect uri', async () => { const { status, body } = await request(app) .post('/oauth/authorize') - .send({ redirectUri: 'http://127.0.0.1:2283/auth/login' }); + .send({ redirectUri: 'http://127.0.0.1:2285/auth/login' }); expect(status).toBe(201); expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) }); const params = new URL(body.url).searchParams; expect(params.get('client_id')).toBe('client-default'); expect(params.get('response_type')).toBe('code'); - expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2283/auth/login'); + expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2285/auth/login'); expect(params.get('state')).toBeDefined(); }); }); diff --git a/e2e/src/api/specs/stack.e2e-spec.ts b/e2e/src/api/specs/stack.e2e-spec.ts new file mode 100644 index 0000000000000..bf34369ee3721 --- /dev/null +++ b/e2e/src/api/specs/stack.e2e-spec.ts @@ -0,0 +1,211 @@ +import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/stacks', () => { + let admin: LoginResponseDto; + let user1: LoginResponseDto; + let user2: LoginResponseDto; + let asset: AssetMediaResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + + [user1, user2] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.user1), + utils.userSetup(admin.accessToken, createUserDto.user2), + ]); + + asset = await utils.createAsset(user1.accessToken); + }); + + describe('POST /stacks', () => { + it('should require authentication', async () => { + const { status, body } = await request(app) + .post('/stacks') + .send({ assetIds: [asset.id] }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require at least two assets', async () => { + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [uuidDto.invalid, uuidDto.invalid] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require access', async () => { + const user2Asset = await utils.createAsset(user2.accessToken); + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset.id, user2Asset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should create a stack', async () => { + const [asset1, asset2] = await Promise.all([ + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + ]); + + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset2.id] }); + + expect(status).toBe(201); + expect(body).toEqual({ + id: expect.any(String), + primaryAssetId: asset1.id, + assets: [expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })], + }); + }); + + it('should merge an existing stack', async () => { + const [asset1, asset2, asset3] = await Promise.all([ + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + ]); + + const response1 = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset2.id] }); + + expect(response1.status).toBe(201); + + const stacksBefore = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) }); + + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset3.id] }); + + expect(status).toBe(201); + expect(body).toEqual({ + id: expect.any(String), + primaryAssetId: asset1.id, + assets: expect.arrayContaining([ + expect.objectContaining({ id: asset1.id }), + expect.objectContaining({ id: asset2.id }), + expect.objectContaining({ id: asset3.id }), + ]), + }); + + const stacksAfter = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) }); + expect(stacksAfter.length).toBe(stacksBefore.length); + }); + + // it('should require a valid parent id', async () => { + // const { status, body } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); + // }); + }); + + // it('should require access to the parent', async () => { + // const { status, body } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.noPermission); + // }); + + // it('should add stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); + // }); + + // it('should remove stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ removeParent: true, ids: [stackAssets[1].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual( + // expect.arrayContaining([ + // expect.objectContaining({ id: stackAssets[2].id }), + // expect.objectContaining({ id: stackAssets[3].id }), + // ]), + // ); + // }); + + // it('should remove all stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).toBeUndefined(); + // }); + + // it('should merge stack children', async () => { + // // create stack after previous test removed stack children + // await updateAssets( + // { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, + // { headers: asBearerAuth(stackUser.accessToken) }, + // ); + + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual( + // expect.arrayContaining([ + // expect.objectContaining({ id: stackAssets[0].id }), + // expect.objectContaining({ id: stackAssets[1].id }), + // expect.objectContaining({ id: stackAssets[2].id }), + // ]), + // ); + // }); +}); diff --git a/e2e/src/api/specs/tag.e2e-spec.ts b/e2e/src/api/specs/tag.e2e-spec.ts new file mode 100644 index 0000000000000..a4cbc99ed3bc7 --- /dev/null +++ b/e2e/src/api/specs/tag.e2e-spec.ts @@ -0,0 +1,603 @@ +import { + AssetMediaResponseDto, + LoginResponseDto, + Permission, + TagCreateDto, + TagResponseDto, + createTag, + getAllTags, + tagAssets, + upsertTags, +} from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const create = (accessToken: string, dto: TagCreateDto) => + createTag({ tagCreateDto: dto }, { headers: asBearerAuth(accessToken) }); + +const upsert = (accessToken: string, tags: string[]) => + upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }); + +describe('/tags', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + let userAsset: AssetMediaResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + user = await utils.userSetup(admin.accessToken, createUserDto.user1); + userAsset = await utils.createAsset(user.accessToken); + }); + + beforeEach(async () => { + // tagging assets eventually triggers metadata extraction which can impact other tests + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.resetDatabase(['tags']); + }); + + describe('POST /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/tags').send({ name: 'TagA' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.create')); + }); + + it('should work with tag.create', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]); + const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + + it('should create a tag', async () => { + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + + it('should allow multiple users to create tags with the same value', async () => { + await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + + it('should create a nested tag', async () => { + const parent = await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ name: 'TagB', parentId: parent.id }); + expect(body).toEqual({ + id: expect.any(String), + parentId: parent.id, + name: 'TagB', + value: 'TagA/TagB', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + }); + + describe('GET /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/tags'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).get('/tags').set('x-api-key', secret); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.read')); + }); + + it('should start off empty', async () => { + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([]); + expect(status).toEqual(200); + }); + + it('should return a list of tags', async () => { + const [tagA, tagB, tagC] = await Promise.all([ + create(admin.accessToken, { name: 'TagA' }), + create(admin.accessToken, { name: 'TagB' }), + create(admin.accessToken, { name: 'TagC' }), + ]); + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toHaveLength(3); + expect(body).toEqual([tagA, tagB, tagC]); + expect(status).toEqual(200); + }); + + it('should return a nested tags', async () => { + await upsert(admin.accessToken, ['TagA/TagB/TagC', 'TagD']); + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + + expect(body).toHaveLength(4); + expect(status).toEqual(200); + + const tags = body as TagResponseDto[]; + const tagA = tags.find((tag) => tag.value === 'TagA') as TagResponseDto; + const tagB = tags.find((tag) => tag.value === 'TagA/TagB') as TagResponseDto; + const tagC = tags.find((tag) => tag.value === 'TagA/TagB/TagC') as TagResponseDto; + const tagD = tags.find((tag) => tag.value === 'TagD') as TagResponseDto; + + expect(tagA).toEqual(expect.objectContaining({ name: 'TagA', value: 'TagA' })); + expect(tagB).toEqual(expect.objectContaining({ name: 'TagB', value: 'TagA/TagB', parentId: tagA.id })); + expect(tagC).toEqual(expect.objectContaining({ name: 'TagC', value: 'TagA/TagB/TagC', parentId: tagB.id })); + expect(tagD).toEqual(expect.objectContaining({ name: 'TagD', value: 'TagD' })); + }); + }); + + describe('PUT /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/tags`).send({ name: 'TagA/TagB' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.create')); + }); + + it('should upsert tags', async () => { + const { status, body } = await request(app) + .put(`/tags`) + .send({ tags: ['TagA/TagB/TagC/TagD'] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ name: 'TagD', value: 'TagA/TagB/TagC/TagD' })]); + }); + + it('should upsert tags in parallel without conflicts', async () => { + const [[tag1], [tag2], [tag3], [tag4]] = await Promise.all([ + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + ]); + + const { id, parentId, createdAt } = tag1; + for (const tag of [tag1, tag2, tag3, tag4]) { + expect(tag).toMatchObject({ + id, + parentId, + createdAt, + name: 'TagD', + value: 'TagA/TagB/TagC/TagD', + }); + } + }); + }); + + describe('PUT /tags/assets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/tags/assets`).send({ tagIds: [], assetIds: [] }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put('/tags/assets') + .set('x-api-key', secret) + .send({ assetIds: [], tagIds: [] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should skip assets that are not owned by the user', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(user.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(admin.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 3 }); + }); + + it('should skip tags that are not owned by the user', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(admin.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 4 }); + }); + + it('should bulk tag assets', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(user.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 6 }); + }); + }); + + describe('GET /tags/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/tags/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .get(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .get(`/tags/${uuidDto.notFound}`) + .set('x-api-key', secret) + .send({ assetIds: [], tagIds: [] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.read')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .get(`/tags/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should get tag details', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .get(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + + it('should get nested tag details', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const tagC = await create(user.accessToken, { name: 'TagC', parentId: tagB.id }); + const tagD = await create(user.accessToken, { name: 'TagD', parentId: tagC.id }); + + const { status, body } = await request(app) + .get(`/tags/${tagD.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + parentId: tagC.id, + name: 'TagD', + value: 'TagA/TagB/TagC/TagD', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + }); + + describe('PUT /tags/:id', () => { + it('should require authentication', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app).put(`/tags/${tag.id}`).send({ color: '#000000' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(admin.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '#000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .set('x-api-key', secret) + .send({ color: '#000000' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.update')); + }); + + it('should update a tag', async () => { + const tag = await create(user.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '#000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ color: `#000000` })); + }); + + it('should update a tag color without a # prefix', async () => { + const tag = await create(user.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ color: `#000000` })); + }); + }); + + describe('DELETE /tags/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/tags/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .delete(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.delete')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .delete(`/tags/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should delete a tag', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status } = await request(app) + .delete(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + }); + + it('should delete a nested tag (root)', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const { status } = await request(app) + .delete(`/tags/${tagA.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) }); + expect(tags.length).toBe(0); + }); + + it('should delete a nested tag (leaf)', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const { status } = await request(app) + .delete(`/tags/${tagB.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) }); + expect(tags.length).toBe(1); + expect(tags[0]).toEqual(tagA); + }); + }); + + describe('PUT /tags/:id/assets', () => { + it('should require authentication', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .send({ ids: [userAsset.id] }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}/assets`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [userAsset.id] }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put(`/tags/${tag.id}/assets`) + .set('x-api-key', secret) + .send({ ids: [userAsset.id] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should be able to tag own asset', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]); + }); + + it("should not be able to add assets to another user's tag", async () => { + const tagA = await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no tag.asset access')); + }); + + it('should add duplicate assets only once', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id, userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: userAsset.id, success: true }), + expect.objectContaining({ id: userAsset.id, success: false, error: 'duplicate' }), + ]); + }); + }); + + describe('DELETE /tags/:id/assets', () => { + it('should require authentication', async () => { + const tagA = await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .delete(`/tags/${tagA}/assets`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .delete(`/tags/${tag.id}/assets`) + .set('x-api-key', secret) + .send({ ids: [userAsset.id] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should be able to remove own asset from own tag', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]); + }); + + it('should remove duplicate assets only once', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id, userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: userAsset.id, success: true }), + expect.objectContaining({ id: userAsset.id, success: false, error: 'not_found' }), + ]); + }); + }); +}); diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index b7147f52cc734..8a417387e7da1 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -1,11 +1,11 @@ import { LoginResponseDto, + createStack, deleteUserAdmin, getMyUser, getUserAdmin, getUserPreferencesAdmin, login, - updateAssets, } from '@immich/sdk'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; @@ -321,8 +321,8 @@ describe('/admin/users', () => { utils.createAsset(user.accessToken), ]); - await updateAssets( - { assetBulkUpdateDto: { stackParentId: asset1.id, ids: [asset2.id] } }, + await createStack( + { stackCreateDto: { assetIds: [asset1.id, asset2.id] } }, { headers: asBearerAuth(user.accessToken) }, ); diff --git a/e2e/src/cli/specs/login.e2e-spec.ts b/e2e/src/cli/specs/login.e2e-spec.ts index fc3e8175957c0..3bc3ebc9c29b8 100644 --- a/e2e/src/cli/specs/login.e2e-spec.ts +++ b/e2e/src/cli/specs/login.e2e-spec.ts @@ -33,7 +33,7 @@ describe(`immich login`, () => { const key = await utils.createApiKey(admin.accessToken, [Permission.All]); const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]); expect(stdout.split('\n')).toEqual([ - 'Logging in to http://127.0.0.1:2283/api', + 'Logging in to http://127.0.0.1:2285/api', 'Logged in as admin@immich.cloud', 'Wrote auth info to /tmp/immich/auth.yml', ]); @@ -50,8 +50,8 @@ describe(`immich login`, () => { const key = await utils.createApiKey(admin.accessToken, [Permission.All]); const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]); expect(stdout.split('\n')).toEqual([ - 'Logging in to http://127.0.0.1:2283', - 'Discovered API at http://127.0.0.1:2283/api', + 'Logging in to http://127.0.0.1:2285', + 'Discovered API at http://127.0.0.1:2285/api', 'Logged in as admin@immich.cloud', 'Wrote auth info to /tmp/immich/auth.yml', ]); diff --git a/e2e/src/cli/specs/server-info.e2e-spec.ts b/e2e/src/cli/specs/server-info.e2e-spec.ts index 13eefd3df4676..96c45c8cc0111 100644 --- a/e2e/src/cli/specs/server-info.e2e-spec.ts +++ b/e2e/src/cli/specs/server-info.e2e-spec.ts @@ -12,7 +12,7 @@ describe(`immich server-info`, () => { const { stderr, stdout, exitCode } = await immichCli(['server-info']); expect(stdout.split('\n')).toEqual([ expect.stringContaining('Server Info (via admin@immich.cloud'), - ' Url: http://127.0.0.1:2283/api', + ' Url: http://127.0.0.1:2285/api', expect.stringContaining('Version:'), ' Formats:', expect.stringContaining('Images:'), diff --git a/e2e/src/setup/auth-server.ts b/e2e/src/setup/auth-server.ts index a8c49050be129..3dd63fc403426 100644 --- a/e2e/src/setup/auth-server.ts +++ b/e2e/src/setup/auth-server.ts @@ -86,14 +86,14 @@ const setup = async () => { { client_id: OAuthClient.DEFAULT, client_secret: OAuthClient.DEFAULT, - redirect_uris: ['http://127.0.0.1:2283/auth/login'], + redirect_uris: ['http://127.0.0.1:2285/auth/login'], grant_types: ['authorization_code'], response_types: ['code'], }, { client_id: OAuthClient.RS256_TOKENS, client_secret: OAuthClient.RS256_TOKENS, - redirect_uris: ['http://127.0.0.1:2283/auth/login'], + redirect_uris: ['http://127.0.0.1:2285/auth/login'], grant_types: ['authorization_code'], id_token_signed_response_alg: 'RS256', jwks: { keys: [await exportJWK(publicKey)] }, @@ -101,7 +101,7 @@ const setup = async () => { { client_id: OAuthClient.RS256_PROFILE, client_secret: OAuthClient.RS256_PROFILE, - redirect_uris: ['http://127.0.0.1:2283/auth/login'], + redirect_uris: ['http://127.0.0.1:2285/auth/login'], grant_types: ['authorization_code'], userinfo_signed_response_alg: 'RS256', jwks: { keys: [await exportJWK(publicKey)] }, diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 30e2497b514d1..e2f05d5261759 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -53,8 +53,8 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu type AdminSetupOptions = { onboarding?: boolean }; type FileData = { bytes?: Buffer; filename: string }; -const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5433/immich'; -export const baseUrl = 'http://127.0.0.1:2283'; +const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich'; +export const baseUrl = 'http://127.0.0.1:2285'; export const shareUrl = `${baseUrl}/share`; export const app = `${baseUrl}/api`; // TODO move test assets into e2e/assets @@ -148,6 +148,7 @@ export const utils = { 'sessions', 'users', 'system_metadata', + 'tags', ]; const sql: string[] = []; diff --git a/e2e/src/web/specs/album.e2e-spec.ts b/e2e/src/web/specs/album.e2e-spec.ts new file mode 100644 index 0000000000000..953c7d00ae1cd --- /dev/null +++ b/e2e/src/web/specs/album.e2e-spec.ts @@ -0,0 +1,25 @@ +import { LoginResponseDto } from '@immich/sdk'; +import { test } from '@playwright/test'; +import { utils } from 'src/utils'; + +test.describe('Album', () => { + let admin: LoginResponseDto; + + test.beforeAll(async () => { + utils.initSdk(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + }); + + test(`doesn't delete album after canceling add assets`, async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto('/albums'); + await page.getByRole('button', { name: 'Create album' }).click(); + await page.getByRole('button', { name: 'Select photos' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + await page.reload(); + await page.getByRole('button', { name: 'Select photos' }).waitFor(); + }); +}); diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index bc3f6843ca750..09340e98cbfb3 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -33,7 +33,7 @@ test.describe('Photo Viewer', () => { await page.waitForLoadState('load'); // this is the spinner await page.waitForSelector('svg[role=status]'); - await expect(page.getByRole('status')).toBeVisible(); + await expect(page.getByTestId('loading-spinner')).toBeVisible(); }); test('loads high resolution photo when zoomed', async ({ page }) => { diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index e40c20388b9e6..fe7da0b2c0ead 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -44,7 +44,7 @@ test.describe('Shared Links', () => { test('download from a shared link', async ({ page }) => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); - await page.locator('.group > div').first().hover(); + await page.locator('.group').first().hover(); await page.waitForSelector('#asset-group-by-date svg'); await page.getByRole('checkbox').click(); await page.getByRole('button', { name: 'Download' }).click(); diff --git a/e2e/src/web/specs/websocket.e2e-spec.ts b/e2e/src/web/specs/websocket.e2e-spec.ts index 47f69ec4eaf34..a929c6467f3ad 100644 --- a/e2e/src/web/specs/websocket.e2e-spec.ts +++ b/e2e/src/web/specs/websocket.e2e-spec.ts @@ -13,13 +13,13 @@ test.describe('Websocket', () => { test('connects using ipv4', async ({ page, context }) => { await utils.setAuthCookies(context, admin.accessToken); - await page.goto('http://127.0.0.1:2283/'); + await page.goto('http://127.0.0.1:2285/'); await expect(page.locator('#sidebar')).toContainText('Server Online'); }); test('connects using ipv6', async ({ page, context }) => { await utils.setAuthCookies(context, admin.accessToken, '[::1]'); - await page.goto('http://[::1]:2283/'); + await page.goto('http://[::1]:2285/'); await expect(page.locator('#sidebar')).toContainText('Server Online'); }); }); diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 500b6d3e5900e..9c80f25ace140 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'; // skip `docker compose up` if `make e2e` was already run const globalSetup: string[] = ['src/setup/auth-server.ts']; try { - await fetch('http://127.0.0.1:2283/api/server-info/ping'); + await fetch('http://127.0.0.1:2285/api/server-info/ping'); } catch { globalSetup.push('src/setup/docker-compose.ts'); } diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index c06b4900e699d..8fc72b308f08a 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:add76c758e402c3acf53b8251da50d8ae67989a81ca96ff4331e296773df853d AS builder-cpu +FROM python:3.11-bookworm@sha256:f7543d9969bdc112dd9819ca642e14433fdacfe857f170f6b803392fc7e451ad AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:1c0c54195c7c7b46e61a2f3b906e9b55a8165f20388a0eeb4af4c6f8579988ac AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:ad5dadd957a398226996bc4846e522c39f2a77340b531b28aaab85b2d361210b AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/app/models/clip/textual.py b/machine-learning/app/models/clip/textual.py index 7a25c2f4ad5bf..32c28ea2bb145 100644 --- a/machine-learning/app/models/clip/textual.py +++ b/machine-learning/app/models/clip/textual.py @@ -10,6 +10,7 @@ from tokenizers import Encoding, Tokenizer from app.config import log from app.models.base import InferenceModel +from app.models.transforms import clean_text from app.schemas import ModelSession, ModelTask, ModelType @@ -25,6 +26,8 @@ class BaseCLIPTextualEncoder(InferenceModel): session = super()._load() log.debug(f"Loading tokenizer for CLIP model '{self.model_name}'") self.tokenizer = self._load_tokenizer() + tokenizer_kwargs: dict[str, Any] | None = self.text_cfg.get("tokenizer_kwargs") + self.canonicalize = tokenizer_kwargs is not None and tokenizer_kwargs.get("clean") == "canonicalize" log.debug(f"Loaded tokenizer for CLIP model '{self.model_name}'") return session @@ -56,6 +59,11 @@ class BaseCLIPTextualEncoder(InferenceModel): log.debug(f"Loaded model config for CLIP model '{self.model_name}'") return model_cfg + @property + def text_cfg(self) -> dict[str, Any]: + text_cfg: dict[str, Any] = self.model_cfg["text_cfg"] + return text_cfg + @cached_property def tokenizer_file(self) -> dict[str, Any]: log.debug(f"Loading tokenizer file for CLIP model '{self.model_name}'") @@ -73,8 +81,7 @@ class BaseCLIPTextualEncoder(InferenceModel): class OpenClipTextualEncoder(BaseCLIPTextualEncoder): def _load_tokenizer(self) -> Tokenizer: - text_cfg: dict[str, Any] = self.model_cfg["text_cfg"] - context_length: int = text_cfg.get("context_length", 77) + context_length: int = self.text_cfg.get("context_length", 77) pad_token: str = self.tokenizer_cfg["pad_token"] tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix()) @@ -86,12 +93,14 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder): return tokenizer def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]: + text = clean_text(text, canonicalize=self.canonicalize) tokens: Encoding = self.tokenizer.encode(text) return {"text": np.array([tokens.ids], dtype=np.int32)} class MClipTextualEncoder(OpenClipTextualEncoder): def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]: + text = clean_text(text, canonicalize=self.canonicalize) tokens: Encoding = self.tokenizer.encode(text) return { "input_ids": np.array([tokens.ids], dtype=np.int32), diff --git a/machine-learning/app/models/transforms.py b/machine-learning/app/models/transforms.py index cae9b6b1ab8ea..bb03103d4b069 100644 --- a/machine-learning/app/models/transforms.py +++ b/machine-learning/app/models/transforms.py @@ -1,3 +1,4 @@ +import string from io import BytesIO from typing import IO @@ -7,6 +8,7 @@ from numpy.typing import NDArray from PIL import Image _PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling} +_PUNCTUATION_TRANS = str.maketrans("", "", string.punctuation) def resize_pil(img: Image.Image, size: int) -> Image.Image: @@ -60,3 +62,10 @@ def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[ if isinstance(image_bytes, Image.Image): return pil_to_cv2(image_bytes) return image_bytes + + +def clean_text(text: str, canonicalize: bool = False) -> str: + text = " ".join(text.split()) + if canonicalize: + text = text.translate(_PUNCTUATION_TRANS).lower() + return text diff --git a/machine-learning/app/test_main.py b/machine-learning/app/test_main.py index fb3542e7e4630..17fdb5b1fadd2 100644 --- a/machine-learning/app/test_main.py +++ b/machine-learning/app/test_main.py @@ -379,13 +379,40 @@ class TestCLIP: clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache") clip_encoder._load() - tokens = clip_encoder.tokenize("test search query") + tokens = clip_encoder.tokenize("test search query") assert "text" in tokens assert isinstance(tokens["text"], np.ndarray) assert tokens["text"].shape == (1, 77) assert tokens["text"].dtype == np.int32 assert np.allclose(tokens["text"], np.array([mock_ids], dtype=np.int32), atol=0) + mock_tokenizer.encode.assert_called_once_with("test search query") + + def test_openclip_tokenizer_canonicalizes_text( + self, + mocker: MockerFixture, + clip_model_cfg: dict[str, Any], + clip_tokenizer_cfg: Callable[[Path], dict[str, Any]], + ) -> None: + clip_model_cfg["text_cfg"]["tokenizer_kwargs"] = {"clean": "canonicalize"} + mocker.patch.object(OpenClipTextualEncoder, "download") + mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg) + mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg) + mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value + mock_tokenizer = mocker.patch("app.models.clip.textual.Tokenizer.from_file", autospec=True).return_value + mock_ids = [randint(0, 50000) for _ in range(77)] + mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids) + + clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache") + clip_encoder._load() + tokens = clip_encoder.tokenize("Test Search Query!") + + assert "text" in tokens + assert isinstance(tokens["text"], np.ndarray) + assert tokens["text"].shape == (1, 77) + assert tokens["text"].dtype == np.int32 + assert np.allclose(tokens["text"], np.array([mock_ids], dtype=np.int32), atol=0) + mock_tokenizer.encode.assert_called_once_with("test search query") def test_mclip_tokenizer( self, diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index 94082ae9573d8..d458d92d15014 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:e37ec9f3f7dea01ef9958d3d924d46077911f7e29c4faed40cd6b37a9ac239fc AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:475730daef12ff9c0733e70092aeeefdf4c373a584c952dac3f7bdb739601990 AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 9d19b671d1a04..7385d1269d3f8 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,18 +680,18 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.112.0" +version = "0.112.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.112.0-py3-none-any.whl", hash = "sha256:7663edfbb5036d641aa45b4f5dad341cf78d98885216e78743a8cdd39a38883e"}, - {file = "fastapi_slim-0.112.0.tar.gz", hash = "sha256:2420f700b7dc2d1a6d02c7230f7aa2ae9fa0320d8d481094062ff717659c0843"}, + {file = "fastapi_slim-0.112.1-py3-none-any.whl", hash = "sha256:cc227cf9402d0ba54a24f80eb205c33bcb25d3ea18d53fdac3fd76ea5af8e76d"}, + {file = "fastapi_slim-0.112.1.tar.gz", hash = "sha256:876ebd24e72273986709db2d469b75dc18f04c3ab9140ffd78b29d7785d26687"}, ] [package.dependencies] pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.37.2,<0.38.0" +starlette = ">=0.37.2,<0.39.0" typing-extensions = ">=4.8.0" [package.extras] @@ -1111,13 +1111,13 @@ test = ["objgraph", "psutil"] [[package]] name = "gunicorn" -version = "22.0.0" +version = "23.0.0" description = "WSGI HTTP Server for UNIX" optional = false python-versions = ">=3.7" files = [ - {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, - {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, + {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, + {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, ] [package.dependencies] @@ -1236,13 +1236,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "huggingface-hub" -version = "0.24.5" +version = "0.24.6" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.24.5-py3-none-any.whl", hash = "sha256:d93fb63b1f1a919a22ce91a14518974e81fc4610bf344dfe7572343ce8d3aced"}, - {file = "huggingface_hub-0.24.5.tar.gz", hash = "sha256:7b45d6744dd53ce9cbf9880957de00e9d10a9ae837f1c9b7255fc8fa4e8264f3"}, + {file = "huggingface_hub-0.24.6-py3-none-any.whl", hash = "sha256:a990f3232aa985fe749bc9474060cbad75e8b2f115f6665a9fda5b9c97818970"}, + {file = "huggingface_hub-0.24.6.tar.gz", hash = "sha256:cc2579e761d070713eaa9c323e3debe39d5b464ae3a7261c39a9195b27bb8000"}, ] [package.dependencies] @@ -1530,13 +1530,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.31.2" +version = "2.31.3" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.31.2-py3-none-any.whl", hash = "sha256:9bcb8b777d9844ac9498d6eebe17a0afa21712419c42da27b1d1cac5895cd182"}, - {file = "locust-2.31.2.tar.gz", hash = "sha256:a31f8e1d24535494eb809bd8dfd545ada9514df4581b69bdc2ecf3e109b7a1dd"}, + {file = "locust-2.31.3-py3-none-any.whl", hash = "sha256:03122e007519b371a5a553d578af502826755de83551d79ea8a412ea1c660115"}, + {file = "locust-2.31.3.tar.gz", hash = "sha256:25f4603f24afa11ef1ee1f26b1c86a232eb9a1140be30b2a4642c12d7a7af8ae"}, ] [package.dependencies] @@ -1962,42 +1962,42 @@ reference = ["Pillow", "google-re2"] [[package]] name = "onnxruntime" -version = "1.18.1" +version = "1.19.0" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime-1.18.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:29ef7683312393d4ba04252f1b287d964bd67d5e6048b94d2da3643986c74d80"}, - {file = "onnxruntime-1.18.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc706eb1df06ddf55776e15a30519fb15dda7697f987a2bbda4962845e3cec05"}, - {file = "onnxruntime-1.18.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7de69f5ced2a263531923fa68bbec52a56e793b802fcd81a03487b5e292bc3a"}, - {file = "onnxruntime-1.18.1-cp310-cp310-win32.whl", hash = "sha256:221e5b16173926e6c7de2cd437764492aa12b6811f45abd37024e7cf2ae5d7e3"}, - {file = "onnxruntime-1.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:75211b619275199c861ee94d317243b8a0fcde6032e5a80e1aa9ded8ab4c6060"}, - {file = "onnxruntime-1.18.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:f26582882f2dc581b809cfa41a125ba71ad9e715738ec6402418df356969774a"}, - {file = "onnxruntime-1.18.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef36f3a8b768506d02be349ac303fd95d92813ba3ba70304d40c3cd5c25d6a4c"}, - {file = "onnxruntime-1.18.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:170e711393e0618efa8ed27b59b9de0ee2383bd2a1f93622a97006a5ad48e434"}, - {file = "onnxruntime-1.18.1-cp311-cp311-win32.whl", hash = "sha256:9b6a33419b6949ea34e0dc009bc4470e550155b6da644571ecace4b198b0d88f"}, - {file = "onnxruntime-1.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:5c1380a9f1b7788da742c759b6a02ba771fe1ce620519b2b07309decbd1a2fe1"}, - {file = "onnxruntime-1.18.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:31bd57a55e3f983b598675dfc7e5d6f0877b70ec9864b3cc3c3e1923d0a01919"}, - {file = "onnxruntime-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9e03c4ba9f734500691a4d7d5b381cd71ee2f3ce80a1154ac8f7aed99d1ecaa"}, - {file = "onnxruntime-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:781aa9873640f5df24524f96f6070b8c550c66cb6af35710fd9f92a20b4bfbf6"}, - {file = "onnxruntime-1.18.1-cp312-cp312-win32.whl", hash = "sha256:3a2d9ab6254ca62adbb448222e630dc6883210f718065063518c8f93a32432be"}, - {file = "onnxruntime-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:ad93c560b1c38c27c0275ffd15cd7f45b3ad3fc96653c09ce2931179982ff204"}, - {file = "onnxruntime-1.18.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:3b55dc9d3c67626388958a3eb7ad87eb7c70f75cb0f7ff4908d27b8b42f2475c"}, - {file = "onnxruntime-1.18.1-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f80dbcfb6763cc0177a31168b29b4bd7662545b99a19e211de8c734b657e0669"}, - {file = "onnxruntime-1.18.1-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1ff2c61a16d6c8631796c54139bafea41ee7736077a0fc64ee8ae59432f5c58"}, - {file = "onnxruntime-1.18.1-cp38-cp38-win32.whl", hash = "sha256:219855bd272fe0c667b850bf1a1a5a02499269a70d59c48e6f27f9c8bcb25d02"}, - {file = "onnxruntime-1.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:afdf16aa607eb9a2c60d5ca2d5abf9f448e90c345b6b94c3ed14f4fb7e6a2d07"}, - {file = "onnxruntime-1.18.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:128df253ade673e60cea0955ec9d0e89617443a6d9ce47c2d79eb3f72a3be3de"}, - {file = "onnxruntime-1.18.1-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9839491e77e5c5a175cab3621e184d5a88925ee297ff4c311b68897197f4cde9"}, - {file = "onnxruntime-1.18.1-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad3187c1faff3ac15f7f0e7373ef4788c582cafa655a80fdbb33eaec88976c66"}, - {file = "onnxruntime-1.18.1-cp39-cp39-win32.whl", hash = "sha256:34657c78aa4e0b5145f9188b550ded3af626651b15017bf43d280d7e23dbf195"}, - {file = "onnxruntime-1.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:9c14fd97c3ddfa97da5feef595e2c73f14c2d0ec1d4ecbea99c8d96603c89589"}, + {file = "onnxruntime-1.19.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6ce22a98dfec7b646ae305f52d0ce14a189a758b02ea501860ca719f4b0ae04b"}, + {file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19019c72873f26927aa322c54cf2bf7312b23451b27451f39b88f57016c94f8b"}, + {file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8eaa16df99171dc636e30108d15597aed8c4c2dd9dbfdd07cc464d57d73fb275"}, + {file = "onnxruntime-1.19.0-cp310-cp310-win32.whl", hash = "sha256:0eb0f8dbe596fd0f4737fe511fdbb17603853a7d204c5b2ca38d3c7808fc556b"}, + {file = "onnxruntime-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:616092d54ba8023b7bc0a5f6d900a07a37cc1cfcc631873c15f8c1d6e9e184d4"}, + {file = "onnxruntime-1.19.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a2b53b3c287cd933e5eb597273926e899082d8c84ab96e1b34035764a1627e17"}, + {file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e94984663963e74fbb468bde9ec6f19dcf890b594b35e249c4dc8789d08993c5"}, + {file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f379d1f050cfb55ce015d53727b78ee362febc065c38eed81512b22b757da73"}, + {file = "onnxruntime-1.19.0-cp311-cp311-win32.whl", hash = "sha256:4ccb48faea02503275ae7e79e351434fc43c294c4cb5c4d8bcb7479061396614"}, + {file = "onnxruntime-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:9cdc8d311289a84e77722de68bd22b8adfb94eea26f4be6f9e017350faac8b18"}, + {file = "onnxruntime-1.19.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1b59eaec1be9a8613c5fdeaafe67f73a062edce3ac03bbbdc9e2d98b58a30617"}, + {file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4144d014a4b25184e63ce7a463a2e7796e2f3df931fccc6a6aefa6f1365dc5"}, + {file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10d7e7d4ca7021ce7f29a66dbc6071addf2de5839135339bd855c6d9c2bba371"}, + {file = "onnxruntime-1.19.0-cp312-cp312-win32.whl", hash = "sha256:87f2c58b577a1fb31dc5d92b647ecc588fd5f1ea0c3ad4526f5f80a113357c8d"}, + {file = "onnxruntime-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a1f50d49676d7b69566536ff039d9e4e95fc482a55673719f46528218ecbb94"}, + {file = "onnxruntime-1.19.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:71423c8c4b2d7a58956271534302ec72721c62a41efd0c4896343249b8399ab0"}, + {file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d63630d45e9498f96e75bbeb7fd4a56acb10155de0de4d0e18d1b6cbb0b358a"}, + {file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3bfd15db1e8794d379a86c1a9116889f47f2cca40cc82208fc4f7e8c38e8522"}, + {file = "onnxruntime-1.19.0-cp38-cp38-win32.whl", hash = "sha256:3b098003b6b4cb37cc84942e5f1fe27f945dd857cbd2829c824c26b0ba4a247e"}, + {file = "onnxruntime-1.19.0-cp38-cp38-win_amd64.whl", hash = "sha256:cea067a6541d6787d903ee6843401c5b1332a266585160d9700f9f0939443886"}, + {file = "onnxruntime-1.19.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:c4fcff12dc5ca963c5f76b9822bb404578fa4a98c281e8c666b429192799a099"}, + {file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6dcad8a4db908fbe70b98c79cea1c8b6ac3316adf4ce93453136e33a524ac59"}, + {file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bc449907c6e8d99eee5ae5cc9c8fdef273d801dcd195393d3f9ab8ad3f49522"}, + {file = "onnxruntime-1.19.0-cp39-cp39-win32.whl", hash = "sha256:947febd48405afcf526e45ccff97ff23b15e530434705f734870d22ae7fcf236"}, + {file = "onnxruntime-1.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:f60be47eff5ee77fd28a466b0fd41d7debc42a32179d1ddb21e05d6067d7b48b"}, ] [package.dependencies] coloredlogs = "*" flatbuffers = "*" -numpy = ">=1.21.6,<2.0" +numpy = ">=1.21.6" packaging = "*" protobuf = "*" sympy = "*" @@ -2082,64 +2082,68 @@ numpy = [ [[package]] name = "orjson" -version = "3.10.6" +version = "3.10.7" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.8" files = [ - {file = "orjson-3.10.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb0ee33124db6eaa517d00890fc1a55c3bfe1cf78ba4a8899d71a06f2d6ff5c7"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c1c4b53b24a4c06547ce43e5fee6ec4e0d8fe2d597f4647fc033fd205707365"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eadc8fd310edb4bdbd333374f2c8fec6794bbbae99b592f448d8214a5e4050c0"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61272a5aec2b2661f4fa2b37c907ce9701e821b2c1285d5c3ab0207ebd358d38"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57985ee7e91d6214c837936dc1608f40f330a6b88bb13f5a57ce5257807da143"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633a3b31d9d7c9f02d49c4ab4d0a86065c4a6f6adc297d63d272e043472acab5"}, - {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c680b269d33ec444afe2bdc647c9eb73166fa47a16d9a75ee56a374f4a45f43"}, - {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f759503a97a6ace19e55461395ab0d618b5a117e8d0fbb20e70cfd68a47327f2"}, - {file = "orjson-3.10.6-cp310-none-win32.whl", hash = "sha256:95a0cce17f969fb5391762e5719575217bd10ac5a189d1979442ee54456393f3"}, - {file = "orjson-3.10.6-cp310-none-win_amd64.whl", hash = "sha256:df25d9271270ba2133cc88ee83c318372bdc0f2cd6f32e7a450809a111efc45c"}, - {file = "orjson-3.10.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b1ec490e10d2a77c345def52599311849fc063ae0e67cf4f84528073152bb2ba"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d43d3feb8f19d07e9f01e5b9be4f28801cf7c60d0fa0d279951b18fae1932b"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3045267e98fe749408eee1593a142e02357c5c99be0802185ef2170086a863"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c27bc6a28ae95923350ab382c57113abd38f3928af3c80be6f2ba7eb8d8db0b0"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d27456491ca79532d11e507cadca37fb8c9324a3976294f68fb1eff2dc6ced5a"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05ac3d3916023745aa3b3b388e91b9166be1ca02b7c7e41045da6d12985685f0"}, - {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1335d4ef59ab85cab66fe73fd7a4e881c298ee7f63ede918b7faa1b27cbe5212"}, - {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4bbc6d0af24c1575edc79994c20e1b29e6fb3c6a570371306db0993ecf144dc5"}, - {file = "orjson-3.10.6-cp311-none-win32.whl", hash = "sha256:450e39ab1f7694465060a0550b3f6d328d20297bf2e06aa947b97c21e5241fbd"}, - {file = "orjson-3.10.6-cp311-none-win_amd64.whl", hash = "sha256:227df19441372610b20e05bdb906e1742ec2ad7a66ac8350dcfd29a63014a83b"}, - {file = "orjson-3.10.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ea2977b21f8d5d9b758bb3f344a75e55ca78e3ff85595d248eee813ae23ecdfb"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f3d167d13a16ed263b52dbfedff52c962bfd3d270b46b7518365bcc2121eed"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f710f346e4c44a4e8bdf23daa974faede58f83334289df80bc9cd12fe82573c7"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7275664f84e027dcb1ad5200b8b18373e9c669b2a9ec33d410c40f5ccf4b257e"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0943e4c701196b23c240b3d10ed8ecd674f03089198cf503105b474a4f77f21f"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:446dee5a491b5bc7d8f825d80d9637e7af43f86a331207b9c9610e2f93fee22a"}, - {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64c81456d2a050d380786413786b057983892db105516639cb5d3ee3c7fd5148"}, - {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, - {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, - {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, - {file = "orjson-3.10.6-cp313-none-win32.whl", hash = "sha256:efdf2c5cde290ae6b83095f03119bdc00303d7a03b42b16c54517baa3c4ca3d0"}, - {file = "orjson-3.10.6-cp313-none-win_amd64.whl", hash = "sha256:8e190fe7888e2e4392f52cafb9626113ba135ef53aacc65cd13109eb9746c43e"}, - {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2c116072a8533f2fec435fde4d134610f806bdac20188c7bd2081f3e9e0133f"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eeb13218c8cf34c61912e9df2de2853f1d009de0e46ea09ccdf3d757896af0a"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965a916373382674e323c957d560b953d81d7a8603fbeee26f7b8248638bd48b"}, - {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03c95484d53ed8e479cade8628c9cea00fd9d67f5554764a1110e0d5aa2de96e"}, - {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e060748a04cccf1e0a6f2358dffea9c080b849a4a68c28b1b907f272b5127e9b"}, - {file = "orjson-3.10.6-cp38-none-win32.whl", hash = "sha256:738dbe3ef909c4b019d69afc19caf6b5ed0e2f1c786b5d6215fbb7539246e4c6"}, - {file = "orjson-3.10.6-cp38-none-win_amd64.whl", hash = "sha256:d40f839dddf6a7d77114fe6b8a70218556408c71d4d6e29413bb5f150a692ff7"}, - {file = "orjson-3.10.6-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:697a35a083c4f834807a6232b3e62c8b280f7a44ad0b759fd4dce748951e70db"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd502f96bf5ea9a61cbc0b2b5900d0dd68aa0da197179042bdd2be67e51a1e4b"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f215789fb1667cdc874c1b8af6a84dc939fd802bf293a8334fce185c79cd359b"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2debd8ddce948a8c0938c8c93ade191d2f4ba4649a54302a7da905a81f00b56"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5410111d7b6681d4b0d65e0f58a13be588d01b473822483f77f513c7f93bd3b2"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb1f28a137337fdc18384079fa5726810681055b32b92253fa15ae5656e1dddb"}, - {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf2fbbce5fe7cd1aa177ea3eab2b8e6a6bc6e8592e4279ed3db2d62e57c0e1b2"}, - {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:79b9b9e33bd4c517445a62b90ca0cc279b0f1f3970655c3df9e608bc3f91741a"}, - {file = "orjson-3.10.6-cp39-none-win32.whl", hash = "sha256:30b0a09a2014e621b1adf66a4f705f0809358350a757508ee80209b2d8dae219"}, - {file = "orjson-3.10.6-cp39-none-win_amd64.whl", hash = "sha256:49e3bc615652617d463069f91b867a4458114c5b104e13b7ae6872e5f79d0844"}, - {file = "orjson-3.10.6.tar.gz", hash = "sha256:e54b63d0a7c6c54a5f5f726bc93a2078111ef060fec4ecbf34c5db800ca3b3a7"}, + {file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"}, + {file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"}, + {file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"}, + {file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"}, + {file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"}, + {file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"}, + {file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"}, + {file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"}, + {file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"}, + {file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"}, + {file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"}, + {file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"}, + {file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"}, + {file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"}, + {file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"}, + {file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"}, + {file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"}, + {file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"}, + {file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"}, + {file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"}, ] [[package]] @@ -2369,54 +2373,54 @@ files = [ [[package]] name = "pydantic" -version = "1.10.17" +version = "1.10.18" description = "Data validation and settings management using python type hints" optional = false python-versions = ">=3.7" files = [ - {file = "pydantic-1.10.17-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fa51175313cc30097660b10eec8ca55ed08bfa07acbfe02f7a42f6c242e9a4b"}, - {file = "pydantic-1.10.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7e8988bb16988890c985bd2093df9dd731bfb9d5e0860db054c23034fab8f7a"}, - {file = "pydantic-1.10.17-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:371dcf1831f87c9e217e2b6a0c66842879a14873114ebb9d0861ab22e3b5bb1e"}, - {file = "pydantic-1.10.17-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4866a1579c0c3ca2c40575398a24d805d4db6cb353ee74df75ddeee3c657f9a7"}, - {file = "pydantic-1.10.17-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:543da3c6914795b37785703ffc74ba4d660418620cc273490d42c53949eeeca6"}, - {file = "pydantic-1.10.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7623b59876f49e61c2e283551cc3647616d2fbdc0b4d36d3d638aae8547ea681"}, - {file = "pydantic-1.10.17-cp310-cp310-win_amd64.whl", hash = "sha256:409b2b36d7d7d19cd8310b97a4ce6b1755ef8bd45b9a2ec5ec2b124db0a0d8f3"}, - {file = "pydantic-1.10.17-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fa43f362b46741df8f201bf3e7dff3569fa92069bcc7b4a740dea3602e27ab7a"}, - {file = "pydantic-1.10.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2a72d2a5ff86a3075ed81ca031eac86923d44bc5d42e719d585a8eb547bf0c9b"}, - {file = "pydantic-1.10.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4ad32aed3bf5eea5ca5decc3d1bbc3d0ec5d4fbcd72a03cdad849458decbc63"}, - {file = "pydantic-1.10.17-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb4e741782e236ee7dc1fb11ad94dc56aabaf02d21df0e79e0c21fe07c95741"}, - {file = "pydantic-1.10.17-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d2f89a719411cb234105735a520b7c077158a81e0fe1cb05a79c01fc5eb59d3c"}, - {file = "pydantic-1.10.17-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db3b48d9283d80a314f7a682f7acae8422386de659fffaba454b77a083c3937d"}, - {file = "pydantic-1.10.17-cp311-cp311-win_amd64.whl", hash = "sha256:9c803a5113cfab7bbb912f75faa4fc1e4acff43e452c82560349fff64f852e1b"}, - {file = "pydantic-1.10.17-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:820ae12a390c9cbb26bb44913c87fa2ff431a029a785642c1ff11fed0a095fcb"}, - {file = "pydantic-1.10.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1e51d1af306641b7d1574d6d3307eaa10a4991542ca324f0feb134fee259815"}, - {file = "pydantic-1.10.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e53fb834aae96e7b0dadd6e92c66e7dd9cdf08965340ed04c16813102a47fab"}, - {file = "pydantic-1.10.17-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e2495309b1266e81d259a570dd199916ff34f7f51f1b549a0d37a6d9b17b4dc"}, - {file = "pydantic-1.10.17-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:098ad8de840c92ea586bf8efd9e2e90c6339d33ab5c1cfbb85be66e4ecf8213f"}, - {file = "pydantic-1.10.17-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:525bbef620dac93c430d5d6bdbc91bdb5521698d434adf4434a7ef6ffd5c4b7f"}, - {file = "pydantic-1.10.17-cp312-cp312-win_amd64.whl", hash = "sha256:6654028d1144df451e1da69a670083c27117d493f16cf83da81e1e50edce72ad"}, - {file = "pydantic-1.10.17-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c87cedb4680d1614f1d59d13fea353faf3afd41ba5c906a266f3f2e8c245d655"}, - {file = "pydantic-1.10.17-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11289fa895bcbc8f18704efa1d8020bb9a86314da435348f59745473eb042e6b"}, - {file = "pydantic-1.10.17-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94833612d6fd18b57c359a127cbfd932d9150c1b72fea7c86ab58c2a77edd7c7"}, - {file = "pydantic-1.10.17-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d4ecb515fa7cb0e46e163ecd9d52f9147ba57bc3633dca0e586cdb7a232db9e3"}, - {file = "pydantic-1.10.17-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7017971ffa7fd7808146880aa41b266e06c1e6e12261768a28b8b41ba55c8076"}, - {file = "pydantic-1.10.17-cp37-cp37m-win_amd64.whl", hash = "sha256:e840e6b2026920fc3f250ea8ebfdedf6ea7a25b77bf04c6576178e681942ae0f"}, - {file = "pydantic-1.10.17-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bfbb18b616abc4df70591b8c1ff1b3eabd234ddcddb86b7cac82657ab9017e33"}, - {file = "pydantic-1.10.17-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebb249096d873593e014535ab07145498957091aa6ae92759a32d40cb9998e2e"}, - {file = "pydantic-1.10.17-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8c209af63ccd7b22fba94b9024e8b7fd07feffee0001efae50dd99316b27768"}, - {file = "pydantic-1.10.17-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b40c9e13a0b61583e5599e7950490c700297b4a375b55b2b592774332798b7"}, - {file = "pydantic-1.10.17-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c31d281c7485223caf6474fc2b7cf21456289dbaa31401844069b77160cab9c7"}, - {file = "pydantic-1.10.17-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae5184e99a060a5c80010a2d53c99aee76a3b0ad683d493e5f0620b5d86eeb75"}, - {file = "pydantic-1.10.17-cp38-cp38-win_amd64.whl", hash = "sha256:ad1e33dc6b9787a6f0f3fd132859aa75626528b49cc1f9e429cdacb2608ad5f0"}, - {file = "pydantic-1.10.17-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e17c0ee7192e54a10943f245dc79e36d9fe282418ea05b886e1c666063a7b54"}, - {file = "pydantic-1.10.17-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cafb9c938f61d1b182dfc7d44a7021326547b7b9cf695db5b68ec7b590214773"}, - {file = "pydantic-1.10.17-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95ef534e3c22e5abbdbdd6f66b6ea9dac3ca3e34c5c632894f8625d13d084cbe"}, - {file = "pydantic-1.10.17-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d96b8799ae3d782df7ec9615cb59fc32c32e1ed6afa1b231b0595f6516e8ab"}, - {file = "pydantic-1.10.17-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ab2f976336808fd5d539fdc26eb51f9aafc1f4b638e212ef6b6f05e753c8011d"}, - {file = "pydantic-1.10.17-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8ad363330557beac73159acfbeed220d5f1bfcd6b930302a987a375e02f74fd"}, - {file = "pydantic-1.10.17-cp39-cp39-win_amd64.whl", hash = "sha256:48db882e48575ce4b39659558b2f9f37c25b8d348e37a2b4e32971dd5a7d6227"}, - {file = "pydantic-1.10.17-py3-none-any.whl", hash = "sha256:e41b5b973e5c64f674b3b4720286ded184dcc26a691dd55f34391c62c6934688"}, - {file = "pydantic-1.10.17.tar.gz", hash = "sha256:f434160fb14b353caf634149baaf847206406471ba70e64657c1e8330277a991"}, + {file = "pydantic-1.10.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e405ffcc1254d76bb0e760db101ee8916b620893e6edfbfee563b3c6f7a67c02"}, + {file = "pydantic-1.10.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e306e280ebebc65040034bff1a0a81fd86b2f4f05daac0131f29541cafd80b80"}, + {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11d9d9b87b50338b1b7de4ebf34fd29fdb0d219dc07ade29effc74d3d2609c62"}, + {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b661ce52c7b5e5f600c0c3c5839e71918346af2ef20062705ae76b5c16914cab"}, + {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c20f682defc9ef81cd7eaa485879ab29a86a0ba58acf669a78ed868e72bb89e0"}, + {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c5ae6b7c8483b1e0bf59e5f1843e4fd8fd405e11df7de217ee65b98eb5462861"}, + {file = "pydantic-1.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:74fe19dda960b193b0eb82c1f4d2c8e5e26918d9cda858cbf3f41dd28549cb70"}, + {file = "pydantic-1.10.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72fa46abace0a7743cc697dbb830a41ee84c9db8456e8d77a46d79b537efd7ec"}, + {file = "pydantic-1.10.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef0fe7ad7cbdb5f372463d42e6ed4ca9c443a52ce544472d8842a0576d830da5"}, + {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00e63104346145389b8e8f500bc6a241e729feaf0559b88b8aa513dd2065481"}, + {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae6fa2008e1443c46b7b3a5eb03800121868d5ab6bc7cda20b5df3e133cde8b3"}, + {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9f463abafdc92635da4b38807f5b9972276be7c8c5121989768549fceb8d2588"}, + {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3445426da503c7e40baccefb2b2989a0c5ce6b163679dd75f55493b460f05a8f"}, + {file = "pydantic-1.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:467a14ee2183bc9c902579bb2f04c3d3dac00eff52e252850509a562255b2a33"}, + {file = "pydantic-1.10.18-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:efbc8a7f9cb5fe26122acba1852d8dcd1e125e723727c59dcd244da7bdaa54f2"}, + {file = "pydantic-1.10.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24a4a159d0f7a8e26bf6463b0d3d60871d6a52eac5bb6a07a7df85c806f4c048"}, + {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b74be007703547dc52e3c37344d130a7bfacca7df112a9e5ceeb840a9ce195c7"}, + {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcb20d4cb355195c75000a49bb4a31d75e4295200df620f454bbc6bdf60ca890"}, + {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46f379b8cb8a3585e3f61bf9ae7d606c70d133943f339d38b76e041ec234953f"}, + {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cbfbca662ed3729204090c4d09ee4beeecc1a7ecba5a159a94b5a4eb24e3759a"}, + {file = "pydantic-1.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:c6d0a9f9eccaf7f438671a64acf654ef0d045466e63f9f68a579e2383b63f357"}, + {file = "pydantic-1.10.18-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d5492dbf953d7d849751917e3b2433fb26010d977aa7a0765c37425a4026ff1"}, + {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe734914977eed33033b70bfc097e1baaffb589517863955430bf2e0846ac30f"}, + {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15fdbe568beaca9aacfccd5ceadfb5f1a235087a127e8af5e48df9d8a45ae85c"}, + {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c3e742f62198c9eb9201781fbebe64533a3bbf6a76a91b8d438d62b813079dbc"}, + {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19a3bd00b9dafc2cd7250d94d5b578edf7a0bd7daf102617153ff9a8fa37871c"}, + {file = "pydantic-1.10.18-cp37-cp37m-win_amd64.whl", hash = "sha256:2ce3fcf75b2bae99aa31bd4968de0474ebe8c8258a0110903478bd83dfee4e3b"}, + {file = "pydantic-1.10.18-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:335a32d72c51a313b33fa3a9b0fe283503272ef6467910338e123f90925f0f03"}, + {file = "pydantic-1.10.18-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:34a3613c7edb8c6fa578e58e9abe3c0f5e7430e0fc34a65a415a1683b9c32d9a"}, + {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9ee4e6ca1d9616797fa2e9c0bfb8815912c7d67aca96f77428e316741082a1b"}, + {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23e8ec1ce4e57b4f441fc91e3c12adba023fedd06868445a5b5f1d48f0ab3682"}, + {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:44ae8a3e35a54d2e8fa88ed65e1b08967a9ef8c320819a969bfa09ce5528fafe"}, + {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5389eb3b48a72da28c6e061a247ab224381435256eb541e175798483368fdd3"}, + {file = "pydantic-1.10.18-cp38-cp38-win_amd64.whl", hash = "sha256:069b9c9fc645474d5ea3653788b544a9e0ccd3dca3ad8c900c4c6eac844b4620"}, + {file = "pydantic-1.10.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80b982d42515632eb51f60fa1d217dfe0729f008e81a82d1544cc392e0a50ddf"}, + {file = "pydantic-1.10.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aad8771ec8dbf9139b01b56f66386537c6fe4e76c8f7a47c10261b69ad25c2c9"}, + {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941a2eb0a1509bd7f31e355912eb33b698eb0051730b2eaf9e70e2e1589cae1d"}, + {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65f7361a09b07915a98efd17fdec23103307a54db2000bb92095457ca758d485"}, + {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6951f3f47cb5ca4da536ab161ac0163cab31417d20c54c6de5ddcab8bc813c3f"}, + {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a4c5eec138a9b52c67f664c7d51d4c7234c5ad65dd8aacd919fb47445a62c86"}, + {file = "pydantic-1.10.18-cp39-cp39-win_amd64.whl", hash = "sha256:49e26c51ca854286bffc22b69787a8d4063a62bf7d83dc21d44d2ff426108518"}, + {file = "pydantic-1.10.18-py3-none-any.whl", hash = "sha256:06a189b81ffc52746ec9c8c007f16e5167c8b0a696e1a726369327e3db7b2a82"}, + {file = "pydantic-1.10.18.tar.gz", hash = "sha256:baebdff1907d1d96a139c25136a9bb7d17e118f133a76a2ef3b845e831e3403a"}, ] [package.dependencies] @@ -2490,17 +2494,17 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.23.8" +version = "0.24.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, - {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, ] [package.dependencies] -pytest = ">=7.0.0,<9" +pytest = ">=8.2,<9" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] @@ -2508,13 +2512,13 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "4.1.0" +version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, ] [package.dependencies] @@ -2522,7 +2526,7 @@ coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-mock" @@ -2829,29 +2833,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.5.7" +version = "0.6.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, - {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, - {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, - {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, - {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, - {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, - {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, + {file = "ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c"}, + {file = "ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570"}, + {file = "ruff-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3dbeac76ed13456f8158b8f4fe087bf87882e645c8e8b606dd17b0b66c2c1158"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094600ee88cda325988d3f54e3588c46de5c18dae09d683ace278b11f9d4d534"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:316d418fe258c036ba05fbf7dfc1f7d3d4096db63431546163b472285668132b"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d72b8b3abf8a2d51b7b9944a41307d2f442558ccb3859bbd87e6ae9be1694a5d"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2aed7e243be68487aa8982e91c6e260982d00da3f38955873aecd5a9204b1d66"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d371f7fc9cec83497fe7cf5eaf5b76e22a8efce463de5f775a1826197feb9df8"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8f310d63af08f583363dfb844ba8f9417b558199c58a5999215082036d795a1"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7db6880c53c56addb8638fe444818183385ec85eeada1d48fc5abe045301b2f1"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1175d39faadd9a50718f478d23bfc1d4da5743f1ab56af81a2b6caf0a2394f23"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939f9c86d51635fe486585389f54582f0d65b8238e08c327c1534844b3bb9a"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0d62ca91219f906caf9b187dea50d17353f15ec9bb15aae4a606cd697b49b4c"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7438a7288f9d67ed3c8ce4d059e67f7ed65e9fe3aa2ab6f5b4b3610e57e3cb56"}, + {file = "ruff-0.6.2-py3-none-win32.whl", hash = "sha256:279d5f7d86696df5f9549b56b9b6a7f6c72961b619022b5b7999b15db392a4da"}, + {file = "ruff-0.6.2-py3-none-win_amd64.whl", hash = "sha256:d9f3469c7dd43cd22eb1c3fc16926fb8258d50cb1b216658a07be95dd117b0f2"}, + {file = "ruff-0.6.2-py3-none-win_arm64.whl", hash = "sha256:f28fcd2cd0e02bdf739297516d5643a945cc7caf09bd9bcb4d932540a5ea4fa9"}, + {file = "ruff-0.6.2.tar.gz", hash = "sha256:239ee6beb9e91feb8e0ec384204a763f36cb53fb895a1a364618c6abb076b3be"}, ] [[package]] @@ -3264,13 +3268,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.30.5" +version = "0.30.6" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.30.5-py3-none-any.whl", hash = "sha256:b2d86de274726e9878188fa07576c9ceeff90a839e2b6e25c917fe05f5a6c835"}, - {file = "uvicorn-0.30.5.tar.gz", hash = "sha256:ac6fdbd4425c5fd17a9fe39daf4d4d075da6fdc80f653e5894cdc2fd98752bee"}, + {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, + {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, ] [package.dependencies] diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 05ac4618cdef2..08e7d01bf490f 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.112.1" +version = "1.113.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/machine-learning/start.sh b/machine-learning/start.sh index 6b8e55a23657d..c3fda523df832 100755 --- a/machine-learning/start.sh +++ b/machine-learning/start.sh @@ -13,6 +13,7 @@ fi : "${IMMICH_HOST:=[::]}" : "${IMMICH_PORT:=3003}" : "${MACHINE_LEARNING_WORKERS:=1}" +: "${MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S:=2}" gunicorn app.main:app \ -k app.config.CustomUvicornWorker \ @@ -20,4 +21,5 @@ gunicorn app.main:app \ -w "$MACHINE_LEARNING_WORKERS" \ -t "$MACHINE_LEARNING_WORKER_TIMEOUT" \ --log-config-json log_conf.json \ + --keep-alive "$MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S" \ --graceful-timeout 0 diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index edb41510f0156..17c2830b48e26 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -24,7 +24,7 @@ + android:largeHeap="true" android:enableOnBackInvokedCallback="false"> + android:value="false" /> - + @@ -14,13 +16,14 @@ - + - + + diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index c7a5991212991..1c3ac477f8227 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.112.1 + 1.113.0 CFBundleSignature ???? CFBundleVersion - 169 + 171 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index c7d078ceeafb4..a9000ba86d032 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.112.1" + version_number: "1.113.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 3f8c1fa74cbe7..97e10b3d200fe 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -33,11 +33,13 @@ class Asset { isArchived = remote.isArchived, isTrashed = remote.isTrashed, isOffline = remote.isOffline, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId = - remote.stackParentId == remote.id ? null : remote.stackParentId, - stackCount = remote.stackCount, + stackPrimaryAssetId = remote.stack?.primaryAssetId == remote.id + ? null + : remote.stack?.primaryAssetId, + stackCount = remote.stack?.assetCount ?? 0, + stackId = remote.stack?.id, thumbhash = remote.thumbhash; Asset.local(AssetEntity local, List hash) @@ -86,7 +88,8 @@ class Asset { this.isFavorite = false, this.isArchived = false, this.isTrashed = false, - this.stackParentId, + this.stackId, + this.stackPrimaryAssetId, this.stackCount = 0, this.isOffline = false, this.thumbhash, @@ -163,12 +166,11 @@ class Asset { @ignore ExifInfo? exifInfo; - String? stackParentId; + String? stackId; - @ignore - int get stackChildrenCount => stackCount ?? 0; + String? stackPrimaryAssetId; - int? stackCount; + int stackCount; /// Aspect ratio of the asset @ignore @@ -231,7 +233,8 @@ class Asset { isArchived == other.isArchived && isTrashed == other.isTrashed && stackCount == other.stackCount && - stackParentId == other.stackParentId; + stackPrimaryAssetId == other.stackPrimaryAssetId && + stackId == other.stackId; } @override @@ -256,7 +259,8 @@ class Asset { isArchived.hashCode ^ isTrashed.hashCode ^ stackCount.hashCode ^ - stackParentId.hashCode; + stackPrimaryAssetId.hashCode ^ + stackId.hashCode; /// Returns `true` if this [Asset] can updated with values from parameter [a] bool canUpdate(Asset a) { @@ -269,7 +273,6 @@ class Asset { width == null && a.width != null || height == null && a.height != null || livePhotoVideoId == null && a.livePhotoVideoId != null || - stackParentId == null && a.stackParentId != null || isFavorite != a.isFavorite || isArchived != a.isArchived || isTrashed != a.isTrashed || @@ -278,10 +281,9 @@ class Asset { a.exifInfo?.longitude != exifInfo?.longitude || // no local stack count or different count from remote a.thumbhash != thumbhash || - ((stackCount == null && a.stackCount != null) || - (stackCount != null && - a.stackCount != null && - stackCount != a.stackCount)); + stackId != a.stackId || + stackCount != a.stackCount || + stackPrimaryAssetId == null && a.stackPrimaryAssetId != null; } /// Returns a new [Asset] with values from this and merged & updated with [a] @@ -311,9 +313,11 @@ class Asset { id: id, remoteId: remoteId, livePhotoVideoId: livePhotoVideoId, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId: stackParentId == remoteId ? null : stackParentId, + stackId: stackId, + stackPrimaryAssetId: + stackPrimaryAssetId == remoteId ? null : stackPrimaryAssetId, stackCount: stackCount, isFavorite: isFavorite, isArchived: isArchived, @@ -330,9 +334,12 @@ class Asset { width: a.width, height: a.height, livePhotoVideoId: a.livePhotoVideoId, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId: a.stackParentId == a.remoteId ? null : a.stackParentId, + stackId: a.stackId, + stackPrimaryAssetId: a.stackPrimaryAssetId == a.remoteId + ? null + : a.stackPrimaryAssetId, stackCount: a.stackCount, // isFavorite + isArchived are not set by device-only assets isFavorite: a.isFavorite, @@ -374,7 +381,8 @@ class Asset { bool? isTrashed, bool? isOffline, ExifInfo? exifInfo, - String? stackParentId, + String? stackId, + String? stackPrimaryAssetId, int? stackCount, String? thumbhash, }) => @@ -398,7 +406,8 @@ class Asset { isTrashed: isTrashed ?? this.isTrashed, isOffline: isOffline ?? this.isOffline, exifInfo: exifInfo ?? this.exifInfo, - stackParentId: stackParentId ?? this.stackParentId, + stackId: stackId ?? this.stackId, + stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId, stackCount: stackCount ?? this.stackCount, thumbhash: thumbhash ?? this.thumbhash, ); @@ -445,8 +454,9 @@ class Asset { "checksum": "$checksum", "ownerId": $ownerId, "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}", + "stackId": "${stackId ?? "N/A"}", + "stackPrimaryAssetId": "${stackPrimaryAssetId ?? "N/A"}", "stackCount": "$stackCount", - "stackParentId": "${stackParentId ?? "N/A"}", "fileCreatedAt": "$fileCreatedAt", "fileModifiedAt": "$fileModifiedAt", "updatedAt": "$updatedAt", diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 099e15eef15ca..23bf23604635d 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -92,29 +92,34 @@ const AssetSchema = CollectionSchema( name: r'stackCount', type: IsarType.long, ), - r'stackParentId': PropertySchema( + r'stackId': PropertySchema( id: 15, - name: r'stackParentId', + name: r'stackId', + type: IsarType.string, + ), + r'stackPrimaryAssetId': PropertySchema( + id: 16, + name: r'stackPrimaryAssetId', type: IsarType.string, ), r'thumbhash': PropertySchema( - id: 16, + id: 17, name: r'thumbhash', type: IsarType.string, ), r'type': PropertySchema( - id: 17, + id: 18, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 18, + id: 19, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 19, + id: 20, name: r'width', type: IsarType.int, ) @@ -205,7 +210,13 @@ int _assetEstimateSize( } } { - final value = object.stackParentId; + final value = object.stackId; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.stackPrimaryAssetId; if (value != null) { bytesCount += 3 + value.length * 3; } @@ -240,11 +251,12 @@ void _assetSerialize( writer.writeLong(offsets[12], object.ownerId); writer.writeString(offsets[13], object.remoteId); writer.writeLong(offsets[14], object.stackCount); - writer.writeString(offsets[15], object.stackParentId); - writer.writeString(offsets[16], object.thumbhash); - writer.writeByte(offsets[17], object.type.index); - writer.writeDateTime(offsets[18], object.updatedAt); - writer.writeInt(offsets[19], object.width); + writer.writeString(offsets[15], object.stackId); + writer.writeString(offsets[16], object.stackPrimaryAssetId); + writer.writeString(offsets[17], object.thumbhash); + writer.writeByte(offsets[18], object.type.index); + writer.writeDateTime(offsets[19], object.updatedAt); + writer.writeInt(offsets[20], object.width); } Asset _assetDeserialize( @@ -269,13 +281,14 @@ Asset _assetDeserialize( localId: reader.readStringOrNull(offsets[11]), ownerId: reader.readLong(offsets[12]), remoteId: reader.readStringOrNull(offsets[13]), - stackCount: reader.readLongOrNull(offsets[14]), - stackParentId: reader.readStringOrNull(offsets[15]), - thumbhash: reader.readStringOrNull(offsets[16]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? + stackCount: reader.readLongOrNull(offsets[14]) ?? 0, + stackId: reader.readStringOrNull(offsets[15]), + stackPrimaryAssetId: reader.readStringOrNull(offsets[16]), + thumbhash: reader.readStringOrNull(offsets[17]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[18]), - width: reader.readIntOrNull(offsets[19]), + updatedAt: reader.readDateTime(offsets[19]), + width: reader.readIntOrNull(offsets[20]), ); return object; } @@ -316,17 +329,19 @@ P _assetDeserializeProp

( case 13: return (reader.readStringOrNull(offset)) as P; case 14: - return (reader.readLongOrNull(offset)) as P; + return (reader.readLongOrNull(offset) ?? 0) as P; case 15: return (reader.readStringOrNull(offset)) as P; case 16: return (reader.readStringOrNull(offset)) as P; case 17: + return (reader.readStringOrNull(offset)) as P; + case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 18: - return (reader.readDateTime(offset)) as P; case 19: + return (reader.readDateTime(offset)) as P; + case 20: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1859,24 +1874,8 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder stackCountIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'stackCount', - )); - }); - } - - QueryBuilder stackCountIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'stackCount', - )); - }); - } - QueryBuilder stackCountEqualTo( - int? value) { + int value) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.equalTo( property: r'stackCount', @@ -1886,7 +1885,7 @@ extension AssetQueryFilter on QueryBuilder { } QueryBuilder stackCountGreaterThan( - int? value, { + int value, { bool include = false, }) { return QueryBuilder.apply(this, (query) { @@ -1899,7 +1898,7 @@ extension AssetQueryFilter on QueryBuilder { } QueryBuilder stackCountLessThan( - int? value, { + int value, { bool include = false, }) { return QueryBuilder.apply(this, (query) { @@ -1912,8 +1911,8 @@ extension AssetQueryFilter on QueryBuilder { } QueryBuilder stackCountBetween( - int? lower, - int? upper, { + int lower, + int upper, { bool includeLower = true, bool includeUpper = true, }) { @@ -1928,36 +1927,36 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder stackParentIdIsNull() { + QueryBuilder stackIdIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( - property: r'stackParentId', + property: r'stackId', )); }); } - QueryBuilder stackParentIdIsNotNull() { + QueryBuilder stackIdIsNotNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'stackParentId', + property: r'stackId', )); }); } - QueryBuilder stackParentIdEqualTo( + QueryBuilder stackIdEqualTo( String? value, { bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.equalTo( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdGreaterThan( + QueryBuilder stackIdGreaterThan( String? value, { bool include = false, bool caseSensitive = true, @@ -1965,14 +1964,14 @@ extension AssetQueryFilter on QueryBuilder { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.greaterThan( include: include, - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdLessThan( + QueryBuilder stackIdLessThan( String? value, { bool include = false, bool caseSensitive = true, @@ -1980,14 +1979,14 @@ extension AssetQueryFilter on QueryBuilder { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.lessThan( include: include, - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdBetween( + QueryBuilder stackIdBetween( String? lower, String? upper, { bool includeLower = true, @@ -1996,7 +1995,7 @@ extension AssetQueryFilter on QueryBuilder { }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.between( - property: r'stackParentId', + property: r'stackId', lower: lower, includeLower: includeLower, upper: upper, @@ -2006,69 +2005,221 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder stackParentIdStartsWith( + QueryBuilder stackIdStartsWith( String value, { bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.startsWith( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdEndsWith( + QueryBuilder stackIdEndsWith( String value, { bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.endsWith( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdContains( + QueryBuilder stackIdContains( String value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.contains( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdMatches( + QueryBuilder stackIdMatches( String pattern, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.matches( - property: r'stackParentId', + property: r'stackId', wildcard: pattern, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdIsEmpty() { + QueryBuilder stackIdIsEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.equalTo( - property: r'stackParentId', + property: r'stackId', value: '', )); }); } - QueryBuilder stackParentIdIsNotEmpty() { + QueryBuilder stackIdIsNotEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.greaterThan( - property: r'stackParentId', + property: r'stackId', + value: '', + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'stackPrimaryAssetId', + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'stackPrimaryAssetId', + )); + }); + } + + QueryBuilder stackPrimaryAssetIdEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'stackPrimaryAssetId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'stackPrimaryAssetId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackPrimaryAssetId', + value: '', + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'stackPrimaryAssetId', value: '', )); }); @@ -2580,15 +2731,27 @@ extension AssetQuerySortBy on QueryBuilder { }); } - QueryBuilder sortByStackParentId() { + QueryBuilder sortByStackId() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.asc); + return query.addSortBy(r'stackId', Sort.asc); }); } - QueryBuilder sortByStackParentIdDesc() { + QueryBuilder sortByStackIdDesc() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.desc); + return query.addSortBy(r'stackId', Sort.desc); + }); + } + + QueryBuilder sortByStackPrimaryAssetId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.asc); + }); + } + + QueryBuilder sortByStackPrimaryAssetIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.desc); }); } @@ -2834,15 +2997,27 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } - QueryBuilder thenByStackParentId() { + QueryBuilder thenByStackId() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.asc); + return query.addSortBy(r'stackId', Sort.asc); }); } - QueryBuilder thenByStackParentIdDesc() { + QueryBuilder thenByStackIdDesc() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.desc); + return query.addSortBy(r'stackId', Sort.desc); + }); + } + + QueryBuilder thenByStackPrimaryAssetId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.asc); + }); + } + + QueryBuilder thenByStackPrimaryAssetIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.desc); }); } @@ -2992,10 +3167,17 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } - QueryBuilder distinctByStackParentId( + QueryBuilder distinctByStackId( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'stackParentId', + return query.addDistinctBy(r'stackId', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByStackPrimaryAssetId( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'stackPrimaryAssetId', caseSensitive: caseSensitive); }); } @@ -3117,15 +3299,21 @@ extension AssetQueryProperty on QueryBuilder { }); } - QueryBuilder stackCountProperty() { + QueryBuilder stackCountProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'stackCount'); }); } - QueryBuilder stackParentIdProperty() { + QueryBuilder stackIdProperty() { return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'stackParentId'); + return query.addPropertyName(r'stackId'); + }); + } + + QueryBuilder stackPrimaryAssetIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'stackPrimaryAssetId'); }); } diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index a84f9800019c3..1dda2b9a12a03 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -234,6 +234,8 @@ enum StoreKey { primaryColor(128, type: String), dynamicTheme(129, type: bool), colorfulInterface(130, type: bool), + + syncAlbums(131, type: bool), ; const StoreKey( diff --git a/mobile/lib/models/backup/backup_candidate.model.dart b/mobile/lib/models/backup/backup_candidate.model.dart new file mode 100644 index 0000000000000..5ef15167455df --- /dev/null +++ b/mobile/lib/models/backup/backup_candidate.model.dart @@ -0,0 +1,19 @@ +import 'package:photo_manager/photo_manager.dart'; + +class BackupCandidate { + BackupCandidate({required this.asset, required this.albumNames}); + + AssetEntity asset; + List albumNames; + + @override + int get hashCode => asset.hashCode; + + @override + bool operator ==(Object other) { + if (other is! BackupCandidate) { + return false; + } + return asset == other.asset; + } +} diff --git a/mobile/lib/models/backup/backup_state.model.dart b/mobile/lib/models/backup/backup_state.model.dart index bb693a5b75f7a..d829f411fc355 100644 --- a/mobile/lib/models/backup/backup_state.model.dart +++ b/mobile/lib/models/backup/backup_state.model.dart @@ -2,7 +2,7 @@ import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -41,7 +41,7 @@ class BackUpState { final Set excludedBackupAlbums; /// Assets that are not overlapping in selected backup albums and excluded backup albums - final Set allUniqueAssets; + final Set allUniqueAssets; /// All assets from the selected albums that have been backup final Set selectedAlbumsBackupAssetsIds; @@ -94,7 +94,7 @@ class BackUpState { List? availableAlbums, Set? selectedBackupAlbums, Set? excludedBackupAlbums, - Set? allUniqueAssets, + Set? allUniqueAssets, Set? selectedAlbumsBackupAssetsIds, CurrentUploadAsset? currentUploadAsset, }) { diff --git a/mobile/lib/models/backup/success_upload_asset.model.dart b/mobile/lib/models/backup/success_upload_asset.model.dart new file mode 100644 index 0000000000000..045715e8cbbda --- /dev/null +++ b/mobile/lib/models/backup/success_upload_asset.model.dart @@ -0,0 +1,42 @@ +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; + +class SuccessUploadAsset { + final BackupCandidate candidate; + final String remoteAssetId; + final bool isDuplicate; + + SuccessUploadAsset({ + required this.candidate, + required this.remoteAssetId, + required this.isDuplicate, + }); + + SuccessUploadAsset copyWith({ + BackupCandidate? candidate, + String? remoteAssetId, + bool? isDuplicate, + }) { + return SuccessUploadAsset( + candidate: candidate ?? this.candidate, + remoteAssetId: remoteAssetId ?? this.remoteAssetId, + isDuplicate: isDuplicate ?? this.isDuplicate, + ); + } + + @override + String toString() => + 'SuccessUploadAsset(asset: $candidate, remoteAssetId: $remoteAssetId, isDuplicate: $isDuplicate)'; + + @override + bool operator ==(covariant SuccessUploadAsset other) { + if (identical(this, other)) return true; + + return other.candidate == candidate && + other.remoteAssetId == remoteAssetId && + other.isDuplicate == isDuplicate; + } + + @override + int get hashCode => + candidate.hashCode ^ remoteAssetId.hashCode ^ isDuplicate.hashCode; +} diff --git a/mobile/lib/pages/backup/backup_album_selection.page.dart b/mobile/lib/pages/backup/backup_album_selection.page.dart index 9f3e387755e85..8dccece325d8f 100644 --- a/mobile/lib/pages/backup/backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/backup_album_selection.page.dart @@ -4,19 +4,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/backup/album_info_card.dart'; import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @RoutePage() class BackupAlbumSelectionPage extends HookConsumerWidget { const BackupAlbumSelectionPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - // final availableAlbums = ref.watch(backupProvider).availableAlbums; final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums; final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums; + final enableSyncUploadAlbum = + useAppSettingsState(AppSettingsEnum.syncAlbums); final isDarkTheme = context.isDarkTheme; final albums = ref.watch(backupProvider).availableAlbums; @@ -144,47 +149,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { }).toSet(); } - // buildSearchBar() { - // return Padding( - // padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0), - // child: TextFormField( - // onChanged: (searchValue) { - // // if (searchValue.isEmpty) { - // // albums = availableAlbums; - // // } else { - // // albums.value = availableAlbums - // // .where( - // // (album) => album.name - // // .toLowerCase() - // // .contains(searchValue.toLowerCase()), - // // ) - // // .toList(); - // // } - // }, - // decoration: InputDecoration( - // contentPadding: const EdgeInsets.symmetric( - // horizontal: 8.0, - // vertical: 8.0, - // ), - // hintText: "Search", - // hintStyle: TextStyle( - // color: isDarkTheme ? Colors.white : Colors.grey, - // fontSize: 14.0, - // ), - // prefixIcon: const Icon( - // Icons.search, - // color: Colors.grey, - // ), - // border: OutlineInputBorder( - // borderRadius: BorderRadius.circular(10), - // borderSide: BorderSide.none, - // ), - // filled: true, - // fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200], - // ), - // ), - // ); - // } + handleSyncAlbumToggle(bool isEnable) async { + if (isEnable) { + await ref.read(albumProvider.notifier).getAllAlbums(); + for (final album in selectedBackupAlbums) { + await ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } + } + } return Scaffold( appBar: AppBar( @@ -226,6 +198,20 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { ), ), + SettingsSwitchListTile( + valueNotifier: enableSyncUploadAlbum, + title: "sync_albums".tr(), + subtitle: "sync_upload_album_setting_subtitle".tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + titleStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.primary, + ), + onChanged: handleSyncAlbumToggle, + ), + ListTile( title: Text( "backup_album_selection_page_albums_device".tr( diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart index 51282d8dd6ad4..1fd860520d5c7 100644 --- a/mobile/lib/pages/common/create_album.page.dart +++ b/mobile/lib/pages/common/create_album.page.dart @@ -52,6 +52,7 @@ class CreateAlbumPage extends HookConsumerWidget { if (albumTitleController.text.isEmpty) { albumTitleController.text = 'create_album_page_untitled'.tr(); + isAlbumTitleEmpty.value = false; ref .watch(albumTitleProvider.notifier) .setAlbumTitle('create_album_page_untitled'.tr()); @@ -191,6 +192,7 @@ class CreateAlbumPage extends HookConsumerWidget { } createNonSharedAlbum() async { + onBackgroundTapped(); var newAlbum = await ref.watch(albumProvider.notifier).createAlbum( ref.watch(albumTitleProvider), selectedAssets.value, @@ -238,15 +240,16 @@ class CreateAlbumPage extends HookConsumerWidget { ), if (!isSharedAlbum) TextButton( - onPressed: albumTitleController.text.isNotEmpty && - selectedAssets.value.isNotEmpty + onPressed: albumTitleController.text.isNotEmpty ? createNonSharedAlbum : null, child: Text( 'create_shared_album_page_create'.tr(), style: TextStyle( fontWeight: FontWeight.bold, - color: context.primaryColor, + color: albumTitleController.text.isNotEmpty + ? context.primaryColor + : context.themeData.disabledColor, ), ), ), diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index cc62620dfb239..d8ea7cd89b47f 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -68,7 +68,7 @@ class GalleryViewerPage extends HookConsumerWidget { }); final stackIndex = useState(-1); - final stack = showStack && currentAsset.stackChildrenCount > 0 + final stack = showStack && currentAsset.stackCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) : []; final stackElements = showStack ? [currentAsset, ...stack] : []; diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index 8a21cdf76908f..729b59ded5911 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:crop_image/crop_image.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'edit.page.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:auto_route/auto_route.dart'; /// A widget for cropping an image. @@ -14,7 +17,8 @@ import 'package:auto_route/auto_route.dart'; @RoutePage() class CropImagePage extends HookWidget { final Image image; - const CropImagePage({super.key, required this.image}); + final Asset asset; + const CropImagePage({super.key, required this.image, required this.asset}); @override Widget build(BuildContext context) { @@ -23,29 +27,37 @@ class CropImagePage extends HookWidget { return Scaffold( appBar: AppBar( - backgroundColor: Theme.of(context).bottomAppBarTheme.color, - leading: CloseButton(color: Theme.of(context).iconTheme.color), + backgroundColor: context.scaffoldBackgroundColor, + title: Text("crop".tr()), + leading: CloseButton(color: context.primaryColor), actions: [ IconButton( icon: Icon( Icons.done_rounded, - color: Theme.of(context).iconTheme.color, + color: context.primaryColor, size: 24, ), onPressed: () async { final croppedImage = await cropController.croppedImage(); - context.pushRoute(EditImageRoute(image: croppedImage)); + context.pushRoute( + EditImageRoute( + asset: asset, + image: croppedImage, + isEdited: true, + ), + ); }, ), ], ), + backgroundColor: context.scaffoldBackgroundColor, body: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return Column( children: [ Container( padding: const EdgeInsets.only(top: 20), - width: double.infinity, + width: constraints.maxWidth * 0.9, height: constraints.maxHeight * 0.6, child: CropImage( controller: cropController, @@ -57,7 +69,7 @@ class CropImagePage extends HookWidget { child: Container( width: double.infinity, decoration: BoxDecoration( - color: Theme.of(context).bottomAppBarTheme.color, + color: context.scaffoldBackgroundColor, borderRadius: const BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), @@ -188,7 +200,7 @@ class _AspectRatioButton extends StatelessWidget { icon: Icon( iconData, color: aspectRatio.value == ratio - ? Colors.indigo + ? context.primaryColor : Theme.of(context).iconTheme.color, ), onPressed: () { @@ -197,7 +209,7 @@ class _AspectRatioButton extends StatelessWidget { cropController.aspectRatio = ratio; }, ), - Text(label, style: Theme.of(context).textTheme.bodyMedium), + Text(label, style: context.textTheme.displayMedium), ], ); } diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index 22fb345e0f706..c81e84877b208 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -7,12 +7,15 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:auto_route/auto_route.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:path/path.dart' as p; /// A stateless widget that provides functionality for editing an image. /// @@ -24,18 +27,16 @@ import 'package:immich_mobile/providers/album/album.provider.dart'; @immutable @RoutePage() class EditImagePage extends ConsumerWidget { - final Asset? asset; - final Image? image; + final Asset asset; + final Image image; + final bool isEdited; const EditImagePage({ super.key, - this.image, - this.asset, - }) : assert( - (image != null && asset == null) || (image == null && asset != null), - 'Must supply one of asset or image', - ); - + required this.asset, + required this.image, + required this.isEdited, + }); Future _imageToUint8List(Image image) async { final Completer completer = Completer(); image.image.resolve(const ImageConfiguration()).addListener( @@ -58,83 +59,102 @@ class EditImagePage extends ConsumerWidget { return completer.future; } + Future _saveEditedImage( + BuildContext context, + Asset asset, + Image image, + WidgetRef ref, + ) async { + try { + final Uint8List imageData = await _imageToUint8List(image); + await PhotoManager.editor.saveImage( + imageData, + title: "${p.withoutExtension(asset.fileName)}_edited.jpg", + ); + await ref.read(albumProvider.notifier).getDeviceAlbums(); + Navigator.of(context).popUntil((route) => route.isFirst); + ImmichToast.show( + durationInSecond: 3, + context: context, + msg: 'Image Saved!', + gravity: ToastGravity.CENTER, + ); + } catch (e) { + ImmichToast.show( + durationInSecond: 6, + context: context, + msg: "error_saving_image".tr(args: [e.toString()]), + gravity: ToastGravity.CENTER, + ); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { - final ImageProvider provider = (asset != null) - ? ImmichImage.imageProvider(asset: asset!) - : (image != null) - ? image!.image - : throw Exception('Invalid image source type'); - - final Image imageWidget = (asset != null) - ? Image(image: ImmichImage.imageProvider(asset: asset!)) - : (image != null) - ? image! - : throw Exception('Invalid image source type'); + final Image imageWidget = + Image(image: ImmichImage.imageProvider(asset: asset)); return Scaffold( appBar: AppBar( - backgroundColor: Theme.of(context).appBarTheme.backgroundColor, + title: Text("edit_image_title".tr()), + backgroundColor: context.scaffoldBackgroundColor, leading: IconButton( icon: Icon( Icons.close_rounded, - color: Theme.of(context).iconTheme.color, + color: context.primaryColor, size: 24, ), onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst), ), actions: [ - if (image != null) - TextButton( - onPressed: () async { - try { - final Uint8List imageData = await _imageToUint8List(image!); - ImmichToast.show( - durationInSecond: 3, - context: context, - msg: 'Image Saved!', - gravity: ToastGravity.CENTER, - ); - - await PhotoManager.editor.saveImage( - imageData, - title: '${asset!.fileName}_edited.jpg', - ); - await ref.read(albumProvider.notifier).getDeviceAlbums(); - Navigator.of(context).popUntil((route) => route.isFirst); - } catch (e) { - ImmichToast.show( - durationInSecond: 6, - context: context, - msg: 'Error: ${e.toString()}', - gravity: ToastGravity.BOTTOM, - ); - } - }, - child: Text( - 'Save to gallery', - style: Theme.of(context).textTheme.displayMedium, + TextButton( + onPressed: isEdited + ? () => _saveEditedImage(context, asset, image, ref) + : null, + child: Text( + "save_to_gallery".tr(), + style: TextStyle( + color: isEdited ? context.primaryColor : Colors.grey, ), ), + ), ], ), - body: Column( - children: [ - Expanded( - child: Image(image: provider), + backgroundColor: context.scaffoldBackgroundColor, + body: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + maxWidth: MediaQuery.of(context).size.width * 0.9, ), - Container( - height: 80, - color: Theme.of(context).bottomAppBarTheme.color, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + spreadRadius: 2, + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image( + image: image.image, + fit: BoxFit.contain, + ), + ), ), - ], + ), ), bottomNavigationBar: Container( - height: 80, - margin: const EdgeInsets.only(bottom: 20, right: 10, left: 10, top: 10), + height: 70, + margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10), decoration: BoxDecoration( - color: Theme.of(context).bottomAppBarTheme.color, + color: context.scaffoldBackgroundColor, borderRadius: BorderRadius.circular(30), ), child: Column( @@ -146,12 +166,15 @@ class EditImagePage extends ConsumerWidget { ? Icons.crop_rotate_rounded : Icons.crop_rotate_rounded, color: Theme.of(context).iconTheme.color, + size: 25, ), onPressed: () { - context.pushRoute(CropImageRoute(image: imageWidget)); + context.pushRoute( + CropImageRoute(asset: asset, image: imageWidget), + ); }, ), - Text('Crop', style: Theme.of(context).textTheme.displayMedium), + Text("crop".tr(), style: context.textTheme.displayMedium), ], ), ), diff --git a/mobile/lib/pages/login/login.page.dart b/mobile/lib/pages/login/login.page.dart index b305b5fc534d6..8045ae649fc9a 100644 --- a/mobile/lib/pages/login/login.page.dart +++ b/mobile/lib/pages/login/login.page.dart @@ -29,7 +29,7 @@ class LoginPage extends HookConsumerWidget { ); return Scaffold( - body: const LoginForm(), + body: LoginForm(), bottomNavigationBar: SafeArea( child: Padding( padding: const EdgeInsets.only(bottom: 16.0), diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index 8251d5e66bf33..ed9dc07f5e5c0 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -23,6 +23,7 @@ class AlbumNotifier extends StateNotifier> { }); _streamSub = query.watch().listen((data) => state = data); } + final AlbumService _albumService; late final StreamSubscription> _streamSub; @@ -41,6 +42,23 @@ class AlbumNotifier extends StateNotifier> { ) => _albumService.createAlbum(albumTitle, assets, []); + Future getAlbumByName(String albumName, {bool remoteOnly = false}) => + _albumService.getAlbumByName(albumName, remoteOnly); + + /// Create an album on the server with the same name as the selected album for backup + /// First this will check if the album already exists on the server with name + /// If it does not exist, it will create the album on the server + Future createSyncAlbum( + String albumName, + ) async { + final album = await getAlbumByName(albumName, remoteOnly: true); + if (album != null) { + return; + } + + await createAlbum(albumName, {}); + } + @override void dispose() { _streamSub.cancel(); diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index a0a3879db54f1..3c1a5ecc0119b 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -360,7 +360,7 @@ QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { .filter() .ownerIdEqualTo(userId) .isTrashedEqualTo(false) - .stackParentIdIsNull() + .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); } @@ -374,6 +374,6 @@ QueryBuilder _commonFilterAndSort( .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackParentIdIsNull() + .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); } diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart index 0883ed92dbc10..c3e4414b3935a 100644 --- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart @@ -48,7 +48,7 @@ final assetStackProvider = .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackParentIdEqualTo(asset.remoteId) + .stackPrimaryAssetIdEqualTo(asset.remoteId) .sortByFileCreatedAtDesc() .findAll(); }); diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart index 5846bb78cc3e8..b56e71b11b3f6 100644 --- a/mobile/lib/providers/authentication.provider.dart +++ b/mobile/lib/providers/authentication.provider.dart @@ -170,8 +170,10 @@ class AuthenticationNotifier extends StateNotifier { UserPreferencesResponseDto? userPreferences; try { final responses = await Future.wait([ - _apiService.usersApi.getMyUser(), - _apiService.usersApi.getMyPreferences(), + _apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)), + _apiService.usersApi + .getMyPreferences() + .timeout(const Duration(seconds: 7)), ]); userResponse = responses[0] as UserAdminResponseDto; userPreferences = responses[1] as UserPreferencesResponseDto; @@ -190,6 +192,9 @@ class AuthenticationNotifier extends StateNotifier { error, stackTrace, ); + debugPrint( + "Error getting user information from the server [CATCH ALL] $error $stackTrace", + ); } // If the user information is successfully retrieved, update the store diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 58027e3b941e0..02f1f07904f97 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -2,13 +2,16 @@ import 'dart:io'; import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; @@ -290,8 +293,8 @@ class BackupNotifier extends StateNotifier { /// Future _updateBackupAssetCount() async { final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); - final Set assetsFromSelectedAlbums = {}; - final Set assetsFromExcludedAlbums = {}; + final Set assetsFromSelectedAlbums = {}; + final Set assetsFromExcludedAlbums = {}; for (final album in state.selectedBackupAlbums) { final assetCount = await album.albumEntity.assetCountAsync; @@ -304,7 +307,27 @@ class BackupNotifier extends StateNotifier { start: 0, end: assetCount, ); - assetsFromSelectedAlbums.addAll(assets); + + // Add album's name to the asset info + for (final asset in assets) { + List albumNames = [album.name]; + + final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull( + (a) => a.asset.id == asset.id, + ); + + if (existingAsset != null) { + albumNames.addAll(existingAsset.albumNames); + assetsFromSelectedAlbums.remove(existingAsset); + } + + assetsFromSelectedAlbums.add( + BackupCandidate( + asset: asset, + albumNames: albumNames, + ), + ); + } } for (final album in state.excludedBackupAlbums) { @@ -318,11 +341,17 @@ class BackupNotifier extends StateNotifier { start: 0, end: assetCount, ); - assetsFromExcludedAlbums.addAll(assets); + + for (final asset in assets) { + assetsFromExcludedAlbums.add( + BackupCandidate(asset: asset, albumNames: [album.name]), + ); + } } - final Set allUniqueAssets = + final Set allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); + final allAssetsInDatabase = await _backupService.getDeviceBackupAsset(); if (allAssetsInDatabase == null) { @@ -331,14 +360,14 @@ class BackupNotifier extends StateNotifier { // Find asset that were backup from selected albums final Set selectedAlbumsBackupAssets = - Set.from(allUniqueAssets.map((e) => e.id)); + Set.from(allUniqueAssets.map((e) => e.asset.id)); selectedAlbumsBackupAssets .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId)); // Remove duplicated asset from all unique assets allUniqueAssets.removeWhere( - (asset) => duplicatedAssetIds.contains(asset.id), + (candidate) => duplicatedAssetIds.contains(candidate.asset.id), ); if (allUniqueAssets.isEmpty) { @@ -433,10 +462,10 @@ class BackupNotifier extends StateNotifier { return; } - Set assetsWillBeBackup = Set.from(state.allUniqueAssets); + Set assetsWillBeBackup = Set.from(state.allUniqueAssets); // Remove item that has already been backed up for (final assetId in state.allAssetsInDatabase) { - assetsWillBeBackup.removeWhere((e) => e.id == assetId); + assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId); } if (assetsWillBeBackup.isEmpty) { @@ -456,11 +485,11 @@ class BackupNotifier extends StateNotifier { await _backupService.backupAsset( assetsWillBeBackup, state.cancelToken, - pmProgressHandler, - _onAssetUploaded, - _onUploadProgress, - _onSetCurrentBackupAsset, - _onBackupError, + pmProgressHandler: pmProgressHandler, + onSuccess: _onAssetUploaded, + onProgress: _onUploadProgress, + onCurrentAsset: _onSetCurrentBackupAsset, + onError: _onBackupError, ); await notifyBackgroundServiceCanRun(); } else { @@ -497,34 +526,36 @@ class BackupNotifier extends StateNotifier { ); } - void _onAssetUploaded( - String deviceAssetId, - String deviceId, - bool isDuplicated, - ) { - if (isDuplicated) { + void _onAssetUploaded(SuccessUploadAsset result) async { + if (result.isDuplicate) { state = state.copyWith( allUniqueAssets: state.allUniqueAssets - .where((asset) => asset.id != deviceAssetId) + .where( + (candidate) => candidate.asset.id != result.candidate.asset.id, + ) .toSet(), ); } else { state = state.copyWith( selectedAlbumsBackupAssetsIds: { ...state.selectedAlbumsBackupAssetsIds, - deviceAssetId, + result.candidate.asset.id, }, - allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId], + allAssetsInDatabase: [ + ...state.allAssetsInDatabase, + result.candidate.asset.id, + ], ); } if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) { - final latestAssetBackup = - state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce( - (v, e) => e.isAfter(v) ? e : v, - ); + final latestAssetBackup = state.allUniqueAssets + .map((candidate) => candidate.asset.modifiedDateTime) + .reduce( + (v, e) => e.isAfter(v) ? e : v, + ); state = state.copyWith( selectedBackupAlbums: state.selectedBackupAlbums .map((e) => e.copyWith(lastBackup: latestAssetBackup)) diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index b446711226324..a76b56fea7f8a 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -6,6 +6,8 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -22,6 +24,7 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; +import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -31,6 +34,7 @@ final manualUploadProvider = return ManualUploadNotifier( ref.watch(localNotificationService), ref.watch(backupProvider.notifier), + ref.watch(backupServiceProvider), ref, ); }); @@ -39,11 +43,13 @@ class ManualUploadNotifier extends StateNotifier { final Logger _log = Logger("ManualUploadNotifier"); final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; + final BackupService _backupService; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, + this._backupService, this.ref, ) : super( ManualUploadState( @@ -115,11 +121,7 @@ class ManualUploadNotifier extends StateNotifier { } } - void _onAssetUploaded( - String deviceAssetId, - String deviceId, - bool isDuplicated, - ) { + void _onAssetUploaded(SuccessUploadAsset result) { state = state.copyWith(successfulUploads: state.successfulUploads + 1); _backupProvider.updateDiskInfo(); } @@ -209,9 +211,23 @@ class ManualUploadNotifier extends StateNotifier { ); } - Set allUploadAssets = allAssetsFromDevice.nonNulls.toSet(); + final selectedBackupAlbums = + _backupService.selectedAlbumsQuery().findAllSync(); + final excludedBackupAlbums = + _backupService.excludedAlbumsQuery().findAllSync(); - if (allUploadAssets.isEmpty) { + // Get candidates from selected albums and excluded albums + Set candidates = + await _backupService.buildUploadCandidates( + selectedBackupAlbums, + excludedBackupAlbums, + ); + + // Extrack candidate from allAssetsFromDevice.nonNulls + final uploadAssets = candidates + .where((e) => allAssetsFromDevice.nonNulls.contains(e.asset)); + + if (uploadAssets.isEmpty) { debugPrint("[_startUpload] No Assets to upload - Abort Process"); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); return false; @@ -221,7 +237,7 @@ class ManualUploadNotifier extends StateNotifier { progressInPercentage: 0, progressInFileSize: "0 B / 0 B", progressInFileSpeed: 0, - totalAssetsToUpload: allUploadAssets.length, + totalAssetsToUpload: uploadAssets.length, successfulUploads: 0, currentAssetIndex: 0, currentUploadAsset: CurrentUploadAsset( @@ -250,13 +266,13 @@ class ManualUploadNotifier extends StateNotifier { final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final bool ok = await ref.read(backupServiceProvider).backupAsset( - allUploadAssets, + uploadAssets, state.cancelToken, - pmProgressHandler, - _onAssetUploaded, - _onProgress, - _onSetCurrentBackupAsset, - _onAssetUploadError, + pmProgressHandler: pmProgressHandler, + onSuccess: _onAssetUploaded, + onProgress: _onProgress, + onCurrentAsset: _onSetCurrentBackupAsset, + onError: _onAssetUploadError, ); // Close detailed notification diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index a4259676c7a6d..90fc4cb0fe96c 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -613,12 +613,14 @@ class CropImageRoute extends PageRouteInfo { CropImageRoute({ Key? key, required Image image, + required Asset asset, List? children, }) : super( CropImageRoute.name, args: CropImageRouteArgs( key: key, image: image, + asset: asset, ), initialChildren: children, ); @@ -632,6 +634,7 @@ class CropImageRoute extends PageRouteInfo { return CropImagePage( key: args.key, image: args.image, + asset: args.asset, ); }, ); @@ -641,15 +644,18 @@ class CropImageRouteArgs { const CropImageRouteArgs({ this.key, required this.image, + required this.asset, }); final Key? key; final Image image; + final Asset asset; + @override String toString() { - return 'CropImageRouteArgs{key: $key, image: $image}'; + return 'CropImageRouteArgs{key: $key, image: $image, asset: $asset}'; } } @@ -658,15 +664,17 @@ class CropImageRouteArgs { class EditImageRoute extends PageRouteInfo { EditImageRoute({ Key? key, - Image? image, - Asset? asset, + required Asset asset, + required Image image, + required bool isEdited, List? children, }) : super( EditImageRoute.name, args: EditImageRouteArgs( key: key, - image: image, asset: asset, + image: image, + isEdited: isEdited, ), initialChildren: children, ); @@ -676,12 +684,12 @@ class EditImageRoute extends PageRouteInfo { static PageInfo page = PageInfo( name, builder: (data) { - final args = data.argsAs( - orElse: () => const EditImageRouteArgs()); + final args = data.argsAs(); return EditImagePage( key: args.key, - image: args.image, asset: args.asset, + image: args.image, + isEdited: args.isEdited, ); }, ); @@ -690,19 +698,22 @@ class EditImageRoute extends PageRouteInfo { class EditImageRouteArgs { const EditImageRouteArgs({ this.key, - this.image, - this.asset, + required this.asset, + required this.image, + required this.isEdited, }); final Key? key; - final Image? image; + final Asset asset; - final Asset? asset; + final Image image; + + final bool isEdited; @override String toString() { - return 'EditImageRouteArgs{key: $key, image: $image, asset: $asset}'; + return 'EditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}'; } } diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index c2494680c7da5..ef56f9bf6c12a 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -28,7 +27,6 @@ final albumServiceProvider = Provider( ref.watch(userServiceProvider), ref.watch(syncServiceProvider), ref.watch(dbProvider), - ref.watch(backupServiceProvider), ), ); @@ -37,7 +35,6 @@ class AlbumService { final UserService _userService; final SyncService _syncService; final Isar _db; - final BackupService _backupService; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); @@ -47,9 +44,15 @@ class AlbumService { this._userService, this._syncService, this._db, - this._backupService, ); + QueryBuilder + selectedAlbumsQuery() => + _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); + QueryBuilder + excludedAlbumsQuery() => + _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); + /// Checks all selected device albums for changes of albums and their assets /// Updates the local database and returns `true` if there were any changes Future refreshDeviceAlbums() async { @@ -63,9 +66,9 @@ class AlbumService { bool changes = false; try { final List excludedIds = - await _backupService.excludedAlbumsQuery().idProperty().findAll(); + await excludedAlbumsQuery().idProperty().findAll(); final List selectedIds = - await _backupService.selectedAlbumsQuery().idProperty().findAll(); + await selectedAlbumsQuery().idProperty().findAll(); if (selectedIds.isEmpty) { final numLocal = await _db.albums.where().localIdIsNotNull().count(); if (numLocal > 0) { @@ -441,4 +444,33 @@ class AlbumService { return false; } } + + Future getAlbumByName(String name, bool remoteOnly) async { + return _db.albums + .filter() + .optional(remoteOnly, (q) => q.localIdIsNull()) + .nameEqualTo(name) + .sharedEqualTo(false) + .findFirst(); + } + + /// + /// Add the uploaded asset to the selected albums + /// + Future syncUploadAlbums( + List albumNames, + List assetIds, + ) async { + for (final albumName in albumNames) { + Album? album = await getAlbumByName(albumName, true); + album ??= await createAlbum(albumName, []); + + if (album != null && album.remoteId != null) { + await _apiService.albumsApi.addAssetsToAlbum( + album.remoteId!, + BulkIdsDto(ids: assetIds), + ); + } + } + } } diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index c128a2c2fccf3..4a3cfb19a2a28 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -18,7 +18,7 @@ class ApiService implements Authentication { late AlbumsApi albumsApi; late AssetsApi assetsApi; late SearchApi searchApi; - late ServerInfoApi serverInfoApi; + late ServerApi serverInfoApi; late MapApi mapApi; late PartnersApi partnersApi; late PeopleApi peopleApi; @@ -29,6 +29,7 @@ class ApiService implements Authentication { late ActivitiesApi activitiesApi; late DownloadApi downloadApi; late TrashApi trashApi; + late StacksApi stacksApi; ApiService() { final endpoint = Store.tryGet(StoreKey.serverEndpoint); @@ -49,7 +50,7 @@ class ApiService implements Authentication { oAuthApi = OAuthApi(_apiClient); albumsApi = AlbumsApi(_apiClient); assetsApi = AssetsApi(_apiClient); - serverInfoApi = ServerInfoApi(_apiClient); + serverInfoApi = ServerApi(_apiClient); searchApi = SearchApi(_apiClient); mapApi = MapApi(_apiClient); partnersApi = PartnersApi(_apiClient); @@ -61,6 +62,7 @@ class ApiService implements Authentication { activitiesApi = ActivitiesApi(_apiClient); downloadApi = DownloadApi(_apiClient); trashApi = TrashApi(_apiClient); + stacksApi = StacksApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index bd254032159c0..8f773e1bb33a9 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -76,6 +76,7 @@ enum AppSettingsEnum { false, ), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), + syncAlbums(StoreKey.syncAlbums, null, false), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index d37133a63b9c7..17508cba5153e 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -2,15 +2,20 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:isar/isar.dart'; @@ -23,6 +28,8 @@ final assetServiceProvider = Provider( ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), + ref.watch(backupServiceProvider), + ref.watch(albumServiceProvider), ref.watch(dbProvider), ), ); @@ -31,6 +38,8 @@ class AssetService { final ApiService _apiService; final SyncService _syncService; final UserService _userService; + final BackupService _backupService; + final AlbumService _albumService; final log = Logger('AssetService'); final Isar _db; @@ -38,6 +47,8 @@ class AssetService { this._apiService, this._syncService, this._userService, + this._backupService, + this._albumService, this._db, ); @@ -284,4 +295,64 @@ class AssetService { return Future.value(null); } } + + Future syncUploadedAssetToAlbums() async { + try { + final [selectedAlbums, excludedAlbums] = await Future.wait([ + _backupService.selectedAlbumsQuery().findAll(), + _backupService.excludedAlbumsQuery().findAll(), + ]); + + final candidates = await _backupService.buildUploadCandidates( + selectedAlbums, + excludedAlbums, + useTimeFilter: false, + ); + + final duplicates = await _apiService.assetsApi.checkExistingAssets( + CheckExistingAssetsDto( + deviceAssetIds: candidates.map((c) => c.asset.id).toList(), + deviceId: Store.get(StoreKey.deviceId), + ), + ); + + if (duplicates != null) { + candidates + .removeWhere((c) => !duplicates.existingIds.contains(c.asset.id)); + } + + await refreshRemoteAssets(); + final remoteAssets = await _db.assets + .where() + .localIdIsNotNull() + .filter() + .remoteIdIsNotNull() + .findAll(); + + /// Map + Map> assetToAlbums = {}; + + for (BackupCandidate candidate in candidates) { + final asset = remoteAssets.firstWhereOrNull( + (a) => a.localId == candidate.asset.id, + ); + + if (asset != null) { + for (final albumName in candidate.albumNames) { + assetToAlbums.putIfAbsent(albumName, () => []).add(asset.remoteId!); + } + } + } + + // Upload assets to albums + for (final entry in assetToAlbums.entries) { + final albumName = entry.key; + final assetIds = entry.value; + + await _albumService.syncUploadAlbums([albumName], assetIds); + } + } catch (error, stack) { + log.severe("Error while syncing uploaded asset to albums", error, stack); + } + } } diff --git a/mobile/lib/services/asset_stack.service.dart b/mobile/lib/services/asset_stack.service.dart deleted file mode 100644 index 9eff495f3740f..0000000000000 --- a/mobile/lib/services/asset_stack.service.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:openapi/api.dart'; - -class AssetStackService { - AssetStackService(this._api); - - final ApiService _api; - - Future updateStack( - Asset parentAsset, { - List? childrenToAdd, - List? childrenToRemove, - }) async { - // Guard [local asset] - if (parentAsset.remoteId == null) { - return; - } - - try { - if (childrenToAdd != null) { - final toAdd = childrenToAdd - .where((e) => e.isRemote) - .map((e) => e.remoteId!) - .toList(); - - await _api.assetsApi.updateAssets( - AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId), - ); - } - - if (childrenToRemove != null) { - final toRemove = childrenToRemove - .where((e) => e.isRemote) - .map((e) => e.remoteId!) - .toList(); - await _api.assetsApi.updateAssets( - AssetBulkUpdateDto(ids: toRemove, removeParent: true), - ); - } - } catch (error) { - debugPrint("Error while updating stack children: ${error.toString()}"); - } - } - - Future updateStackParent(Asset oldParent, Asset newParent) async { - // Guard [local asset] - if (oldParent.remoteId == null || newParent.remoteId == null) { - return; - } - - try { - await _api.assetsApi.updateStackParent( - UpdateStackParentDto( - oldParentId: oldParent.remoteId!, - newParentId: newParent.remoteId!, - ), - ); - } catch (error) { - debugPrint("Error while updating stack parent: ${error.toString()}"); - } - } -} - -final assetStackServiceProvider = Provider( - (ref) => AssetStackService( - ref.watch(apiServiceProvider), - ), -); diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index ba8f5c01ed963..fc3feb174d582 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -10,6 +10,10 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/main.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -18,6 +22,9 @@ import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/partner.service.dart'; +import 'package:immich_mobile/services/sync.service.dart'; +import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; @@ -342,11 +349,20 @@ class BackgroundService { Future _onAssetsChanged() async { final Isar db = await loadDb(); + HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); apiService.setAccessToken(Store.get(StoreKey.accessToken)); AppSettingsService settingService = AppSettingsService(); - BackupService backupService = BackupService(apiService, db, settingService); AppSettingsService settingsService = AppSettingsService(); + PartnerService partnerService = PartnerService(apiService, db); + HashService hashService = HashService(db, this); + SyncService syncSerive = SyncService(db, hashService); + UserService userService = + UserService(apiService, db, syncSerive, partnerService); + AlbumService albumService = + AlbumService(apiService, userService, syncSerive, db); + BackupService backupService = + BackupService(apiService, db, settingService, albumService); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); @@ -416,7 +432,7 @@ class BackgroundService { return false; } - List toUpload = await backupService.buildUploadCandidates( + Set toUpload = await backupService.buildUploadCandidates( selectedAlbums, excludedAlbums, ); @@ -460,29 +476,47 @@ class BackgroundService { final bool ok = await backupService.backupAsset( toUpload, _cancellationToken!, - pmProgressHandler, - notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {}, - notifySingleProgress ? _onProgress : (sent, total) {}, - notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {}, - _onBackupError, - sortAssets: true, + pmProgressHandler: pmProgressHandler, + onSuccess: (result) => _onAssetUploaded( + result: result, + shouldNotify: notifyTotalProgress, + ), + onProgress: (bytes, totalBytes) => + _onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress), + onCurrentAsset: (asset) => + _onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress), + onError: _onBackupError, + isBackground: true, ); + if (!ok && !_cancellationToken!.isCancelled) { _showErrorNotification( title: "backup_background_service_error_title".tr(), content: "backup_background_service_backup_failed_message".tr(), ); } + return ok; } - void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) { + void _onAssetUploaded({ + required SuccessUploadAsset result, + bool shouldNotify = false, + }) async { + if (!shouldNotify) { + return; + } + _uploadedAssetsCount++; _throttledNotifiy(); } - void _onProgress(int sent, int total) { - _throttledDetailNotify(progress: sent, total: total); + void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) { + if (!shouldNotify) { + return; + } + + _throttledDetailNotify(progress: bytes, total: totalBytes); } void _updateDetailProgress(String? title, int progress, int total) { @@ -522,7 +556,14 @@ class BackgroundService { ); } - void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { + void _onSetCurrentBackupAsset( + CurrentUploadAsset currentUploadAsset, { + bool shouldNotify = false, + }) { + if (!shouldNotify) { + return; + } + _throttledDetailNotify.title = "backup_background_service_current_upload_notification" .tr(args: [currentUploadAsset.fileName]); diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 64d683dc2ae83..12edd14d609ca 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -9,11 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:isar/isar.dart'; @@ -28,6 +31,7 @@ final backupServiceProvider = Provider( ref.watch(apiServiceProvider), ref.watch(dbProvider), ref.watch(appSettingsServiceProvider), + ref.watch(albumServiceProvider), ), ); @@ -37,8 +41,14 @@ class BackupService { final Isar _db; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; + final AlbumService _albumService; - BackupService(this._apiService, this._db, this._appSetting); + BackupService( + this._apiService, + this._db, + this._appSetting, + this._albumService, + ); Future?> getDeviceBackupAsset() async { final String deviceId = Store.get(StoreKey.deviceId); @@ -70,10 +80,12 @@ class BackupService { _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); /// Returns all assets newer than the last successful backup per album - Future> buildUploadCandidates( + /// if `useTimeFilter` is set to true, all assets will be returned + Future> buildUploadCandidates( List selectedBackupAlbums, - List excludedBackupAlbums, - ) async { + List excludedBackupAlbums, { + bool useTimeFilter = true, + }) async { final filter = FilterOptionGroup( containsPathModified: true, orders: [const OrderOption(type: OrderOptionType.updateDate)], @@ -82,105 +94,156 @@ class BackupService { videoOption: const FilterOption(needTitle: true), ); final now = DateTime.now(); + final List selectedAlbums = - await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now); + await _loadAlbumsWithTimeFilter( + selectedBackupAlbums, + filter, + now, + useTimeFilter: useTimeFilter, + ); + if (selectedAlbums.every((e) => e == null)) { - return []; - } - final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll); - if (allIdx != -1) { - final List excludedAlbums = - await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now); - final List toAdd = await _fetchAssetsAndUpdateLastBackup( - selectedAlbums.slice(allIdx, allIdx + 1), - selectedBackupAlbums.slice(allIdx, allIdx + 1), - now, - ); - final List toRemove = await _fetchAssetsAndUpdateLastBackup( - excludedAlbums, - excludedBackupAlbums, - now, - ); - return toAdd.toSet().difference(toRemove.toSet()).toList(); - } else { - return await _fetchAssetsAndUpdateLastBackup( - selectedAlbums, - selectedBackupAlbums, - now, - ); + return {}; } + + final List excludedAlbums = + await _loadAlbumsWithTimeFilter( + excludedBackupAlbums, + filter, + now, + useTimeFilter: useTimeFilter, + ); + + final Set toAdd = await _fetchAssetsAndUpdateLastBackup( + selectedAlbums, + selectedBackupAlbums, + now, + useTimeFilter: useTimeFilter, + ); + + final Set toRemove = await _fetchAssetsAndUpdateLastBackup( + excludedAlbums, + excludedBackupAlbums, + now, + useTimeFilter: useTimeFilter, + ); + + return toAdd.difference(toRemove); } Future> _loadAlbumsWithTimeFilter( List albums, FilterOptionGroup filter, - DateTime now, - ) async { + DateTime now, { + bool useTimeFilter = true, + }) async { List result = []; - for (BackupAlbum a in albums) { + for (BackupAlbum backupAlbum in albums) { try { + final optionGroup = useTimeFilter + ? filter.copyWith( + updateTimeCond: DateTimeCond( + // subtract 2 seconds to prevent missing assets due to rounding issues + min: backupAlbum.lastBackup + .subtract(const Duration(seconds: 2)), + max: now, + ), + ) + : filter; + final AssetPathEntity album = await AssetPathEntity.obtainPathFromProperties( - id: a.id, - optionGroup: filter.copyWith( - updateTimeCond: DateTimeCond( - // subtract 2 seconds to prevent missing assets due to rounding issues - min: a.lastBackup.subtract(const Duration(seconds: 2)), - max: now, - ), - ), + id: backupAlbum.id, + optionGroup: optionGroup, maxDateTimeToNow: false, ); + result.add(album); } on StateError { // either there are no assets matching the filter criteria OR the album no longer exists } } + return result; } - Future> _fetchAssetsAndUpdateLastBackup( - List albums, + Future> _fetchAssetsAndUpdateLastBackup( + List localAlbums, List backupAlbums, - DateTime now, - ) async { - List result = []; - for (int i = 0; i < albums.length; i++) { - final AssetPathEntity? a = albums[i]; - if (a != null && - a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) { - result.addAll( - await a.getAssetListRange(start: 0, end: await a.assetCountAsync), - ); - backupAlbums[i].lastBackup = now; + DateTime now, { + bool useTimeFilter = true, + }) async { + Set candidate = {}; + + for (int i = 0; i < localAlbums.length; i++) { + final localAlbum = localAlbums[i]; + if (localAlbum == null) { + continue; } + + if (useTimeFilter && + localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) == + true) { + continue; + } + + final assets = await localAlbum.getAssetListRange( + start: 0, + end: await localAlbum.assetCountAsync, + ); + + // Add album's name to the asset info + for (final asset in assets) { + List albumNames = [localAlbum.name]; + + final existingAsset = candidate.firstWhereOrNull( + (a) => a.asset.id == asset.id, + ); + + if (existingAsset != null) { + albumNames.addAll(existingAsset.albumNames); + candidate.remove(existingAsset); + } + + candidate.add( + BackupCandidate( + asset: asset, + albumNames: albumNames, + ), + ); + } + + backupAlbums[i].lastBackup = now; } - return result; + + return candidate; } /// Returns a new list of assets not yet uploaded - Future> removeAlreadyUploadedAssets( - List candidates, + Future> removeAlreadyUploadedAssets( + Set candidates, ) async { if (candidates.isEmpty) { return candidates; } + final Set duplicatedAssetIds = await getDuplicatedAssetIds(); - candidates = duplicatedAssetIds.isEmpty - ? candidates - : candidates - .whereNot((asset) => duplicatedAssetIds.contains(asset.id)) - .toList(); + candidates.removeWhere( + (candidate) => duplicatedAssetIds.contains(candidate.asset.id), + ); + if (candidates.isEmpty) { return candidates; } + final Set existing = {}; try { final String deviceId = Store.get(StoreKey.deviceId); final CheckExistingAssetsResponseDto? duplicates = await _apiService.assetsApi.checkExistingAssets( CheckExistingAssetsDto( - deviceAssetIds: candidates.map((e) => e.id).toList(), + deviceAssetIds: candidates.map((c) => c.asset.id).toList(), deviceId: deviceId, ), ); @@ -194,55 +257,75 @@ class BackupService { existing.addAll(allAssetsInDatabase); } } - return existing.isEmpty - ? candidates - : candidates.whereNot((e) => existing.contains(e.id)).toList(); + + if (existing.isNotEmpty) { + candidates.removeWhere((c) => existing.contains(c.asset.id)); + } + + return candidates; } - Future backupAsset( - Iterable assetList, - http.CancellationToken cancelToken, - PMProgressHandler? pmProgressHandler, - Function(String, String, bool) uploadSuccessCb, - Function(int, int) uploadProgressCb, - Function(CurrentUploadAsset) setCurrentUploadAssetCb, - Function(ErrorUploadAsset) errorCb, { - bool sortAssets = false, - }) async { - final bool isIgnoreIcloudAssets = - _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets); - + Future _checkPermissions() async { if (Platform.isAndroid && !(await pm.Permission.accessMediaLocation.status).isGranted) { // double check that permission is granted here, to guard against // uploading corrupt assets without EXIF information _log.warning("Media location permission is not granted. " "Cannot access original assets for backup."); + return false; } - final String deviceId = Store.get(StoreKey.deviceId); - final String savedEndpoint = Store.get(StoreKey.serverEndpoint); - bool anyErrors = false; - final List duplicatedAssetIds = []; // DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS if (Platform.isIOS) { await PhotoManager.requestPermissionExtend(); } - List assetsToUpload = sortAssets - // Upload images before video assets - // these are further sorted by using their creation date - ? assetList.sorted( - (a, b) { - final cmp = a.typeInt - b.typeInt; - if (cmp != 0) return cmp; - return a.createDateTime.compareTo(b.createDateTime); - }, - ) - : assetList.toList(); + return true; + } - for (var entity in assetsToUpload) { + /// Upload images before video assets for background tasks + /// these are further sorted by using their creation date + List _sortPhotosFirst(List candidates) { + return candidates.sorted( + (a, b) { + final cmp = a.asset.typeInt - b.asset.typeInt; + if (cmp != 0) return cmp; + return a.asset.createDateTime.compareTo(b.asset.createDateTime); + }, + ); + } + + Future backupAsset( + Iterable assets, + http.CancellationToken cancelToken, { + bool isBackground = false, + PMProgressHandler? pmProgressHandler, + required void Function(SuccessUploadAsset result) onSuccess, + required void Function(int bytes, int totalBytes) onProgress, + required void Function(CurrentUploadAsset asset) onCurrentAsset, + required void Function(ErrorUploadAsset error) onError, + }) async { + final bool isIgnoreIcloudAssets = + _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets); + final shouldSyncAlbums = _appSetting.getSetting(AppSettingsEnum.syncAlbums); + final String deviceId = Store.get(StoreKey.deviceId); + final String savedEndpoint = Store.get(StoreKey.serverEndpoint); + final List duplicatedAssetIds = []; + bool anyErrors = false; + + final hasPermission = await _checkPermissions(); + if (!hasPermission) { + return false; + } + + List candidates = assets.toList(); + if (isBackground) { + candidates = _sortPhotosFirst(candidates); + } + + for (final candidate in candidates) { + final AssetEntity entity = candidate.asset; File? file; File? livePhotoFile; @@ -257,7 +340,7 @@ class BackupService { continue; } - setCurrentUploadAssetCb( + onCurrentAsset( CurrentUploadAsset( id: entity.id, fileCreatedAt: entity.createDateTime.year == 1970 @@ -299,23 +382,22 @@ class BackupService { } } - var fileStream = file.openRead(); - var assetRawUploadData = http.MultipartFile( + final fileStream = file.openRead(); + final assetRawUploadData = http.MultipartFile( "assetData", fileStream, file.lengthSync(), filename: originalFileName, ); - var baseRequest = MultipartRequest( + final baseRequest = MultipartRequest( 'POST', Uri.parse('$savedEndpoint/assets'), - onProgress: ((bytes, totalBytes) => - uploadProgressCb(bytes, totalBytes)), + onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), ); + baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.headers["Transfer-Encoding"] = "chunked"; - baseRequest.fields['deviceAssetId'] = entity.id; baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['fileCreatedAt'] = @@ -324,12 +406,9 @@ class BackupService { entity.modifiedDateTime.toUtc().toIso8601String(); baseRequest.fields['isFavorite'] = entity.isFavorite.toString(); baseRequest.fields['duration'] = entity.videoDuration.toString(); - baseRequest.files.add(assetRawUploadData); - var fileSize = file.lengthSync(); - - setCurrentUploadAssetCb( + onCurrentAsset( CurrentUploadAsset( id: entity.id, fileCreatedAt: entity.createDateTime.year == 1970 @@ -337,7 +416,7 @@ class BackupService { : entity.createDateTime, fileName: originalFileName, fileType: _getAssetType(entity.type), - fileSize: fileSize, + fileSize: file.lengthSync(), iCloudAsset: false, ), ); @@ -356,22 +435,23 @@ class BackupService { baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - var response = await httpClient.send( + final response = await httpClient.send( baseRequest, cancellationToken: cancelToken, ); - var responseBody = jsonDecode(await response.stream.bytesToString()); + final responseBody = + jsonDecode(await response.stream.bytesToString()); if (![200, 201].contains(response.statusCode)) { - var error = responseBody; - var errorMessage = error['message'] ?? error['error']; + final error = responseBody; + final errorMessage = error['message'] ?? error['error']; debugPrint( "Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}", ); - errorCb( + onError( ErrorUploadAsset( asset: entity, id: entity.id, @@ -386,23 +466,37 @@ class BackupService { anyErrors = true; break; } + continue; } - var isDuplicate = false; + bool isDuplicate = false; if (response.statusCode == 200) { isDuplicate = true; duplicatedAssetIds.add(entity.id); } - uploadSuccessCb(entity.id, deviceId, isDuplicate); + onSuccess( + SuccessUploadAsset( + candidate: candidate, + remoteAssetId: responseBody['id'] as String, + isDuplicate: isDuplicate, + ), + ); + + if (shouldSyncAlbums && !isDuplicate) { + await _albumService.syncUploadAlbums( + candidate.albumNames, + [responseBody['id'] as String], + ); + } } } on http.CancelledException { debugPrint("Backup was cancelled by the user"); anyErrors = true; break; - } catch (e) { - debugPrint("ERROR backupAsset: ${e.toString()}"); + } catch (error, stackTrace) { + debugPrint("Error backup asset: ${error.toString()}: $stackTrace"); anyErrors = true; continue; } finally { @@ -416,9 +510,11 @@ class BackupService { } } } + if (duplicatedAssetIds.isNotEmpty) { await _saveDuplicatedAssetIds(duplicatedAssetIds); } + return !anyErrors; } diff --git a/mobile/lib/services/oauth.service.dart b/mobile/lib/services/oauth.service.dart index 807c88db8de50..30e6448d7f8a9 100644 --- a/mobile/lib/services/oauth.service.dart +++ b/mobile/lib/services/oauth.service.dart @@ -3,7 +3,7 @@ import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:flutter_web_auth/flutter_web_auth.dart'; -// Redirect URL = app.immich:// +// Redirect URL = app.immich:///oauth-callback class OAuthService { final ApiService _apiService; @@ -16,28 +16,40 @@ class OAuthService { ) async { // Resolve API server endpoint from user provided serverUrl await _apiService.resolveAndSetEndpoint(serverUrl); + final redirectUri = '$callbackUrlScheme:///oauth-callback'; + log.info( + "Starting OAuth flow with redirect URI: $redirectUri", + ); final dto = await _apiService.oAuthApi.startOAuth( - OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'), + OAuthConfigDto(redirectUri: redirectUri), ); - return dto?.url; + + final authUrl = dto?.url; + log.info('Received Authorization URL: $authUrl'); + + return authUrl; } Future oAuthLogin(String oauthUrl) async { - try { - var result = await FlutterWebAuth.authenticate( - url: oauthUrl, - callbackUrlScheme: callbackUrlScheme, - ); + String result = await FlutterWebAuth.authenticate( + url: oauthUrl, + callbackUrlScheme: callbackUrlScheme, + ); - return await _apiService.oAuthApi.finishOAuth( - OAuthCallbackDto( - url: result, - ), + log.info('Received OAuth callback: $result'); + + if (result.startsWith('app.immich:/oauth-callback')) { + result = result.replaceAll( + 'app.immich:/oauth-callback', + 'app.immich:///oauth-callback', ); - } catch (e, stack) { - log.severe("OAuth login failed", e, stack); - return null; } + + return await _apiService.oAuthApi.finishOAuth( + OAuthCallbackDto( + url: result, + ), + ); } } diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart new file mode 100644 index 0000000000000..75074101c2ff8 --- /dev/null +++ b/mobile/lib/services/stack.service.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:isar/isar.dart'; +import 'package:openapi/api.dart'; + +class StackService { + StackService(this._api, this._db); + + final ApiService _api; + final Isar _db; + + Future getStack(String stackId) async { + try { + return _api.stacksApi.getStack(stackId); + } catch (error) { + debugPrint("Error while fetching stack: $error"); + } + return null; + } + + Future createStack(List assetIds) async { + try { + return _api.stacksApi.createStack( + StackCreateDto(assetIds: assetIds), + ); + } catch (error) { + debugPrint("Error while creating stack: $error"); + } + return null; + } + + Future updateStack( + String stackId, + String primaryAssetId, + ) async { + try { + return await _api.stacksApi.updateStack( + stackId, + StackUpdateDto(primaryAssetId: primaryAssetId), + ); + } catch (error) { + debugPrint("Error while updating stack children: $error"); + } + return null; + } + + Future deleteStack(String stackId, List assets) async { + try { + await _api.stacksApi.deleteStack(stackId); + + // Update local database to trigger rerendering + final List removeAssets = []; + for (final asset in assets) { + asset.stackId = null; + asset.stackPrimaryAssetId = null; + asset.stackCount = 0; + + removeAssets.add(asset); + } + + _db.writeTxn(() async { + await _db.assets.putAll(removeAssets); + }); + } catch (error) { + debugPrint("Error while deleting stack: $error"); + } + } +} + +final stackServiceProvider = Provider( + (ref) => StackService( + ref.watch(apiServiceProvider), + ref.watch(dbProvider), + ), +); diff --git a/mobile/lib/utils/hooks/crop_controller_hook.dart b/mobile/lib/utils/hooks/crop_controller_hook.dart index b03d9ccdb0917..04bc9787548eb 100644 --- a/mobile/lib/utils/hooks/crop_controller_hook.dart +++ b/mobile/lib/utils/hooks/crop_controller_hook.dart @@ -6,7 +6,7 @@ import 'dart:ui'; // Import the dart:ui library for Rect CropController useCropController() { return useMemoized( () => CropController( - defaultCrop: const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9), + defaultCrop: const Rect.fromLTRB(0, 0, 1, 1), ), ); } diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 7b27f59aee8c3..349b2322afac1 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -4,9 +4,30 @@ dynamic upgradeDto(dynamic value, String targetType) { switch (targetType) { case 'UserPreferencesResponseDto': if (value is Map) { - if (value['rating'] == null) { - value['rating'] = RatingResponse().toJson(); - } + addDefault(value, 'download.includeEmbeddedVideos', false); + addDefault(value, 'folders', FoldersResponse().toJson()); + addDefault(value, 'memories', MemoriesResponse().toJson()); + addDefault(value, 'ratings', RatingsResponse().toJson()); + addDefault(value, 'people', PeopleResponse().toJson()); + addDefault(value, 'tags', TagsResponse().toJson()); } + break; + } +} + +addDefault(dynamic value, String keys, dynamic defaultValue) { + // Loop through the keys and assign the default value if the key is not present + List keyList = keys.split('.'); + dynamic current = value; + + for (int i = 0; i < keyList.length - 1; i++) { + if (current[keyList[i]] == null) { + current[keyList[i]] = {}; + } + current = current[keyList[i]]; + } + + if (current[keyList.last] == null) { + current[keyList.last] = defaultValue; } } diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index e50a9a5ece6e3..3263373554df2 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/services/asset_stack.service.dart'; +import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; @@ -344,11 +344,9 @@ class MultiselectGrid extends HookConsumerWidget { if (!selectionEnabledHook.value || selection.value.length < 2) { return; } - final parent = selection.value.elementAt(0); - selection.value.remove(parent); - await ref.read(assetStackServiceProvider).updateStack( - parent, - childrenToAdd: selection.value.toList(), + + await ref.read(stackServiceProvider).createStack( + selection.value.map((e) => e.remoteId!).toList(), ); } finally { processing.value = false; diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index 2480f44278bb1..8e818f64fb7cc 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -107,16 +107,16 @@ class ThumbnailImage extends ConsumerWidget { right: 8, child: Row( children: [ - if (asset.stackChildrenCount > 1) + if (asset.stackCount > 1) Text( - "${asset.stackChildrenCount}", + "${asset.stackCount}", style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), - if (asset.stackChildrenCount > 1) + if (asset.stackCount > 1) const SizedBox( width: 3, ), @@ -208,7 +208,7 @@ class ThumbnailImage extends ConsumerWidget { ), ), if (!asset.isImage) buildVideoIcon(), - if (asset.stackChildrenCount > 0) buildStackIcon(), + if (asset.stackCount > 0) buildStackIcon(), ], ); } diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index fb70ac309ed7f..7e6136c256192 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -11,11 +11,12 @@ import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/services/asset_stack.service.dart'; +import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -49,11 +50,10 @@ class BottomGalleryBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; - final stack = showStack && asset.stackChildrenCount > 0 + final stackItems = showStack && asset.stackCount > 0 ? ref.watch(assetStackStateProvider(asset)) : []; - final stackElements = showStack ? [asset, ...stack] : []; - bool isParent = stackIndex == -1 || stackIndex == 0; + bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null; final navStack = AutoRouter.of(context).stackData; final isTrashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); @@ -76,7 +76,7 @@ class BottomGalleryBar extends ConsumerWidget { {asset}, force: force, ); - if (isDeleted && isParent) { + if (isDeleted && isStackPrimaryAsset) { // Workaround for asset remaining in the gallery renderList.deleteAsset(asset); @@ -98,7 +98,7 @@ class BottomGalleryBar extends ConsumerWidget { final isDeleted = await onDelete(false); if (isDeleted) { // Can only trash assets stored in server. Local assets are always permanently removed for now - if (context.mounted && asset.isRemote && isParent) { + if (context.mounted && asset.isRemote && isStackPrimaryAsset) { ImmichToast.show( durationInSecond: 1, context: context, @@ -127,6 +127,16 @@ class BottomGalleryBar extends ConsumerWidget { ); } + unStack() async { + if (asset.stackId == null) { + return; + } + + await ref + .read(stackServiceProvider) + .deleteStack(asset.stackId!, [asset, ...stackItems]); + } + void showStackActionItems() { showModalBottomSheet( context: context, @@ -138,74 +148,13 @@ class BottomGalleryBar extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (!isParent) - ListTile( - leading: const Icon( - Icons.bookmark_border_outlined, - size: 24, - ), - onTap: () async { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - asset, - stackElements.elementAt(stackIndex), - ); - ctx.pop(); - context.maybePop(); - }, - title: const Text( - "viewer_stack_use_as_main_asset", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ListTile( - leading: const Icon( - Icons.copy_all_outlined, - size: 24, - ), - onTap: () async { - if (isParent) { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - asset, - stackElements - .elementAt(1), // Next asset as parent - ); - // Remove itself from stack - await ref.read(assetStackServiceProvider).updateStack( - stackElements.elementAt(1), - childrenToRemove: [asset], - ); - ctx.pop(); - context.maybePop(); - } else { - await ref.read(assetStackServiceProvider).updateStack( - asset, - childrenToRemove: [ - stackElements.elementAt(stackIndex), - ], - ); - removeAssetFromStack(); - ctx.pop(); - } - }, - title: const Text( - "viewer_remove_from_stack", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), ListTile( leading: const Icon( Icons.filter_none_outlined, size: 18, ), onTap: () async { - await ref.read(assetStackServiceProvider).updateStack( - asset, - childrenToRemove: stack, - ); + await unStack(); ctx.pop(); context.maybePop(); }, @@ -236,6 +185,7 @@ class BottomGalleryBar extends ConsumerWidget { } void handleEdit() async { + final image = Image(image: ImmichImage.imageProvider(asset: asset)); if (asset.isOffline) { ImmichToast.show( durationInSecond: 1, @@ -247,15 +197,18 @@ class BottomGalleryBar extends ConsumerWidget { } Navigator.of(context).push( MaterialPageRoute( - builder: (context) => - EditImagePage(asset: asset), // Send the Asset object + builder: (context) => EditImagePage( + asset: asset, + image: image, + isEdited: false, + ), ), ); } handleArchive() { ref.read(assetProvider.notifier).toggleArchive([asset]); - if (isParent) { + if (isStackPrimaryAsset) { context.maybePop(); return; } @@ -346,7 +299,7 @@ class BottomGalleryBar extends ConsumerWidget { tooltip: 'control_bottom_app_bar_archive'.tr(), ): (_) => handleArchive(), }, - if (isOwner && stack.isNotEmpty) + if (isOwner && asset.stackCount > 0) { BottomNavigationBarItem( icon: const Icon(Icons.burst_mode_outlined), diff --git a/mobile/lib/widgets/backup/album_info_card.dart b/mobile/lib/widgets/backup/album_info_card.dart index e9349bd69eccf..0c9cd2d89d33c 100644 --- a/mobile/lib/widgets/backup/album_info_card.dart +++ b/mobile/lib/widgets/backup/album_info_card.dart @@ -5,15 +5,21 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumInfoCard extends HookConsumerWidget { final AvailableAlbum album; - const AlbumInfoCard({super.key, required this.album}); + const AlbumInfoCard({ + super.key, + required this.album, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -21,6 +27,9 @@ class AlbumInfoCard extends HookConsumerWidget { ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album); + final syncAlbum = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); final isDarkTheme = context.isDarkTheme; @@ -85,6 +94,9 @@ class AlbumInfoCard extends HookConsumerWidget { ref.read(backupProvider.notifier).removeAlbumForBackup(album); } else { ref.read(backupProvider.notifier).addAlbumForBackup(album); + if (syncAlbum) { + ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } } }, onDoubleTap: () { diff --git a/mobile/lib/widgets/backup/album_info_list_tile.dart b/mobile/lib/widgets/backup/album_info_list_tile.dart index 7cdc595c7fc53..d326bad3e0fc7 100644 --- a/mobile/lib/widgets/backup/album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/album_info_list_tile.dart @@ -5,9 +5,12 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumInfoListTile extends HookConsumerWidget { @@ -21,7 +24,10 @@ class AlbumInfoListTile extends HookConsumerWidget { ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album); - var assetCount = useState(0); + final assetCount = useState(0); + final syncAlbum = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); useEffect( () { @@ -98,6 +104,9 @@ class AlbumInfoListTile extends HookConsumerWidget { ref.read(backupProvider.notifier).removeAlbumForBackup(album); } else { ref.read(backupProvider.notifier).addAlbumForBackup(album); + if (syncAlbum) { + ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } } }, leading: buildIcon(), diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 4384879fce6a0..14a4e89dd6066 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -27,12 +27,15 @@ import 'package:immich_mobile/widgets/forms/login/login_button.dart'; import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart'; import 'package:immich_mobile/widgets/forms/login/password_input.dart'; import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart'; +import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:permission_handler/permission_handler.dart'; class LoginForm extends HookConsumerWidget { - const LoginForm({super.key}); + LoginForm({super.key}); + + final log = Logger('LoginForm'); @override Widget build(BuildContext context, WidgetRef ref) { @@ -229,7 +232,9 @@ class LoginForm extends HookConsumerWidget { .getOAuthServerUrl(sanitizeUrl(serverEndpointController.text)); isLoading.value = true; - } catch (e) { + } catch (error, stack) { + log.severe('Error getting OAuth server Url: $error', stack); + ImmichToast.show( context: context, msg: "login_form_failed_get_oauth_server_config".tr(), @@ -241,10 +246,19 @@ class LoginForm extends HookConsumerWidget { } if (oAuthServerUrl != null) { - var loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl); + try { + final loginResponseDto = + await oAuthService.oAuthLogin(oAuthServerUrl); - if (loginResponseDto != null) { - var isSuccess = await ref + if (loginResponseDto == null) { + return; + } + + log.info( + "Finished OAuth login with response: ${loginResponseDto.userEmail}", + ); + + final isSuccess = await ref .watch(authenticationProvider.notifier) .setSuccessLoginInfo( accessToken: loginResponseDto.accessToken, @@ -258,17 +272,19 @@ class LoginForm extends HookConsumerWidget { ref.watch(backupProvider.notifier).resumeBackup(); } context.replaceRoute(const TabControllerRoute()); - } else { - ImmichToast.show( - context: context, - msg: "login_form_failed_login".tr(), - toastType: ToastType.error, - gravity: ToastGravity.TOP, - ); } - } + } catch (error, stack) { + log.severe('Error logging in with OAuth: $error', stack); - isLoading.value = false; + ImmichToast.show( + context: context, + msg: error.toString(), + toastType: ToastType.error, + gravity: ToastGravity.TOP, + ); + } finally { + isLoading.value = false; + } } else { ImmichToast.show( context: context, diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index 4d4fa8c4e0d54..41e9cc628e71a 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -1,6 +1,8 @@ import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -9,6 +11,7 @@ import 'package:immich_mobile/widgets/common/immich_image.dart'; class MemoryLane extends HookConsumerWidget { const MemoryLane({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { final memoryLaneFutureProvider = ref.watch(memoryFutureProvider); @@ -16,82 +19,35 @@ class MemoryLane extends HookConsumerWidget { final memoryLane = memoryLaneFutureProvider .whenData( (memories) => memories != null - ? SizedBox( - height: 200, - child: ListView.builder( - scrollDirection: Axis.horizontal, - shrinkWrap: true, - itemCount: memories.length, - padding: const EdgeInsets.only( - right: 8.0, - bottom: 8, - top: 10, - left: 10, + ? ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 200, + ), + child: CarouselView( + itemExtent: 145.0, + shrinkExtent: 1.0, + elevation: 2, + backgroundColor: Colors.black, + overlayColor: WidgetStateProperty.all( + Colors.white.withOpacity(0.1), ), - itemBuilder: (context, index) { - final memory = memories[index]; - - return GestureDetector( - onTap: () { - ref - .read(hapticFeedbackProvider.notifier) - .heavyImpact(); - context.pushRoute( - MemoryRoute( - memories: memories, - memoryIndex: index, - ), - ); - }, - child: Stack( - children: [ - Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(13.0), - ), - clipBehavior: Clip.hardEdge, - child: ColorFiltered( - colorFilter: ColorFilter.mode( - Colors.black.withOpacity(0.2), - BlendMode.darken, - ), - child: Hero( - tag: 'memory-${memory.assets[0].id}', - child: ImmichImage( - memory.assets[0], - fit: BoxFit.cover, - width: 130, - height: 200, - placeholder: const ThumbnailPlaceholder( - width: 130, - height: 200, - ), - ), - ), - ), - ), - Positioned( - bottom: 16, - left: 16, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 114, - ), - child: Text( - memory.title, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: Colors.white, - fontSize: 15, - ), - ), - ), - ), - ], + onTap: (memoryIndex) { + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + context.pushRoute( + MemoryRoute( + memories: memories, + memoryIndex: memoryIndex, ), ); }, + children: memories + .mapIndexed( + (index, memory) => MemoryCard( + index: index, + memory: memory, + ), + ) + .toList(), ), ) : const SizedBox(), @@ -101,3 +57,60 @@ class MemoryLane extends HookConsumerWidget { return memoryLane ?? const SizedBox(); } } + +class MemoryCard extends ConsumerWidget { + const MemoryCard({ + super.key, + required this.index, + required this.memory, + }); + + final int index; + final Memory memory; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Center( + child: Stack( + children: [ + ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.2), + BlendMode.darken, + ), + child: Hero( + tag: 'memory-${memory.assets[0].id}', + child: ImmichImage( + memory.assets[0], + fit: BoxFit.cover, + width: 205, + height: 200, + placeholder: const ThumbnailPlaceholder( + width: 105, + height: 200, + ), + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 114, + ), + child: Text( + memory.title, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white, + fontSize: 15, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/search/search_filter/camera_picker.dart b/mobile/lib/widgets/search/search_filter/camera_picker.dart index 2e5618c9e03bf..e2110c9c295f1 100644 --- a/mobile/lib/widgets/search/search_filter/camera_picker.dart +++ b/mobile/lib/widgets/search/search_filter/camera_picker.dart @@ -51,10 +51,14 @@ class CameraPicker extends HookConsumerWidget { controller: makeTextController, leadingIcon: const Icon(Icons.photo_camera_rounded), onSelected: (value) { + if (value.toString() == selectedMake.value) { + return; + } selectedMake.value = value.toString(); + modelTextController.value = TextEditingValue.empty; onSelect({ 'make': selectedMake.value, - 'model': selectedModel.value, + 'model': null, }); }, ); diff --git a/mobile/lib/widgets/search/search_filter/common/dropdown.dart b/mobile/lib/widgets/search/search_filter/common/dropdown.dart index 230d7dd4daa5d..dd8785459f7fb 100644 --- a/mobile/lib/widgets/search/search_filter/common/dropdown.dart +++ b/mobile/lib/widgets/search/search_filter/common/dropdown.dart @@ -29,6 +29,7 @@ class SearchDropdown extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { return DropdownMenu( + controller: controller, leadingIcon: leadingIcon, width: constraints.maxWidth, dropdownMenuEntries: dropdownMenuEntries, diff --git a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart index 25bcf2d06e507..c093e8f1e3c98 100644 --- a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart @@ -1,9 +1,12 @@ import 'dart:io'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/backup/backup_verification.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/background_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; @@ -23,7 +26,21 @@ class BackupSettings extends HookConsumerWidget { useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets); final isAdvancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); + final albumSync = useAppSettingsState(AppSettingsEnum.syncAlbums); final isCorruptCheckInProgress = ref.watch(backupVerificationProvider); + final isAlbumSyncInProgress = useState(false); + + syncAlbums() async { + isAlbumSyncInProgress.value = true; + try { + await ref.read(assetServiceProvider).syncUploadedAssetToAlbums(); + } catch (_) { + } finally { + Future.delayed(const Duration(seconds: 1), () { + isAlbumSyncInProgress.value = false; + }); + } + } final backupSettings = [ const ForegroundBackupSettings(), @@ -58,6 +75,23 @@ class BackupSettings extends HookConsumerWidget { .performBackupCheck(context) : null, ), + if (albumSync.value) + SettingsButtonListTile( + icon: Icons.photo_album_outlined, + title: 'sync_albums'.tr(), + subtitle: Text( + "sync_albums_manual_subtitle".tr(), + ), + buttonText: 'sync_albums'.tr(), + child: isAlbumSyncInProgress.value + ? const CircularProgressIndicator.adaptive( + strokeWidth: 2, + ) + : ElevatedButton( + onPressed: syncAlbums, + child: Text('sync'.tr()), + ), + ), ]; return SettingsSubPageScaffold( diff --git a/mobile/lib/widgets/settings/settings_button_list_tile.dart b/mobile/lib/widgets/settings/settings_button_list_tile.dart index 196e3d170feaf..c8bd8e4b588c9 100644 --- a/mobile/lib/widgets/settings/settings_button_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_button_list_tile.dart @@ -9,6 +9,7 @@ class SettingsButtonListTile extends StatelessWidget { final Widget? subtitle; final String? subtileText; final String buttonText; + final Widget? child; final void Function()? onButtonTap; const SettingsButtonListTile({ @@ -18,6 +19,7 @@ class SettingsButtonListTile extends StatelessWidget { this.subtileText, this.subtitle, required this.buttonText, + this.child, this.onButtonTap, super.key, }); @@ -48,7 +50,8 @@ class SettingsButtonListTile extends StatelessWidget { ), if (subtitle != null) subtitle!, const SizedBox(height: 6), - ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), + child ?? + ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), ], ), ); diff --git a/mobile/lib/widgets/settings/settings_switch_list_tile.dart b/mobile/lib/widgets/settings/settings_switch_list_tile.dart index 78f1738266a31..8aa4ec0a60ec0 100644 --- a/mobile/lib/widgets/settings/settings_switch_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_switch_list_tile.dart @@ -9,6 +9,9 @@ class SettingsSwitchListTile extends StatelessWidget { final String? subtitle; final IconData? icon; final Function(bool)? onChanged; + final EdgeInsets? contentPadding; + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; const SettingsSwitchListTile({ required this.valueNotifier, @@ -17,6 +20,9 @@ class SettingsSwitchListTile extends StatelessWidget { this.icon, this.enabled = true, this.onChanged, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 20), + this.titleStyle, + this.subtitleStyle, super.key, }); @@ -30,7 +36,7 @@ class SettingsSwitchListTile extends StatelessWidget { } return SwitchListTile.adaptive( - contentPadding: const EdgeInsets.symmetric(horizontal: 20), + contentPadding: contentPadding, selectedTileColor: enabled ? null : context.themeData.disabledColor, value: valueNotifier.value, onChanged: onSwitchChanged, @@ -45,20 +51,22 @@ class SettingsSwitchListTile extends StatelessWidget { : null, title: Text( title, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - color: enabled ? null : context.themeData.disabledColor, - height: 1.5, - ), + style: titleStyle ?? + context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: enabled ? null : context.themeData.disabledColor, + height: 1.5, + ), ), subtitle: subtitle != null ? Text( subtitle!, - style: context.textTheme.bodyMedium?.copyWith( - color: enabled - ? context.colorScheme.onSurfaceSecondary - : context.themeData.disabledColor, - ), + style: subtitleStyle ?? + context.textTheme.bodyMedium?.copyWith( + color: enabled + ? context.colorScheme.onSurfaceSecondary + : context.themeData.disabledColor, + ), ) : null, ); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 657dad9d5b33b..66707f917594e 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.112.1 +- API version: 1.113.0 - Generator version: 7.5.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen @@ -86,8 +86,8 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users | *AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums | *AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} | -*AlbumsApi* | [**getAlbumCount**](doc//AlbumsApi.md#getalbumcount) | **GET** /albums/count | *AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} | +*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | *AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | *AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | @@ -107,7 +107,6 @@ Class | Method | HTTP request | Description *AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | -*AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /assets/stack/parent | *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | @@ -116,16 +115,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | -*DeprecatedApi* | [**getAboutInfo**](doc//DeprecatedApi.md#getaboutinfo) | **GET** /server-info/about | *DeprecatedApi* | [**getPersonAssets**](doc//DeprecatedApi.md#getpersonassets) | **GET** /people/{id}/assets | -*DeprecatedApi* | [**getServerConfig**](doc//DeprecatedApi.md#getserverconfig) | **GET** /server-info/config | -*DeprecatedApi* | [**getServerFeatures**](doc//DeprecatedApi.md#getserverfeatures) | **GET** /server-info/features | -*DeprecatedApi* | [**getServerStatistics**](doc//DeprecatedApi.md#getserverstatistics) | **GET** /server-info/statistics | -*DeprecatedApi* | [**getServerVersion**](doc//DeprecatedApi.md#getserverversion) | **GET** /server-info/version | -*DeprecatedApi* | [**getStorage**](doc//DeprecatedApi.md#getstorage) | **GET** /server-info/storage | -*DeprecatedApi* | [**getSupportedMediaTypes**](doc//DeprecatedApi.md#getsupportedmediatypes) | **GET** /server-info/media-types | -*DeprecatedApi* | [**getTheme**](doc//DeprecatedApi.md#gettheme) | **GET** /server-info/theme | -*DeprecatedApi* | [**pingServer**](doc//DeprecatedApi.md#pingserver) | **GET** /server-info/ping | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | @@ -183,17 +173,17 @@ Class | Method | HTTP request | Description *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | *ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | +*ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about | +*ServerApi* | [**getServerConfig**](doc//ServerApi.md#getserverconfig) | **GET** /server/config | +*ServerApi* | [**getServerFeatures**](doc//ServerApi.md#getserverfeatures) | **GET** /server/features | *ServerApi* | [**getServerLicense**](doc//ServerApi.md#getserverlicense) | **GET** /server/license | +*ServerApi* | [**getServerStatistics**](doc//ServerApi.md#getserverstatistics) | **GET** /server/statistics | +*ServerApi* | [**getServerVersion**](doc//ServerApi.md#getserverversion) | **GET** /server/version | +*ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage | +*ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types | +*ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme | +*ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping | *ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | -*ServerInfoApi* | [**getAboutInfo**](doc//ServerInfoApi.md#getaboutinfo) | **GET** /server-info/about | -*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | -*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | -*ServerInfoApi* | [**getServerStatistics**](doc//ServerInfoApi.md#getserverstatistics) | **GET** /server-info/statistics | -*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | -*ServerInfoApi* | [**getStorage**](doc//ServerInfoApi.md#getstorage) | **GET** /server-info/storage | -*ServerInfoApi* | [**getSupportedMediaTypes**](doc//ServerInfoApi.md#getsupportedmediatypes) | **GET** /server-info/media-types | -*ServerInfoApi* | [**getTheme**](doc//ServerInfoApi.md#gettheme) | **GET** /server-info/theme | -*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | *SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | *SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | @@ -205,6 +195,12 @@ Class | Method | HTTP request | Description *SharedLinksApi* | [**removeSharedLink**](doc//SharedLinksApi.md#removesharedlink) | **DELETE** /shared-links/{id} | *SharedLinksApi* | [**removeSharedLinkAssets**](doc//SharedLinksApi.md#removesharedlinkassets) | **DELETE** /shared-links/{id}/assets | *SharedLinksApi* | [**updateSharedLink**](doc//SharedLinksApi.md#updatesharedlink) | **PATCH** /shared-links/{id} | +*StacksApi* | [**createStack**](doc//StacksApi.md#createstack) | **POST** /stacks | +*StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} | +*StacksApi* | [**deleteStacks**](doc//StacksApi.md#deletestacks) | **DELETE** /stacks | +*StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} | +*StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks | +*StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} | *SyncApi* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync | *SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | @@ -214,14 +210,15 @@ Class | Method | HTTP request | Description *SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | *SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | *SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | +*TagsApi* | [**bulkTagAssets**](doc//TagsApi.md#bulktagassets) | **PUT** /tags/assets | *TagsApi* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags | *TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} | *TagsApi* | [**getAllTags**](doc//TagsApi.md#getalltags) | **GET** /tags | -*TagsApi* | [**getTagAssets**](doc//TagsApi.md#gettagassets) | **GET** /tags/{id}/assets | *TagsApi* | [**getTagById**](doc//TagsApi.md#gettagbyid) | **GET** /tags/{id} | *TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets | *TagsApi* | [**untagAssets**](doc//TagsApi.md#untagassets) | **DELETE** /tags/{id}/assets | -*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PATCH** /tags/{id} | +*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PUT** /tags/{id} | +*TagsApi* | [**upsertTags**](doc//TagsApi.md#upserttags) | **PUT** /tags | *TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | *TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | *TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | @@ -247,6 +244,8 @@ Class | Method | HTTP request | Description *UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | *UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} | *UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | +*ViewApi* | [**getAssetsByOriginalPath**](doc//ViewApi.md#getassetsbyoriginalpath) | **GET** /view/folder | +*ViewApi* | [**getUniqueOriginalPaths**](doc//ViewApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths | ## Documentation For Models @@ -260,8 +259,8 @@ Class | Method | HTTP request | Description - [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md) - [AddUsersDto](doc//AddUsersDto.md) - [AdminOnboardingUpdateDto](doc//AdminOnboardingUpdateDto.md) - - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md) - [AlbumResponseDto](doc//AlbumResponseDto.md) + - [AlbumStatisticsResponseDto](doc//AlbumStatisticsResponseDto.md) - [AlbumUserAddDto](doc//AlbumUserAddDto.md) - [AlbumUserCreateDto](doc//AlbumUserCreateDto.md) - [AlbumUserResponseDto](doc//AlbumUserResponseDto.md) @@ -289,6 +288,7 @@ Class | Method | HTTP request | Description - [AssetMediaStatus](doc//AssetMediaStatus.md) - [AssetOrder](doc//AssetOrder.md) - [AssetResponseDto](doc//AssetResponseDto.md) + - [AssetStackResponseDto](doc//AssetStackResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) - [AudioCodec](doc//AudioCodec.md) @@ -306,7 +306,6 @@ Class | Method | HTTP request | Description - [CreateAlbumDto](doc//CreateAlbumDto.md) - [CreateLibraryDto](doc//CreateLibraryDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) - - [CreateTagDto](doc//CreateTagDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponse](doc//DownloadResponse.md) @@ -325,6 +324,8 @@ Class | Method | HTTP request | Description - [FileReportDto](doc//FileReportDto.md) - [FileReportFixDto](doc//FileReportFixDto.md) - [FileReportItemDto](doc//FileReportItemDto.md) + - [FoldersResponse](doc//FoldersResponse.md) + - [FoldersUpdate](doc//FoldersUpdate.md) - [ImageFormat](doc//ImageFormat.md) - [JobCommand](doc//JobCommand.md) - [JobCommandDto](doc//JobCommandDto.md) @@ -343,12 +344,12 @@ Class | Method | HTTP request | Description - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - [MapTheme](doc//MapTheme.md) + - [MemoriesResponse](doc//MemoriesResponse.md) + - [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md) - - [MemoryResponse](doc//MemoryResponse.md) - [MemoryResponseDto](doc//MemoryResponseDto.md) - [MemoryType](doc//MemoryType.md) - - [MemoryUpdate](doc//MemoryUpdate.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MergePersonDto](doc//MergePersonDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md) @@ -360,7 +361,9 @@ Class | Method | HTTP request | Description - [PartnerResponseDto](doc//PartnerResponseDto.md) - [PathEntityType](doc//PathEntityType.md) - [PathType](doc//PathType.md) + - [PeopleResponse](doc//PeopleResponse.md) - [PeopleResponseDto](doc//PeopleResponseDto.md) + - [PeopleUpdate](doc//PeopleUpdate.md) - [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md) - [Permission](doc//Permission.md) @@ -373,8 +376,8 @@ Class | Method | HTTP request | Description - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueStatusDto](doc//QueueStatusDto.md) - - [RatingResponse](doc//RatingResponse.md) - - [RatingUpdate](doc//RatingUpdate.md) + - [RatingsResponse](doc//RatingsResponse.md) + - [RatingsUpdate](doc//RatingsUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) @@ -404,6 +407,9 @@ Class | Method | HTTP request | Description - [SignUpDto](doc//SignUpDto.md) - [SmartInfoResponseDto](doc//SmartInfoResponseDto.md) - [SmartSearchDto](doc//SmartSearchDto.md) + - [StackCreateDto](doc//StackCreateDto.md) + - [StackResponseDto](doc//StackResponseDto.md) + - [StackUpdateDto](doc//StackUpdateDto.md) - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigImageDto](doc//SystemConfigImageDto.md) @@ -427,8 +433,14 @@ Class | Method | HTTP request | Description - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md) - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md) - [SystemConfigUserDto](doc//SystemConfigUserDto.md) + - [TagBulkAssetsDto](doc//TagBulkAssetsDto.md) + - [TagBulkAssetsResponseDto](doc//TagBulkAssetsResponseDto.md) + - [TagCreateDto](doc//TagCreateDto.md) - [TagResponseDto](doc//TagResponseDto.md) - - [TagTypeEnum](doc//TagTypeEnum.md) + - [TagUpdateDto](doc//TagUpdateDto.md) + - [TagUpsertDto](doc//TagUpsertDto.md) + - [TagsResponse](doc//TagsResponse.md) + - [TagsUpdate](doc//TagsUpdate.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) - [ToneMapping](doc//ToneMapping.md) @@ -439,8 +451,6 @@ Class | Method | HTTP request | Description - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UpdatePartnerDto](doc//UpdatePartnerDto.md) - - [UpdateStackParentDto](doc//UpdateStackParentDto.md) - - [UpdateTagDto](doc//UpdateTagDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) - [UserAdminCreateDto](doc//UserAdminCreateDto.md) - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 4d33f1018cb52..d6ce89624cee6 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -51,9 +51,9 @@ part 'api/partners_api.dart'; part 'api/people_api.dart'; part 'api/search_api.dart'; part 'api/server_api.dart'; -part 'api/server_info_api.dart'; part 'api/sessions_api.dart'; part 'api/shared_links_api.dart'; +part 'api/stacks_api.dart'; part 'api/sync_api.dart'; part 'api/system_config_api.dart'; part 'api/system_metadata_api.dart'; @@ -62,6 +62,7 @@ part 'api/timeline_api.dart'; part 'api/trash_api.dart'; part 'api/users_api.dart'; part 'api/users_admin_api.dart'; +part 'api/view_api.dart'; part 'model/api_key_create_dto.dart'; part 'model/api_key_create_response_dto.dart'; @@ -72,8 +73,8 @@ part 'model/activity_response_dto.dart'; part 'model/activity_statistics_response_dto.dart'; part 'model/add_users_dto.dart'; part 'model/admin_onboarding_update_dto.dart'; -part 'model/album_count_response_dto.dart'; part 'model/album_response_dto.dart'; +part 'model/album_statistics_response_dto.dart'; part 'model/album_user_add_dto.dart'; part 'model/album_user_create_dto.dart'; part 'model/album_user_response_dto.dart'; @@ -101,6 +102,7 @@ part 'model/asset_media_size.dart'; part 'model/asset_media_status.dart'; part 'model/asset_order.dart'; part 'model/asset_response_dto.dart'; +part 'model/asset_stack_response_dto.dart'; part 'model/asset_stats_response_dto.dart'; part 'model/asset_type_enum.dart'; part 'model/audio_codec.dart'; @@ -118,7 +120,6 @@ part 'model/colorspace.dart'; part 'model/create_album_dto.dart'; part 'model/create_library_dto.dart'; part 'model/create_profile_image_response_dto.dart'; -part 'model/create_tag_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response.dart'; @@ -137,6 +138,8 @@ part 'model/file_checksum_response_dto.dart'; part 'model/file_report_dto.dart'; part 'model/file_report_fix_dto.dart'; part 'model/file_report_item_dto.dart'; +part 'model/folders_response.dart'; +part 'model/folders_update.dart'; part 'model/image_format.dart'; part 'model/job_command.dart'; part 'model/job_command_dto.dart'; @@ -155,12 +158,12 @@ part 'model/logout_response_dto.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; part 'model/map_theme.dart'; +part 'model/memories_response.dart'; +part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; part 'model/memory_lane_response_dto.dart'; -part 'model/memory_response.dart'; part 'model/memory_response_dto.dart'; part 'model/memory_type.dart'; -part 'model/memory_update.dart'; part 'model/memory_update_dto.dart'; part 'model/merge_person_dto.dart'; part 'model/metadata_search_dto.dart'; @@ -172,7 +175,9 @@ part 'model/partner_direction.dart'; part 'model/partner_response_dto.dart'; part 'model/path_entity_type.dart'; part 'model/path_type.dart'; +part 'model/people_response.dart'; part 'model/people_response_dto.dart'; +part 'model/people_update.dart'; part 'model/people_update_dto.dart'; part 'model/people_update_item.dart'; part 'model/permission.dart'; @@ -185,8 +190,8 @@ part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_status_dto.dart'; -part 'model/rating_response.dart'; -part 'model/rating_update.dart'; +part 'model/ratings_response.dart'; +part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; @@ -216,6 +221,9 @@ part 'model/shared_link_type.dart'; part 'model/sign_up_dto.dart'; part 'model/smart_info_response_dto.dart'; part 'model/smart_search_dto.dart'; +part 'model/stack_create_dto.dart'; +part 'model/stack_response_dto.dart'; +part 'model/stack_update_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_image_dto.dart'; @@ -239,8 +247,14 @@ part 'model/system_config_template_storage_option_dto.dart'; part 'model/system_config_theme_dto.dart'; part 'model/system_config_trash_dto.dart'; part 'model/system_config_user_dto.dart'; +part 'model/tag_bulk_assets_dto.dart'; +part 'model/tag_bulk_assets_response_dto.dart'; +part 'model/tag_create_dto.dart'; part 'model/tag_response_dto.dart'; -part 'model/tag_type_enum.dart'; +part 'model/tag_update_dto.dart'; +part 'model/tag_upsert_dto.dart'; +part 'model/tags_response.dart'; +part 'model/tags_update.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; part 'model/tone_mapping.dart'; @@ -251,8 +265,6 @@ part 'model/update_album_user_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_library_dto.dart'; part 'model/update_partner_dto.dart'; -part 'model/update_stack_parent_dto.dart'; -part 'model/update_tag_dto.dart'; part 'model/usage_by_user_dto.dart'; part 'model/user_admin_create_dto.dart'; part 'model/user_admin_delete_dto.dart'; diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index fb81c04616742..eb2bb7c0bd9cb 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -218,47 +218,6 @@ class AlbumsApi { } } - /// Performs an HTTP 'GET /albums/count' operation and returns the [Response]. - Future getAlbumCountWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/albums/count'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - Future getAlbumCount() async { - final response = await getAlbumCountWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumCountResponseDto',) as AlbumCountResponseDto; - - } - return null; - } - /// Performs an HTTP 'GET /albums/{id}' operation and returns the [Response]. /// Parameters: /// @@ -322,6 +281,47 @@ class AlbumsApi { return null; } + /// Performs an HTTP 'GET /albums/statistics' operation and returns the [Response]. + Future getAlbumStatisticsWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/albums/statistics'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getAlbumStatistics() async { + final response = await getAlbumStatisticsWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumStatisticsResponseDto',) as AlbumStatisticsResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /albums' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index d7d386130bc02..ceba3574cd17a 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -804,45 +804,6 @@ class AssetsApi { } } - /// Performs an HTTP 'PUT /assets/stack/parent' operation and returns the [Response]. - /// Parameters: - /// - /// * [UpdateStackParentDto] updateStackParentDto (required): - Future updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async { - // ignore: prefer_const_declarations - final path = r'/assets/stack/parent'; - - // ignore: prefer_final_locals - Object? postBody = updateStackParentDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'PUT', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [UpdateStackParentDto] updateStackParentDto (required): - Future updateStackParent(UpdateStackParentDto updateStackParentDto,) async { - final response = await updateStackParentWithHttpInfo(updateStackParentDto,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - /// Performs an HTTP 'POST /assets' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index e1e09ae4b2dda..96cb3c2ef0ad2 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -16,50 +16,6 @@ class DeprecatedApi { final ApiClient apiClient; - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getAboutInfoWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/about'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getAboutInfo() async { - final response = await getAboutInfoWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerAboutResponseDto',) as ServerAboutResponseDto; - - } - return null; - } - /// This property was deprecated in v1.113.0 /// /// Note: This method returns the HTTP [Response]. @@ -115,356 +71,4 @@ class DeprecatedApi { } return null; } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerConfigWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/config'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerConfig() async { - final response = await getServerConfigWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerConfigDto',) as ServerConfigDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerFeaturesWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/features'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerFeatures() async { - final response = await getServerFeaturesWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerFeaturesDto',) as ServerFeaturesDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerStatisticsWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/statistics'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerStatistics() async { - final response = await getServerStatisticsWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerStatsResponseDto',) as ServerStatsResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerVersionWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/version'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerVersion() async { - final response = await getServerVersionWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerVersionResponseDto',) as ServerVersionResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getStorageWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/storage'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getStorage() async { - final response = await getStorageWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerStorageResponseDto',) as ServerStorageResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getSupportedMediaTypesWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/media-types'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getSupportedMediaTypes() async { - final response = await getSupportedMediaTypesWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerMediaTypesResponseDto',) as ServerMediaTypesResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getThemeWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/theme'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getTheme() async { - final response = await getThemeWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerThemeDto',) as ServerThemeDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future pingServerWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/ping'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future pingServer() async { - final response = await pingServerWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerPingResponse',) as ServerPingResponse; - - } - return null; - } } diff --git a/mobile/openapi/lib/api/server_api.dart b/mobile/openapi/lib/api/server_api.dart index 9cb52514c25fd..bde8d595b6fb0 100644 --- a/mobile/openapi/lib/api/server_api.dart +++ b/mobile/openapi/lib/api/server_api.dart @@ -49,6 +49,129 @@ class ServerApi { } } + /// Performs an HTTP 'GET /server/about' operation and returns the [Response]. + Future getAboutInfoWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/about'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getAboutInfo() async { + final response = await getAboutInfoWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerAboutResponseDto',) as ServerAboutResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/config' operation and returns the [Response]. + Future getServerConfigWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/config'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getServerConfig() async { + final response = await getServerConfigWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerConfigDto',) as ServerConfigDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/features' operation and returns the [Response]. + Future getServerFeaturesWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/features'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getServerFeatures() async { + final response = await getServerFeaturesWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerFeaturesDto',) as ServerFeaturesDto; + + } + return null; + } + /// Performs an HTTP 'GET /server/license' operation and returns the [Response]. Future getServerLicenseWithHttpInfo() async { // ignore: prefer_const_declarations @@ -90,6 +213,252 @@ class ServerApi { return null; } + /// Performs an HTTP 'GET /server/statistics' operation and returns the [Response]. + Future getServerStatisticsWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/statistics'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getServerStatistics() async { + final response = await getServerStatisticsWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerStatsResponseDto',) as ServerStatsResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/version' operation and returns the [Response]. + Future getServerVersionWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/version'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getServerVersion() async { + final response = await getServerVersionWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerVersionResponseDto',) as ServerVersionResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/storage' operation and returns the [Response]. + Future getStorageWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/storage'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getStorage() async { + final response = await getStorageWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerStorageResponseDto',) as ServerStorageResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/media-types' operation and returns the [Response]. + Future getSupportedMediaTypesWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/media-types'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getSupportedMediaTypes() async { + final response = await getSupportedMediaTypesWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerMediaTypesResponseDto',) as ServerMediaTypesResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/theme' operation and returns the [Response]. + Future getThemeWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/theme'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getTheme() async { + final response = await getThemeWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerThemeDto',) as ServerThemeDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/ping' operation and returns the [Response]. + Future pingServerWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/ping'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future pingServer() async { + final response = await pingServerWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerPingResponse',) as ServerPingResponse; + + } + return null; + } + /// Performs an HTTP 'PUT /server/license' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart deleted file mode 100644 index dc58a94fd06b3..0000000000000 --- a/mobile/openapi/lib/api/server_info_api.dart +++ /dev/null @@ -1,414 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class ServerInfoApi { - ServerInfoApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; - - final ApiClient apiClient; - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getAboutInfoWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/about'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getAboutInfo() async { - final response = await getAboutInfoWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerAboutResponseDto',) as ServerAboutResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerConfigWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/config'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerConfig() async { - final response = await getServerConfigWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerConfigDto',) as ServerConfigDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerFeaturesWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/features'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerFeatures() async { - final response = await getServerFeaturesWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerFeaturesDto',) as ServerFeaturesDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerStatisticsWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/statistics'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerStatistics() async { - final response = await getServerStatisticsWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerStatsResponseDto',) as ServerStatsResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerVersionWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/version'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerVersion() async { - final response = await getServerVersionWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerVersionResponseDto',) as ServerVersionResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getStorageWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/storage'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getStorage() async { - final response = await getStorageWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerStorageResponseDto',) as ServerStorageResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getSupportedMediaTypesWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/media-types'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getSupportedMediaTypes() async { - final response = await getSupportedMediaTypesWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerMediaTypesResponseDto',) as ServerMediaTypesResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getThemeWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/theme'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getTheme() async { - final response = await getThemeWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerThemeDto',) as ServerThemeDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future pingServerWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/ping'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future pingServer() async { - final response = await pingServerWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerPingResponse',) as ServerPingResponse; - - } - return null; - } -} diff --git a/mobile/openapi/lib/api/stacks_api.dart b/mobile/openapi/lib/api/stacks_api.dart new file mode 100644 index 0000000000000..aa1d9b341615d --- /dev/null +++ b/mobile/openapi/lib/api/stacks_api.dart @@ -0,0 +1,298 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class StacksApi { + StacksApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'POST /stacks' operation and returns the [Response]. + /// Parameters: + /// + /// * [StackCreateDto] stackCreateDto (required): + Future createStackWithHttpInfo(StackCreateDto stackCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/stacks'; + + // ignore: prefer_final_locals + Object? postBody = stackCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [StackCreateDto] stackCreateDto (required): + Future createStack(StackCreateDto stackCreateDto,) async { + final response = await createStackWithHttpInfo(stackCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'StackResponseDto',) as StackResponseDto; + + } + return null; + } + + /// Performs an HTTP 'DELETE /stacks/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future deleteStackWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/stacks/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future deleteStack(String id,) async { + final response = await deleteStackWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'DELETE /stacks' operation and returns the [Response]. + /// Parameters: + /// + /// * [BulkIdsDto] bulkIdsDto (required): + Future deleteStacksWithHttpInfo(BulkIdsDto bulkIdsDto,) async { + // ignore: prefer_const_declarations + final path = r'/stacks'; + + // ignore: prefer_final_locals + Object? postBody = bulkIdsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [BulkIdsDto] bulkIdsDto (required): + Future deleteStacks(BulkIdsDto bulkIdsDto,) async { + final response = await deleteStacksWithHttpInfo(bulkIdsDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'GET /stacks/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getStackWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/stacks/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getStack(String id,) async { + final response = await getStackWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'StackResponseDto',) as StackResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /stacks' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] primaryAssetId: + Future searchStacksWithHttpInfo({ String? primaryAssetId, }) async { + // ignore: prefer_const_declarations + final path = r'/stacks'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (primaryAssetId != null) { + queryParams.addAll(_queryParams('', 'primaryAssetId', primaryAssetId)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] primaryAssetId: + Future?> searchStacks({ String? primaryAssetId, }) async { + final response = await searchStacksWithHttpInfo( primaryAssetId: primaryAssetId, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Performs an HTTP 'PUT /stacks/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [StackUpdateDto] stackUpdateDto (required): + Future updateStackWithHttpInfo(String id, StackUpdateDto stackUpdateDto,) async { + // ignore: prefer_const_declarations + final path = r'/stacks/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = stackUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [StackUpdateDto] stackUpdateDto (required): + Future updateStack(String id, StackUpdateDto stackUpdateDto,) async { + final response = await updateStackWithHttpInfo(id, stackUpdateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'StackResponseDto',) as StackResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api/tags_api.dart b/mobile/openapi/lib/api/tags_api.dart index e5d1e9c650311..87c9001a3c63e 100644 --- a/mobile/openapi/lib/api/tags_api.dart +++ b/mobile/openapi/lib/api/tags_api.dart @@ -16,16 +16,63 @@ class TagsApi { final ApiClient apiClient; + /// Performs an HTTP 'PUT /tags/assets' operation and returns the [Response]. + /// Parameters: + /// + /// * [TagBulkAssetsDto] tagBulkAssetsDto (required): + Future bulkTagAssetsWithHttpInfo(TagBulkAssetsDto tagBulkAssetsDto,) async { + // ignore: prefer_const_declarations + final path = r'/tags/assets'; + + // ignore: prefer_final_locals + Object? postBody = tagBulkAssetsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [TagBulkAssetsDto] tagBulkAssetsDto (required): + Future bulkTagAssets(TagBulkAssetsDto tagBulkAssetsDto,) async { + final response = await bulkTagAssetsWithHttpInfo(tagBulkAssetsDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TagBulkAssetsResponseDto',) as TagBulkAssetsResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /tags' operation and returns the [Response]. /// Parameters: /// - /// * [CreateTagDto] createTagDto (required): - Future createTagWithHttpInfo(CreateTagDto createTagDto,) async { + /// * [TagCreateDto] tagCreateDto (required): + Future createTagWithHttpInfo(TagCreateDto tagCreateDto,) async { // ignore: prefer_const_declarations final path = r'/tags'; // ignore: prefer_final_locals - Object? postBody = createTagDto; + Object? postBody = tagCreateDto; final queryParams = []; final headerParams = {}; @@ -47,9 +94,9 @@ class TagsApi { /// Parameters: /// - /// * [CreateTagDto] createTagDto (required): - Future createTag(CreateTagDto createTagDto,) async { - final response = await createTagWithHttpInfo(createTagDto,); + /// * [TagCreateDto] tagCreateDto (required): + Future createTag(TagCreateDto tagCreateDto,) async { + final response = await createTagWithHttpInfo(tagCreateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -147,57 +194,6 @@ class TagsApi { return null; } - /// Performs an HTTP 'GET /tags/{id}/assets' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - Future getTagAssetsWithHttpInfo(String id,) async { - // ignore: prefer_const_declarations - final path = r'/tags/{id}/assets' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - Future?> getTagAssets(String id,) async { - final response = await getTagAssetsWithHttpInfo(id,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - - } - return null; - } - /// Performs an HTTP 'GET /tags/{id}' operation and returns the [Response]. /// Parameters: /// @@ -251,14 +247,14 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future tagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { + /// * [BulkIdsDto] bulkIdsDto (required): + Future tagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = bulkIdsDto; final queryParams = []; final headerParams = {}; @@ -282,9 +278,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future?> tagAssets(String id, AssetIdsDto assetIdsDto,) async { - final response = await tagAssetsWithHttpInfo(id, assetIdsDto,); + /// * [BulkIdsDto] bulkIdsDto (required): + Future?> tagAssets(String id, BulkIdsDto bulkIdsDto,) async { + final response = await tagAssetsWithHttpInfo(id, bulkIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -293,8 +289,8 @@ class TagsApi { // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } @@ -306,14 +302,14 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future untagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { + /// * [BulkIdsDto] bulkIdsDto (required): + Future untagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = bulkIdsDto; final queryParams = []; final headerParams = {}; @@ -337,9 +333,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future?> untagAssets(String id, AssetIdsDto assetIdsDto,) async { - final response = await untagAssetsWithHttpInfo(id, assetIdsDto,); + /// * [BulkIdsDto] bulkIdsDto (required): + Future?> untagAssets(String id, BulkIdsDto bulkIdsDto,) async { + final response = await untagAssetsWithHttpInfo(id, bulkIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -348,27 +344,27 @@ class TagsApi { // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } return null; } - /// Performs an HTTP 'PATCH /tags/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /tags/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): /// - /// * [UpdateTagDto] updateTagDto (required): - Future updateTagWithHttpInfo(String id, UpdateTagDto updateTagDto,) async { + /// * [TagUpdateDto] tagUpdateDto (required): + Future updateTagWithHttpInfo(String id, TagUpdateDto tagUpdateDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = updateTagDto; + Object? postBody = tagUpdateDto; final queryParams = []; final headerParams = {}; @@ -379,7 +375,7 @@ class TagsApi { return apiClient.invokeAPI( path, - 'PATCH', + 'PUT', queryParams, postBody, headerParams, @@ -392,9 +388,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [UpdateTagDto] updateTagDto (required): - Future updateTag(String id, UpdateTagDto updateTagDto,) async { - final response = await updateTagWithHttpInfo(id, updateTagDto,); + /// * [TagUpdateDto] tagUpdateDto (required): + Future updateTag(String id, TagUpdateDto tagUpdateDto,) async { + final response = await updateTagWithHttpInfo(id, tagUpdateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -407,4 +403,54 @@ class TagsApi { } return null; } + + /// Performs an HTTP 'PUT /tags' operation and returns the [Response]. + /// Parameters: + /// + /// * [TagUpsertDto] tagUpsertDto (required): + Future upsertTagsWithHttpInfo(TagUpsertDto tagUpsertDto,) async { + // ignore: prefer_const_declarations + final path = r'/tags'; + + // ignore: prefer_final_locals + Object? postBody = tagUpsertDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [TagUpsertDto] tagUpsertDto (required): + Future?> upsertTags(TagUpsertDto tagUpsertDto,) async { + final response = await upsertTagsWithHttpInfo(tagUpsertDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } } diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 4acb98bdf2c49..8c94e09bf5c3f 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -37,12 +37,14 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final path = r'/timeline/bucket'; @@ -75,6 +77,9 @@ class TimelineApi { queryParams.addAll(_queryParams('', 'personId', personId)); } queryParams.addAll(_queryParams('', 'size', size)); + if (tagId != null) { + queryParams.addAll(_queryParams('', 'tagId', tagId)); + } queryParams.addAll(_queryParams('', 'timeBucket', timeBucket)); if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); @@ -120,13 +125,15 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -162,12 +169,14 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final path = r'/timeline/buckets'; @@ -200,6 +209,9 @@ class TimelineApi { queryParams.addAll(_queryParams('', 'personId', personId)); } queryParams.addAll(_queryParams('', 'size', size)); + if (tagId != null) { + queryParams.addAll(_queryParams('', 'tagId', tagId)); + } if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); } @@ -242,13 +254,15 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/view_api.dart b/mobile/openapi/lib/api/view_api.dart new file mode 100644 index 0000000000000..f4489f2d1a36e --- /dev/null +++ b/mobile/openapi/lib/api/view_api.dart @@ -0,0 +1,114 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ViewApi { + ViewApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'GET /view/folder' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] path (required): + Future getAssetsByOriginalPathWithHttpInfo(String path,) async { + // ignore: prefer_const_declarations + final path = r'/view/folder'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'path', path)); + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] path (required): + Future?> getAssetsByOriginalPath(String path,) async { + final response = await getAssetsByOriginalPathWithHttpInfo(path,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Performs an HTTP 'GET /view/folder/unique-paths' operation and returns the [Response]. + Future getUniqueOriginalPathsWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/view/folder/unique-paths'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getUniqueOriginalPaths() async { + final response = await getUniqueOriginalPathsWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index b5b79be8b143c..47375f0b504f1 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -201,10 +201,10 @@ class ApiClient { return AddUsersDto.fromJson(value); case 'AdminOnboardingUpdateDto': return AdminOnboardingUpdateDto.fromJson(value); - case 'AlbumCountResponseDto': - return AlbumCountResponseDto.fromJson(value); case 'AlbumResponseDto': return AlbumResponseDto.fromJson(value); + case 'AlbumStatisticsResponseDto': + return AlbumStatisticsResponseDto.fromJson(value); case 'AlbumUserAddDto': return AlbumUserAddDto.fromJson(value); case 'AlbumUserCreateDto': @@ -259,6 +259,8 @@ class ApiClient { return AssetOrderTypeTransformer().decode(value); case 'AssetResponseDto': return AssetResponseDto.fromJson(value); + case 'AssetStackResponseDto': + return AssetStackResponseDto.fromJson(value); case 'AssetStatsResponseDto': return AssetStatsResponseDto.fromJson(value); case 'AssetTypeEnum': @@ -293,8 +295,6 @@ class ApiClient { return CreateLibraryDto.fromJson(value); case 'CreateProfileImageResponseDto': return CreateProfileImageResponseDto.fromJson(value); - case 'CreateTagDto': - return CreateTagDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': @@ -331,6 +331,10 @@ class ApiClient { return FileReportFixDto.fromJson(value); case 'FileReportItemDto': return FileReportItemDto.fromJson(value); + case 'FoldersResponse': + return FoldersResponse.fromJson(value); + case 'FoldersUpdate': + return FoldersUpdate.fromJson(value); case 'ImageFormat': return ImageFormatTypeTransformer().decode(value); case 'JobCommand': @@ -367,18 +371,18 @@ class ApiClient { return MapReverseGeocodeResponseDto.fromJson(value); case 'MapTheme': return MapThemeTypeTransformer().decode(value); + case 'MemoriesResponse': + return MemoriesResponse.fromJson(value); + case 'MemoriesUpdate': + return MemoriesUpdate.fromJson(value); case 'MemoryCreateDto': return MemoryCreateDto.fromJson(value); case 'MemoryLaneResponseDto': return MemoryLaneResponseDto.fromJson(value); - case 'MemoryResponse': - return MemoryResponse.fromJson(value); case 'MemoryResponseDto': return MemoryResponseDto.fromJson(value); case 'MemoryType': return MemoryTypeTypeTransformer().decode(value); - case 'MemoryUpdate': - return MemoryUpdate.fromJson(value); case 'MemoryUpdateDto': return MemoryUpdateDto.fromJson(value); case 'MergePersonDto': @@ -401,8 +405,12 @@ class ApiClient { return PathEntityTypeTypeTransformer().decode(value); case 'PathType': return PathTypeTypeTransformer().decode(value); + case 'PeopleResponse': + return PeopleResponse.fromJson(value); case 'PeopleResponseDto': return PeopleResponseDto.fromJson(value); + case 'PeopleUpdate': + return PeopleUpdate.fromJson(value); case 'PeopleUpdateDto': return PeopleUpdateDto.fromJson(value); case 'PeopleUpdateItem': @@ -427,10 +435,10 @@ class ApiClient { return PurchaseUpdate.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); - case 'RatingResponse': - return RatingResponse.fromJson(value); - case 'RatingUpdate': - return RatingUpdate.fromJson(value); + case 'RatingsResponse': + return RatingsResponse.fromJson(value); + case 'RatingsUpdate': + return RatingsUpdate.fromJson(value); case 'ReactionLevel': return ReactionLevelTypeTransformer().decode(value); case 'ReactionType': @@ -489,6 +497,12 @@ class ApiClient { return SmartInfoResponseDto.fromJson(value); case 'SmartSearchDto': return SmartSearchDto.fromJson(value); + case 'StackCreateDto': + return StackCreateDto.fromJson(value); + case 'StackResponseDto': + return StackResponseDto.fromJson(value); + case 'StackUpdateDto': + return StackUpdateDto.fromJson(value); case 'SystemConfigDto': return SystemConfigDto.fromJson(value); case 'SystemConfigFFmpegDto': @@ -535,10 +549,22 @@ class ApiClient { return SystemConfigTrashDto.fromJson(value); case 'SystemConfigUserDto': return SystemConfigUserDto.fromJson(value); + case 'TagBulkAssetsDto': + return TagBulkAssetsDto.fromJson(value); + case 'TagBulkAssetsResponseDto': + return TagBulkAssetsResponseDto.fromJson(value); + case 'TagCreateDto': + return TagCreateDto.fromJson(value); case 'TagResponseDto': return TagResponseDto.fromJson(value); - case 'TagTypeEnum': - return TagTypeEnumTypeTransformer().decode(value); + case 'TagUpdateDto': + return TagUpdateDto.fromJson(value); + case 'TagUpsertDto': + return TagUpsertDto.fromJson(value); + case 'TagsResponse': + return TagsResponse.fromJson(value); + case 'TagsUpdate': + return TagsUpdate.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); case 'TimeBucketSize': @@ -559,10 +585,6 @@ class ApiClient { return UpdateLibraryDto.fromJson(value); case 'UpdatePartnerDto': return UpdatePartnerDto.fromJson(value); - case 'UpdateStackParentDto': - return UpdateStackParentDto.fromJson(value); - case 'UpdateTagDto': - return UpdateTagDto.fromJson(value); case 'UsageByUserDto': return UsageByUserDto.fromJson(value); case 'UserAdminCreateDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 7f46e145b15eb..a486551cc5987 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -127,9 +127,6 @@ String parameterToString(dynamic value) { if (value is SharedLinkType) { return SharedLinkTypeTypeTransformer().encode(value).toString(); } - if (value is TagTypeEnum) { - return TagTypeEnumTypeTransformer().encode(value).toString(); - } if (value is TimeBucketSize) { return TimeBucketSizeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/album_count_response_dto.dart b/mobile/openapi/lib/model/album_statistics_response_dto.dart similarity index 63% rename from mobile/openapi/lib/model/album_count_response_dto.dart rename to mobile/openapi/lib/model/album_statistics_response_dto.dart index 531a17a0838cc..90dbe520163bb 100644 --- a/mobile/openapi/lib/model/album_count_response_dto.dart +++ b/mobile/openapi/lib/model/album_statistics_response_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class AlbumCountResponseDto { - /// Returns a new [AlbumCountResponseDto] instance. - AlbumCountResponseDto({ +class AlbumStatisticsResponseDto { + /// Returns a new [AlbumStatisticsResponseDto] instance. + AlbumStatisticsResponseDto({ required this.notShared, required this.owned, required this.shared, @@ -25,7 +25,7 @@ class AlbumCountResponseDto { int shared; @override - bool operator ==(Object other) => identical(this, other) || other is AlbumCountResponseDto && + bool operator ==(Object other) => identical(this, other) || other is AlbumStatisticsResponseDto && other.notShared == notShared && other.owned == owned && other.shared == shared; @@ -38,7 +38,7 @@ class AlbumCountResponseDto { (shared.hashCode); @override - String toString() => 'AlbumCountResponseDto[notShared=$notShared, owned=$owned, shared=$shared]'; + String toString() => 'AlbumStatisticsResponseDto[notShared=$notShared, owned=$owned, shared=$shared]'; Map toJson() { final json = {}; @@ -48,14 +48,14 @@ class AlbumCountResponseDto { return json; } - /// Returns a new [AlbumCountResponseDto] instance and imports its values from + /// Returns a new [AlbumStatisticsResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AlbumCountResponseDto? fromJson(dynamic value) { + static AlbumStatisticsResponseDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return AlbumCountResponseDto( + return AlbumStatisticsResponseDto( notShared: mapValueOfType(json, r'notShared')!, owned: mapValueOfType(json, r'owned')!, shared: mapValueOfType(json, r'shared')!, @@ -64,11 +64,11 @@ class AlbumCountResponseDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AlbumCountResponseDto.fromJson(row); + final value = AlbumStatisticsResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -77,12 +77,12 @@ class AlbumCountResponseDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AlbumCountResponseDto.fromJson(entry.value); + final value = AlbumStatisticsResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -91,14 +91,14 @@ class AlbumCountResponseDto { return map; } - // maps a json object with a list of AlbumCountResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of AlbumStatisticsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = AlbumCountResponseDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = AlbumStatisticsResponseDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 452dd2f9a51f1..c9b21683fbcec 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -21,8 +21,6 @@ class AssetBulkUpdateDto { this.latitude, this.longitude, this.rating, - this.removeParent, - this.stackParentId, }); /// @@ -79,22 +77,6 @@ class AssetBulkUpdateDto { /// num? rating; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? removeParent; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - String? stackParentId; - @override bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto && other.dateTimeOriginal == dateTimeOriginal && @@ -104,9 +86,7 @@ class AssetBulkUpdateDto { other.isFavorite == isFavorite && other.latitude == latitude && other.longitude == longitude && - other.rating == rating && - other.removeParent == removeParent && - other.stackParentId == stackParentId; + other.rating == rating; @override int get hashCode => @@ -118,12 +98,10 @@ class AssetBulkUpdateDto { (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + - (rating == null ? 0 : rating!.hashCode) + - (removeParent == null ? 0 : removeParent!.hashCode) + - (stackParentId == null ? 0 : stackParentId!.hashCode); + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating, removeParent=$removeParent, stackParentId=$stackParentId]'; + String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; Map toJson() { final json = {}; @@ -163,16 +141,6 @@ class AssetBulkUpdateDto { } else { // json[r'rating'] = null; } - if (this.removeParent != null) { - json[r'removeParent'] = this.removeParent; - } else { - // json[r'removeParent'] = null; - } - if (this.stackParentId != null) { - json[r'stackParentId'] = this.stackParentId; - } else { - // json[r'stackParentId'] = null; - } return json; } @@ -194,8 +162,6 @@ class AssetBulkUpdateDto { latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), rating: num.parse('${json[r'rating']}'), - removeParent: mapValueOfType(json, r'removeParent'), - stackParentId: mapValueOfType(json, r'stackParentId'), ); } return null; diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 61e33ef4e0728..bfb461efdc4c5 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -36,11 +36,9 @@ class AssetResponseDto { this.owner, required this.ownerId, this.people = const [], - required this.resized, + this.resized, this.smartInfo, - this.stack = const [], - required this.stackCount, - this.stackParentId, + this.stack, this.tags = const [], required this.thumbhash, required this.type, @@ -114,7 +112,14 @@ class AssetResponseDto { List people; - bool resized; + /// This property was deprecated in v1.113.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? resized; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -124,11 +129,7 @@ class AssetResponseDto { /// SmartInfoResponseDto? smartInfo; - List stack; - - int? stackCount; - - String? stackParentId; + AssetStackResponseDto? stack; List tags; @@ -167,9 +168,7 @@ class AssetResponseDto { _deepEquality.equals(other.people, people) && other.resized == resized && other.smartInfo == smartInfo && - _deepEquality.equals(other.stack, stack) && - other.stackCount == stackCount && - other.stackParentId == stackParentId && + other.stack == stack && _deepEquality.equals(other.tags, tags) && other.thumbhash == thumbhash && other.type == type && @@ -202,11 +201,9 @@ class AssetResponseDto { (owner == null ? 0 : owner!.hashCode) + (ownerId.hashCode) + (people.hashCode) + - (resized.hashCode) + + (resized == null ? 0 : resized!.hashCode) + (smartInfo == null ? 0 : smartInfo!.hashCode) + - (stack.hashCode) + - (stackCount == null ? 0 : stackCount!.hashCode) + - (stackParentId == null ? 0 : stackParentId!.hashCode) + + (stack == null ? 0 : stack!.hashCode) + (tags.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) + (type.hashCode) + @@ -214,7 +211,7 @@ class AssetResponseDto { (updatedAt.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -265,22 +262,20 @@ class AssetResponseDto { } json[r'ownerId'] = this.ownerId; json[r'people'] = this.people; + if (this.resized != null) { json[r'resized'] = this.resized; + } else { + // json[r'resized'] = null; + } if (this.smartInfo != null) { json[r'smartInfo'] = this.smartInfo; } else { // json[r'smartInfo'] = null; } + if (this.stack != null) { json[r'stack'] = this.stack; - if (this.stackCount != null) { - json[r'stackCount'] = this.stackCount; } else { - // json[r'stackCount'] = null; - } - if (this.stackParentId != null) { - json[r'stackParentId'] = this.stackParentId; - } else { - // json[r'stackParentId'] = null; + // json[r'stack'] = null; } json[r'tags'] = this.tags; if (this.thumbhash != null) { @@ -325,11 +320,9 @@ class AssetResponseDto { owner: UserResponseDto.fromJson(json[r'owner']), ownerId: mapValueOfType(json, r'ownerId')!, people: PersonWithFacesResponseDto.listFromJson(json[r'people']), - resized: mapValueOfType(json, r'resized')!, + resized: mapValueOfType(json, r'resized'), smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), - stack: AssetResponseDto.listFromJson(json[r'stack']), - stackCount: mapValueOfType(json, r'stackCount'), - stackParentId: mapValueOfType(json, r'stackParentId'), + stack: AssetStackResponseDto.fromJson(json[r'stack']), tags: TagResponseDto.listFromJson(json[r'tags']), thumbhash: mapValueOfType(json, r'thumbhash'), type: AssetTypeEnum.fromJson(json[r'type'])!, @@ -398,8 +391,6 @@ class AssetResponseDto { 'originalFileName', 'originalPath', 'ownerId', - 'resized', - 'stackCount', 'thumbhash', 'type', 'updatedAt', diff --git a/mobile/openapi/lib/model/asset_stack_response_dto.dart b/mobile/openapi/lib/model/asset_stack_response_dto.dart new file mode 100644 index 0000000000000..89d30f7810682 --- /dev/null +++ b/mobile/openapi/lib/model/asset_stack_response_dto.dart @@ -0,0 +1,114 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetStackResponseDto { + /// Returns a new [AssetStackResponseDto] instance. + AssetStackResponseDto({ + required this.assetCount, + required this.id, + required this.primaryAssetId, + }); + + int assetCount; + + String id; + + String primaryAssetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetStackResponseDto && + other.assetCount == assetCount && + other.id == id && + other.primaryAssetId == primaryAssetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetCount.hashCode) + + (id.hashCode) + + (primaryAssetId.hashCode); + + @override + String toString() => 'AssetStackResponseDto[assetCount=$assetCount, id=$id, primaryAssetId=$primaryAssetId]'; + + Map toJson() { + final json = {}; + json[r'assetCount'] = this.assetCount; + json[r'id'] = this.id; + json[r'primaryAssetId'] = this.primaryAssetId; + return json; + } + + /// Returns a new [AssetStackResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetStackResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return AssetStackResponseDto( + assetCount: mapValueOfType(json, r'assetCount')!, + id: mapValueOfType(json, r'id')!, + primaryAssetId: mapValueOfType(json, r'primaryAssetId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetStackResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetStackResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetStackResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetStackResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetCount', + 'id', + 'primaryAssetId', + }; +} + diff --git a/mobile/openapi/lib/model/folders_response.dart b/mobile/openapi/lib/model/folders_response.dart new file mode 100644 index 0000000000000..5bfc4c793deed --- /dev/null +++ b/mobile/openapi/lib/model/folders_response.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class FoldersResponse { + /// Returns a new [FoldersResponse] instance. + FoldersResponse({ + this.enabled = false, + this.sidebarWeb = false, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is FoldersResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'FoldersResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [FoldersResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FoldersResponse? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return FoldersResponse( + enabled: mapValueOfType(json, r'enabled')!, + sidebarWeb: mapValueOfType(json, r'sidebarWeb')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = FoldersResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = FoldersResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FoldersResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FoldersResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'sidebarWeb', + }; +} + diff --git a/mobile/openapi/lib/model/folders_update.dart b/mobile/openapi/lib/model/folders_update.dart new file mode 100644 index 0000000000000..088c98a4d8fd2 --- /dev/null +++ b/mobile/openapi/lib/model/folders_update.dart @@ -0,0 +1,124 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class FoldersUpdate { + /// Returns a new [FoldersUpdate] instance. + FoldersUpdate({ + this.enabled, + this.sidebarWeb, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is FoldersUpdate && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled == null ? 0 : enabled!.hashCode) + + (sidebarWeb == null ? 0 : sidebarWeb!.hashCode); + + @override + String toString() => 'FoldersUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + if (this.sidebarWeb != null) { + json[r'sidebarWeb'] = this.sidebarWeb; + } else { + // json[r'sidebarWeb'] = null; + } + return json; + } + + /// Returns a new [FoldersUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FoldersUpdate? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return FoldersUpdate( + enabled: mapValueOfType(json, r'enabled'), + sidebarWeb: mapValueOfType(json, r'sidebarWeb'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = FoldersUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = FoldersUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FoldersUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FoldersUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/memory_response.dart b/mobile/openapi/lib/model/memories_response.dart similarity index 62% rename from mobile/openapi/lib/model/memory_response.dart rename to mobile/openapi/lib/model/memories_response.dart index fb34bc1518876..e215a66a03f67 100644 --- a/mobile/openapi/lib/model/memory_response.dart +++ b/mobile/openapi/lib/model/memories_response.dart @@ -10,16 +10,16 @@ part of openapi.api; -class MemoryResponse { - /// Returns a new [MemoryResponse] instance. - MemoryResponse({ - required this.enabled, +class MemoriesResponse { + /// Returns a new [MemoriesResponse] instance. + MemoriesResponse({ + this.enabled = true, }); bool enabled; @override - bool operator ==(Object other) => identical(this, other) || other is MemoryResponse && + bool operator ==(Object other) => identical(this, other) || other is MemoriesResponse && other.enabled == enabled; @override @@ -28,7 +28,7 @@ class MemoryResponse { (enabled.hashCode); @override - String toString() => 'MemoryResponse[enabled=$enabled]'; + String toString() => 'MemoriesResponse[enabled=$enabled]'; Map toJson() { final json = {}; @@ -36,25 +36,25 @@ class MemoryResponse { return json; } - /// Returns a new [MemoryResponse] instance and imports its values from + /// Returns a new [MemoriesResponse] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static MemoryResponse? fromJson(dynamic value) { + static MemoriesResponse? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return MemoryResponse( + return MemoriesResponse( enabled: mapValueOfType(json, r'enabled')!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = MemoryResponse.fromJson(row); + final value = MemoriesResponse.fromJson(row); if (value != null) { result.add(value); } @@ -63,12 +63,12 @@ class MemoryResponse { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = MemoryResponse.fromJson(entry.value); + final value = MemoriesResponse.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -77,14 +77,14 @@ class MemoryResponse { return map; } - // maps a json object with a list of MemoryResponse-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of MemoriesResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = MemoryResponse.listFromJson(entry.value, growable: growable,); + map[entry.key] = MemoriesResponse.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/memory_update.dart b/mobile/openapi/lib/model/memories_update.dart similarity index 68% rename from mobile/openapi/lib/model/memory_update.dart rename to mobile/openapi/lib/model/memories_update.dart index f2529186c0432..d30949136197e 100644 --- a/mobile/openapi/lib/model/memory_update.dart +++ b/mobile/openapi/lib/model/memories_update.dart @@ -10,9 +10,9 @@ part of openapi.api; -class MemoryUpdate { - /// Returns a new [MemoryUpdate] instance. - MemoryUpdate({ +class MemoriesUpdate { + /// Returns a new [MemoriesUpdate] instance. + MemoriesUpdate({ this.enabled, }); @@ -25,7 +25,7 @@ class MemoryUpdate { bool? enabled; @override - bool operator ==(Object other) => identical(this, other) || other is MemoryUpdate && + bool operator ==(Object other) => identical(this, other) || other is MemoriesUpdate && other.enabled == enabled; @override @@ -34,7 +34,7 @@ class MemoryUpdate { (enabled == null ? 0 : enabled!.hashCode); @override - String toString() => 'MemoryUpdate[enabled=$enabled]'; + String toString() => 'MemoriesUpdate[enabled=$enabled]'; Map toJson() { final json = {}; @@ -46,25 +46,25 @@ class MemoryUpdate { return json; } - /// Returns a new [MemoryUpdate] instance and imports its values from + /// Returns a new [MemoriesUpdate] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static MemoryUpdate? fromJson(dynamic value) { + static MemoriesUpdate? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return MemoryUpdate( + return MemoriesUpdate( enabled: mapValueOfType(json, r'enabled'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = MemoryUpdate.fromJson(row); + final value = MemoriesUpdate.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +73,12 @@ class MemoryUpdate { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = MemoryUpdate.fromJson(entry.value); + final value = MemoriesUpdate.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +87,14 @@ class MemoryUpdate { return map; } - // maps a json object with a list of MemoryUpdate-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of MemoriesUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = MemoryUpdate.listFromJson(entry.value, growable: growable,); + map[entry.key] = MemoriesUpdate.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/people_response.dart b/mobile/openapi/lib/model/people_response.dart new file mode 100644 index 0000000000000..e12f86eeab5ba --- /dev/null +++ b/mobile/openapi/lib/model/people_response.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PeopleResponse { + /// Returns a new [PeopleResponse] instance. + PeopleResponse({ + this.enabled = true, + this.sidebarWeb = false, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is PeopleResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'PeopleResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [PeopleResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PeopleResponse? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return PeopleResponse( + enabled: mapValueOfType(json, r'enabled')!, + sidebarWeb: mapValueOfType(json, r'sidebarWeb')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PeopleResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PeopleResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PeopleResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PeopleResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'sidebarWeb', + }; +} + diff --git a/mobile/openapi/lib/model/people_update.dart b/mobile/openapi/lib/model/people_update.dart new file mode 100644 index 0000000000000..7803e6297036a --- /dev/null +++ b/mobile/openapi/lib/model/people_update.dart @@ -0,0 +1,124 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PeopleUpdate { + /// Returns a new [PeopleUpdate] instance. + PeopleUpdate({ + this.enabled, + this.sidebarWeb, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled == null ? 0 : enabled!.hashCode) + + (sidebarWeb == null ? 0 : sidebarWeb!.hashCode); + + @override + String toString() => 'PeopleUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + if (this.sidebarWeb != null) { + json[r'sidebarWeb'] = this.sidebarWeb; + } else { + // json[r'sidebarWeb'] = null; + } + return json; + } + + /// Returns a new [PeopleUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PeopleUpdate? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return PeopleUpdate( + enabled: mapValueOfType(json, r'enabled'), + sidebarWeb: mapValueOfType(json, r'sidebarWeb'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PeopleUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PeopleUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PeopleUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PeopleUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 30dc89a47ca45..1244a434b6ee7 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -36,7 +36,6 @@ class Permission { static const assetPeriodRead = Permission._(r'asset.read'); static const assetPeriodUpdate = Permission._(r'asset.update'); static const assetPeriodDelete = Permission._(r'asset.delete'); - static const assetPeriodRestore = Permission._(r'asset.restore'); static const assetPeriodShare = Permission._(r'asset.share'); static const assetPeriodView = Permission._(r'asset.view'); static const assetPeriodDownload = Permission._(r'asset.download'); @@ -78,10 +77,17 @@ class Permission { static const personPeriodStatistics = Permission._(r'person.statistics'); static const personPeriodMerge = Permission._(r'person.merge'); static const personPeriodReassign = Permission._(r'person.reassign'); + static const sessionPeriodRead = Permission._(r'session.read'); + static const sessionPeriodUpdate = Permission._(r'session.update'); + static const sessionPeriodDelete = Permission._(r'session.delete'); static const sharedLinkPeriodCreate = Permission._(r'sharedLink.create'); static const sharedLinkPeriodRead = Permission._(r'sharedLink.read'); static const sharedLinkPeriodUpdate = Permission._(r'sharedLink.update'); static const sharedLinkPeriodDelete = Permission._(r'sharedLink.delete'); + static const stackPeriodCreate = Permission._(r'stack.create'); + static const stackPeriodRead = Permission._(r'stack.read'); + static const stackPeriodUpdate = Permission._(r'stack.update'); + static const stackPeriodDelete = Permission._(r'stack.delete'); static const systemConfigPeriodRead = Permission._(r'systemConfig.read'); static const systemConfigPeriodUpdate = Permission._(r'systemConfig.update'); static const systemMetadataPeriodRead = Permission._(r'systemMetadata.read'); @@ -90,6 +96,7 @@ class Permission { static const tagPeriodRead = Permission._(r'tag.read'); static const tagPeriodUpdate = Permission._(r'tag.update'); static const tagPeriodDelete = Permission._(r'tag.delete'); + static const tagPeriodAsset = Permission._(r'tag.asset'); static const adminPeriodUserPeriodCreate = Permission._(r'admin.user.create'); static const adminPeriodUserPeriodRead = Permission._(r'admin.user.read'); static const adminPeriodUserPeriodUpdate = Permission._(r'admin.user.update'); @@ -110,7 +117,6 @@ class Permission { assetPeriodRead, assetPeriodUpdate, assetPeriodDelete, - assetPeriodRestore, assetPeriodShare, assetPeriodView, assetPeriodDownload, @@ -152,10 +158,17 @@ class Permission { personPeriodStatistics, personPeriodMerge, personPeriodReassign, + sessionPeriodRead, + sessionPeriodUpdate, + sessionPeriodDelete, sharedLinkPeriodCreate, sharedLinkPeriodRead, sharedLinkPeriodUpdate, sharedLinkPeriodDelete, + stackPeriodCreate, + stackPeriodRead, + stackPeriodUpdate, + stackPeriodDelete, systemConfigPeriodRead, systemConfigPeriodUpdate, systemMetadataPeriodRead, @@ -164,6 +177,7 @@ class Permission { tagPeriodRead, tagPeriodUpdate, tagPeriodDelete, + tagPeriodAsset, adminPeriodUserPeriodCreate, adminPeriodUserPeriodRead, adminPeriodUserPeriodUpdate, @@ -219,7 +233,6 @@ class PermissionTypeTransformer { case r'asset.read': return Permission.assetPeriodRead; case r'asset.update': return Permission.assetPeriodUpdate; case r'asset.delete': return Permission.assetPeriodDelete; - case r'asset.restore': return Permission.assetPeriodRestore; case r'asset.share': return Permission.assetPeriodShare; case r'asset.view': return Permission.assetPeriodView; case r'asset.download': return Permission.assetPeriodDownload; @@ -261,10 +274,17 @@ class PermissionTypeTransformer { case r'person.statistics': return Permission.personPeriodStatistics; case r'person.merge': return Permission.personPeriodMerge; case r'person.reassign': return Permission.personPeriodReassign; + case r'session.read': return Permission.sessionPeriodRead; + case r'session.update': return Permission.sessionPeriodUpdate; + case r'session.delete': return Permission.sessionPeriodDelete; case r'sharedLink.create': return Permission.sharedLinkPeriodCreate; case r'sharedLink.read': return Permission.sharedLinkPeriodRead; case r'sharedLink.update': return Permission.sharedLinkPeriodUpdate; case r'sharedLink.delete': return Permission.sharedLinkPeriodDelete; + case r'stack.create': return Permission.stackPeriodCreate; + case r'stack.read': return Permission.stackPeriodRead; + case r'stack.update': return Permission.stackPeriodUpdate; + case r'stack.delete': return Permission.stackPeriodDelete; case r'systemConfig.read': return Permission.systemConfigPeriodRead; case r'systemConfig.update': return Permission.systemConfigPeriodUpdate; case r'systemMetadata.read': return Permission.systemMetadataPeriodRead; @@ -273,6 +293,7 @@ class PermissionTypeTransformer { case r'tag.read': return Permission.tagPeriodRead; case r'tag.update': return Permission.tagPeriodUpdate; case r'tag.delete': return Permission.tagPeriodDelete; + case r'tag.asset': return Permission.tagPeriodAsset; case r'admin.user.create': return Permission.adminPeriodUserPeriodCreate; case r'admin.user.read': return Permission.adminPeriodUserPeriodRead; case r'admin.user.update': return Permission.adminPeriodUserPeriodUpdate; diff --git a/mobile/openapi/lib/model/rating_response.dart b/mobile/openapi/lib/model/ratings_response.dart similarity index 63% rename from mobile/openapi/lib/model/rating_response.dart rename to mobile/openapi/lib/model/ratings_response.dart index 31505550eff9c..c8791aa91a5ee 100644 --- a/mobile/openapi/lib/model/rating_response.dart +++ b/mobile/openapi/lib/model/ratings_response.dart @@ -10,16 +10,16 @@ part of openapi.api; -class RatingResponse { - /// Returns a new [RatingResponse] instance. - RatingResponse({ +class RatingsResponse { + /// Returns a new [RatingsResponse] instance. + RatingsResponse({ this.enabled = false, }); bool enabled; @override - bool operator ==(Object other) => identical(this, other) || other is RatingResponse && + bool operator ==(Object other) => identical(this, other) || other is RatingsResponse && other.enabled == enabled; @override @@ -28,7 +28,7 @@ class RatingResponse { (enabled.hashCode); @override - String toString() => 'RatingResponse[enabled=$enabled]'; + String toString() => 'RatingsResponse[enabled=$enabled]'; Map toJson() { final json = {}; @@ -36,25 +36,25 @@ class RatingResponse { return json; } - /// Returns a new [RatingResponse] instance and imports its values from + /// Returns a new [RatingsResponse] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static RatingResponse? fromJson(dynamic value) { + static RatingsResponse? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return RatingResponse( + return RatingsResponse( enabled: mapValueOfType(json, r'enabled')!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = RatingResponse.fromJson(row); + final value = RatingsResponse.fromJson(row); if (value != null) { result.add(value); } @@ -63,12 +63,12 @@ class RatingResponse { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = RatingResponse.fromJson(entry.value); + final value = RatingsResponse.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -77,14 +77,14 @@ class RatingResponse { return map; } - // maps a json object with a list of RatingResponse-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of RatingsResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = RatingResponse.listFromJson(entry.value, growable: growable,); + map[entry.key] = RatingsResponse.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/rating_update.dart b/mobile/openapi/lib/model/ratings_update.dart similarity index 69% rename from mobile/openapi/lib/model/rating_update.dart rename to mobile/openapi/lib/model/ratings_update.dart index bb8f7eadc2f55..bde51bad1b360 100644 --- a/mobile/openapi/lib/model/rating_update.dart +++ b/mobile/openapi/lib/model/ratings_update.dart @@ -10,9 +10,9 @@ part of openapi.api; -class RatingUpdate { - /// Returns a new [RatingUpdate] instance. - RatingUpdate({ +class RatingsUpdate { + /// Returns a new [RatingsUpdate] instance. + RatingsUpdate({ this.enabled, }); @@ -25,7 +25,7 @@ class RatingUpdate { bool? enabled; @override - bool operator ==(Object other) => identical(this, other) || other is RatingUpdate && + bool operator ==(Object other) => identical(this, other) || other is RatingsUpdate && other.enabled == enabled; @override @@ -34,7 +34,7 @@ class RatingUpdate { (enabled == null ? 0 : enabled!.hashCode); @override - String toString() => 'RatingUpdate[enabled=$enabled]'; + String toString() => 'RatingsUpdate[enabled=$enabled]'; Map toJson() { final json = {}; @@ -46,25 +46,25 @@ class RatingUpdate { return json; } - /// Returns a new [RatingUpdate] instance and imports its values from + /// Returns a new [RatingsUpdate] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static RatingUpdate? fromJson(dynamic value) { + static RatingsUpdate? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return RatingUpdate( + return RatingsUpdate( enabled: mapValueOfType(json, r'enabled'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = RatingUpdate.fromJson(row); + final value = RatingsUpdate.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +73,12 @@ class RatingUpdate { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = RatingUpdate.fromJson(entry.value); + final value = RatingsUpdate.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +87,14 @@ class RatingUpdate { return map; } - // maps a json object with a list of RatingUpdate-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of RatingsUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = RatingUpdate.listFromJson(entry.value, growable: growable,); + map[entry.key] = RatingsUpdate.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/stack_create_dto.dart b/mobile/openapi/lib/model/stack_create_dto.dart new file mode 100644 index 0000000000000..9b37bc6e2e9aa --- /dev/null +++ b/mobile/openapi/lib/model/stack_create_dto.dart @@ -0,0 +1,101 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class StackCreateDto { + /// Returns a new [StackCreateDto] instance. + StackCreateDto({ + this.assetIds = const [], + }); + + /// first asset becomes the primary + List assetIds; + + @override + bool operator ==(Object other) => identical(this, other) || other is StackCreateDto && + _deepEquality.equals(other.assetIds, assetIds); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode); + + @override + String toString() => 'StackCreateDto[assetIds=$assetIds]'; + + Map toJson() { + final json = {}; + json[r'assetIds'] = this.assetIds; + return json; + } + + /// Returns a new [StackCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static StackCreateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return StackCreateDto( + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = StackCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = StackCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of StackCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = StackCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetIds', + }; +} + diff --git a/mobile/openapi/lib/model/stack_response_dto.dart b/mobile/openapi/lib/model/stack_response_dto.dart new file mode 100644 index 0000000000000..3d0aaf91d17cc --- /dev/null +++ b/mobile/openapi/lib/model/stack_response_dto.dart @@ -0,0 +1,114 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class StackResponseDto { + /// Returns a new [StackResponseDto] instance. + StackResponseDto({ + this.assets = const [], + required this.id, + required this.primaryAssetId, + }); + + List assets; + + String id; + + String primaryAssetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is StackResponseDto && + _deepEquality.equals(other.assets, assets) && + other.id == id && + other.primaryAssetId == primaryAssetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assets.hashCode) + + (id.hashCode) + + (primaryAssetId.hashCode); + + @override + String toString() => 'StackResponseDto[assets=$assets, id=$id, primaryAssetId=$primaryAssetId]'; + + Map toJson() { + final json = {}; + json[r'assets'] = this.assets; + json[r'id'] = this.id; + json[r'primaryAssetId'] = this.primaryAssetId; + return json; + } + + /// Returns a new [StackResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static StackResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return StackResponseDto( + assets: AssetResponseDto.listFromJson(json[r'assets']), + id: mapValueOfType(json, r'id')!, + primaryAssetId: mapValueOfType(json, r'primaryAssetId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = StackResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = StackResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of StackResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = StackResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assets', + 'id', + 'primaryAssetId', + }; +} + diff --git a/mobile/openapi/lib/model/update_tag_dto.dart b/mobile/openapi/lib/model/stack_update_dto.dart similarity index 58% rename from mobile/openapi/lib/model/update_tag_dto.dart rename to mobile/openapi/lib/model/stack_update_dto.dart index dfa9b8cfc078c..0e9712721048a 100644 --- a/mobile/openapi/lib/model/update_tag_dto.dart +++ b/mobile/openapi/lib/model/stack_update_dto.dart @@ -10,10 +10,10 @@ part of openapi.api; -class UpdateTagDto { - /// Returns a new [UpdateTagDto] instance. - UpdateTagDto({ - this.name, +class StackUpdateDto { + /// Returns a new [StackUpdateDto] instance. + StackUpdateDto({ + this.primaryAssetId, }); /// @@ -22,49 +22,49 @@ class UpdateTagDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - String? name; + String? primaryAssetId; @override - bool operator ==(Object other) => identical(this, other) || other is UpdateTagDto && - other.name == name; + bool operator ==(Object other) => identical(this, other) || other is StackUpdateDto && + other.primaryAssetId == primaryAssetId; @override int get hashCode => // ignore: unnecessary_parenthesis - (name == null ? 0 : name!.hashCode); + (primaryAssetId == null ? 0 : primaryAssetId!.hashCode); @override - String toString() => 'UpdateTagDto[name=$name]'; + String toString() => 'StackUpdateDto[primaryAssetId=$primaryAssetId]'; Map toJson() { final json = {}; - if (this.name != null) { - json[r'name'] = this.name; + if (this.primaryAssetId != null) { + json[r'primaryAssetId'] = this.primaryAssetId; } else { - // json[r'name'] = null; + // json[r'primaryAssetId'] = null; } return json; } - /// Returns a new [UpdateTagDto] instance and imports its values from + /// Returns a new [StackUpdateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static UpdateTagDto? fromJson(dynamic value) { + static StackUpdateDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return UpdateTagDto( - name: mapValueOfType(json, r'name'), + return StackUpdateDto( + primaryAssetId: mapValueOfType(json, r'primaryAssetId'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = UpdateTagDto.fromJson(row); + final value = StackUpdateDto.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +73,12 @@ class UpdateTagDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = UpdateTagDto.fromJson(entry.value); + final value = StackUpdateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +87,14 @@ class UpdateTagDto { return map; } - // maps a json object with a list of UpdateTagDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of StackUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = UpdateTagDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = StackUpdateDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart new file mode 100644 index 0000000000000..c11cb66ce081f --- /dev/null +++ b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart @@ -0,0 +1,110 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagBulkAssetsDto { + /// Returns a new [TagBulkAssetsDto] instance. + TagBulkAssetsDto({ + this.assetIds = const [], + this.tagIds = const [], + }); + + List assetIds; + + List tagIds; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagBulkAssetsDto && + _deepEquality.equals(other.assetIds, assetIds) && + _deepEquality.equals(other.tagIds, tagIds); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode) + + (tagIds.hashCode); + + @override + String toString() => 'TagBulkAssetsDto[assetIds=$assetIds, tagIds=$tagIds]'; + + Map toJson() { + final json = {}; + json[r'assetIds'] = this.assetIds; + json[r'tagIds'] = this.tagIds; + return json; + } + + /// Returns a new [TagBulkAssetsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagBulkAssetsDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagBulkAssetsDto( + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagBulkAssetsDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagBulkAssetsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagBulkAssetsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagBulkAssetsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetIds', + 'tagIds', + }; +} + diff --git a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart new file mode 100644 index 0000000000000..d4dcb91d8c45d --- /dev/null +++ b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagBulkAssetsResponseDto { + /// Returns a new [TagBulkAssetsResponseDto] instance. + TagBulkAssetsResponseDto({ + required this.count, + }); + + int count; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagBulkAssetsResponseDto && + other.count == count; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (count.hashCode); + + @override + String toString() => 'TagBulkAssetsResponseDto[count=$count]'; + + Map toJson() { + final json = {}; + json[r'count'] = this.count; + return json; + } + + /// Returns a new [TagBulkAssetsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagBulkAssetsResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagBulkAssetsResponseDto( + count: mapValueOfType(json, r'count')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagBulkAssetsResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagBulkAssetsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagBulkAssetsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagBulkAssetsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'count', + }; +} + diff --git a/mobile/openapi/lib/model/tag_create_dto.dart b/mobile/openapi/lib/model/tag_create_dto.dart new file mode 100644 index 0000000000000..dd7e537a0a021 --- /dev/null +++ b/mobile/openapi/lib/model/tag_create_dto.dart @@ -0,0 +1,126 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagCreateDto { + /// Returns a new [TagCreateDto] instance. + TagCreateDto({ + this.color, + required this.name, + this.parentId, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? color; + + String name; + + String? parentId; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagCreateDto && + other.color == color && + other.name == name && + other.parentId == parentId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (color == null ? 0 : color!.hashCode) + + (name.hashCode) + + (parentId == null ? 0 : parentId!.hashCode); + + @override + String toString() => 'TagCreateDto[color=$color, name=$name, parentId=$parentId]'; + + Map toJson() { + final json = {}; + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } + json[r'name'] = this.name; + if (this.parentId != null) { + json[r'parentId'] = this.parentId; + } else { + // json[r'parentId'] = null; + } + return json; + } + + /// Returns a new [TagCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagCreateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagCreateDto( + color: mapValueOfType(json, r'color'), + name: mapValueOfType(json, r'name')!, + parentId: mapValueOfType(json, r'parentId'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'name', + }; +} + diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index d371bd1c0473d..1d1a88c3cff29 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -13,44 +13,82 @@ part of openapi.api; class TagResponseDto { /// Returns a new [TagResponseDto] instance. TagResponseDto({ + this.color, + required this.createdAt, required this.id, required this.name, - required this.type, - required this.userId, + this.parentId, + required this.updatedAt, + required this.value, }); + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? color; + + DateTime createdAt; + String id; String name; - TagTypeEnum type; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? parentId; - String userId; + DateTime updatedAt; + + String value; @override bool operator ==(Object other) => identical(this, other) || other is TagResponseDto && + other.color == color && + other.createdAt == createdAt && other.id == id && other.name == name && - other.type == type && - other.userId == userId; + other.parentId == parentId && + other.updatedAt == updatedAt && + other.value == value; @override int get hashCode => // ignore: unnecessary_parenthesis + (color == null ? 0 : color!.hashCode) + + (createdAt.hashCode) + (id.hashCode) + (name.hashCode) + - (type.hashCode) + - (userId.hashCode); + (parentId == null ? 0 : parentId!.hashCode) + + (updatedAt.hashCode) + + (value.hashCode); @override - String toString() => 'TagResponseDto[id=$id, name=$name, type=$type, userId=$userId]'; + String toString() => 'TagResponseDto[color=$color, createdAt=$createdAt, id=$id, name=$name, parentId=$parentId, updatedAt=$updatedAt, value=$value]'; Map toJson() { final json = {}; + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'id'] = this.id; json[r'name'] = this.name; - json[r'type'] = this.type; - json[r'userId'] = this.userId; + if (this.parentId != null) { + json[r'parentId'] = this.parentId; + } else { + // json[r'parentId'] = null; + } + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'value'] = this.value; return json; } @@ -62,10 +100,13 @@ class TagResponseDto { final json = value.cast(); return TagResponseDto( + color: mapValueOfType(json, r'color'), + createdAt: mapDateTime(json, r'createdAt', r'')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, - type: TagTypeEnum.fromJson(json[r'type'])!, - userId: mapValueOfType(json, r'userId')!, + parentId: mapValueOfType(json, r'parentId'), + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + value: mapValueOfType(json, r'value')!, ); } return null; @@ -113,10 +154,11 @@ class TagResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'createdAt', 'id', 'name', - 'type', - 'userId', + 'updatedAt', + 'value', }; } diff --git a/mobile/openapi/lib/model/tag_type_enum.dart b/mobile/openapi/lib/model/tag_type_enum.dart deleted file mode 100644 index 3f2e723796b81..0000000000000 --- a/mobile/openapi/lib/model/tag_type_enum.dart +++ /dev/null @@ -1,88 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class TagTypeEnum { - /// Instantiate a new enum with the provided [value]. - const TagTypeEnum._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const OBJECT = TagTypeEnum._(r'OBJECT'); - static const FACE = TagTypeEnum._(r'FACE'); - static const CUSTOM = TagTypeEnum._(r'CUSTOM'); - - /// List of all possible values in this [enum][TagTypeEnum]. - static const values = [ - OBJECT, - FACE, - CUSTOM, - ]; - - static TagTypeEnum? fromJson(dynamic value) => TagTypeEnumTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = TagTypeEnum.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [TagTypeEnum] to String, -/// and [decode] dynamic data back to [TagTypeEnum]. -class TagTypeEnumTypeTransformer { - factory TagTypeEnumTypeTransformer() => _instance ??= const TagTypeEnumTypeTransformer._(); - - const TagTypeEnumTypeTransformer._(); - - String encode(TagTypeEnum data) => data.value; - - /// Decodes a [dynamic value][data] to a TagTypeEnum. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - TagTypeEnum? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'OBJECT': return TagTypeEnum.OBJECT; - case r'FACE': return TagTypeEnum.FACE; - case r'CUSTOM': return TagTypeEnum.CUSTOM; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [TagTypeEnumTypeTransformer] instance. - static TagTypeEnumTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/create_tag_dto.dart b/mobile/openapi/lib/model/tag_update_dto.dart similarity index 57% rename from mobile/openapi/lib/model/create_tag_dto.dart rename to mobile/openapi/lib/model/tag_update_dto.dart index 31b194993d22d..661f65896e56f 100644 --- a/mobile/openapi/lib/model/create_tag_dto.dart +++ b/mobile/openapi/lib/model/tag_update_dto.dart @@ -10,58 +10,55 @@ part of openapi.api; -class CreateTagDto { - /// Returns a new [CreateTagDto] instance. - CreateTagDto({ - required this.name, - required this.type, +class TagUpdateDto { + /// Returns a new [TagUpdateDto] instance. + TagUpdateDto({ + this.color, }); - String name; - - TagTypeEnum type; + String? color; @override - bool operator ==(Object other) => identical(this, other) || other is CreateTagDto && - other.name == name && - other.type == type; + bool operator ==(Object other) => identical(this, other) || other is TagUpdateDto && + other.color == color; @override int get hashCode => // ignore: unnecessary_parenthesis - (name.hashCode) + - (type.hashCode); + (color == null ? 0 : color!.hashCode); @override - String toString() => 'CreateTagDto[name=$name, type=$type]'; + String toString() => 'TagUpdateDto[color=$color]'; Map toJson() { final json = {}; - json[r'name'] = this.name; - json[r'type'] = this.type; + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } return json; } - /// Returns a new [CreateTagDto] instance and imports its values from + /// Returns a new [TagUpdateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static CreateTagDto? fromJson(dynamic value) { + static TagUpdateDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return CreateTagDto( - name: mapValueOfType(json, r'name')!, - type: TagTypeEnum.fromJson(json[r'type'])!, + return TagUpdateDto( + color: mapValueOfType(json, r'color'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = CreateTagDto.fromJson(row); + final value = TagUpdateDto.fromJson(row); if (value != null) { result.add(value); } @@ -70,12 +67,12 @@ class CreateTagDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = CreateTagDto.fromJson(entry.value); + final value = TagUpdateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -84,14 +81,14 @@ class CreateTagDto { return map; } - // maps a json object with a list of CreateTagDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of TagUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = CreateTagDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = TagUpdateDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -99,8 +96,6 @@ class CreateTagDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'name', - 'type', }; } diff --git a/mobile/openapi/lib/model/tag_upsert_dto.dart b/mobile/openapi/lib/model/tag_upsert_dto.dart new file mode 100644 index 0000000000000..941d25b6aee6c --- /dev/null +++ b/mobile/openapi/lib/model/tag_upsert_dto.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagUpsertDto { + /// Returns a new [TagUpsertDto] instance. + TagUpsertDto({ + this.tags = const [], + }); + + List tags; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagUpsertDto && + _deepEquality.equals(other.tags, tags); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (tags.hashCode); + + @override + String toString() => 'TagUpsertDto[tags=$tags]'; + + Map toJson() { + final json = {}; + json[r'tags'] = this.tags; + return json; + } + + /// Returns a new [TagUpsertDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagUpsertDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagUpsertDto( + tags: json[r'tags'] is Iterable + ? (json[r'tags'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagUpsertDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagUpsertDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagUpsertDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagUpsertDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'tags', + }; +} + diff --git a/mobile/openapi/lib/model/tags_response.dart b/mobile/openapi/lib/model/tags_response.dart new file mode 100644 index 0000000000000..3a5ea3b20b3ec --- /dev/null +++ b/mobile/openapi/lib/model/tags_response.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagsResponse { + /// Returns a new [TagsResponse] instance. + TagsResponse({ + this.enabled = true, + this.sidebarWeb = true, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagsResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'TagsResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [TagsResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagsResponse? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagsResponse( + enabled: mapValueOfType(json, r'enabled')!, + sidebarWeb: mapValueOfType(json, r'sidebarWeb')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagsResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagsResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagsResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagsResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'sidebarWeb', + }; +} + diff --git a/mobile/openapi/lib/model/tags_update.dart b/mobile/openapi/lib/model/tags_update.dart new file mode 100644 index 0000000000000..8355b00a00d49 --- /dev/null +++ b/mobile/openapi/lib/model/tags_update.dart @@ -0,0 +1,124 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagsUpdate { + /// Returns a new [TagsUpdate] instance. + TagsUpdate({ + this.enabled, + this.sidebarWeb, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagsUpdate && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled == null ? 0 : enabled!.hashCode) + + (sidebarWeb == null ? 0 : sidebarWeb!.hashCode); + + @override + String toString() => 'TagsUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + if (this.sidebarWeb != null) { + json[r'sidebarWeb'] = this.sidebarWeb; + } else { + // json[r'sidebarWeb'] = null; + } + return json; + } + + /// Returns a new [TagsUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagsUpdate? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagsUpdate( + enabled: mapValueOfType(json, r'enabled'), + sidebarWeb: mapValueOfType(json, r'sidebarWeb'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagsUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagsUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagsUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagsUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/update_stack_parent_dto.dart b/mobile/openapi/lib/model/update_stack_parent_dto.dart deleted file mode 100644 index 4247c2e29fe73..0000000000000 --- a/mobile/openapi/lib/model/update_stack_parent_dto.dart +++ /dev/null @@ -1,106 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class UpdateStackParentDto { - /// Returns a new [UpdateStackParentDto] instance. - UpdateStackParentDto({ - required this.newParentId, - required this.oldParentId, - }); - - String newParentId; - - String oldParentId; - - @override - bool operator ==(Object other) => identical(this, other) || other is UpdateStackParentDto && - other.newParentId == newParentId && - other.oldParentId == oldParentId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (newParentId.hashCode) + - (oldParentId.hashCode); - - @override - String toString() => 'UpdateStackParentDto[newParentId=$newParentId, oldParentId=$oldParentId]'; - - Map toJson() { - final json = {}; - json[r'newParentId'] = this.newParentId; - json[r'oldParentId'] = this.oldParentId; - return json; - } - - /// Returns a new [UpdateStackParentDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static UpdateStackParentDto? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - - return UpdateStackParentDto( - newParentId: mapValueOfType(json, r'newParentId')!, - oldParentId: mapValueOfType(json, r'oldParentId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = UpdateStackParentDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = UpdateStackParentDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of UpdateStackParentDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = UpdateStackParentDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'newParentId', - 'oldParentId', - }; -} - diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 6401a36f9fda2..d3927df8d7ee3 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -16,9 +16,12 @@ class UserPreferencesResponseDto { required this.avatar, required this.download, required this.emailNotifications, + required this.folders, required this.memories, + required this.people, required this.purchase, - required this.rating, + required this.ratings, + required this.tags, }); AvatarResponse avatar; @@ -27,20 +30,29 @@ class UserPreferencesResponseDto { EmailNotificationsResponse emailNotifications; - MemoryResponse memories; + FoldersResponse folders; + + MemoriesResponse memories; + + PeopleResponse people; PurchaseResponse purchase; - RatingResponse rating; + RatingsResponse ratings; + + TagsResponse tags; @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && other.avatar == avatar && other.download == download && other.emailNotifications == emailNotifications && + other.folders == folders && other.memories == memories && + other.people == people && other.purchase == purchase && - other.rating == rating; + other.ratings == ratings && + other.tags == tags; @override int get hashCode => @@ -48,21 +60,27 @@ class UserPreferencesResponseDto { (avatar.hashCode) + (download.hashCode) + (emailNotifications.hashCode) + + (folders.hashCode) + (memories.hashCode) + + (people.hashCode) + (purchase.hashCode) + - (rating.hashCode); + (ratings.hashCode) + + (tags.hashCode); @override - String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories, purchase=$purchase, rating=$rating]'; + String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]'; Map toJson() { final json = {}; json[r'avatar'] = this.avatar; json[r'download'] = this.download; json[r'emailNotifications'] = this.emailNotifications; + json[r'folders'] = this.folders; json[r'memories'] = this.memories; + json[r'people'] = this.people; json[r'purchase'] = this.purchase; - json[r'rating'] = this.rating; + json[r'ratings'] = this.ratings; + json[r'tags'] = this.tags; return json; } @@ -77,9 +95,12 @@ class UserPreferencesResponseDto { avatar: AvatarResponse.fromJson(json[r'avatar'])!, download: DownloadResponse.fromJson(json[r'download'])!, emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, - memories: MemoryResponse.fromJson(json[r'memories'])!, + folders: FoldersResponse.fromJson(json[r'folders'])!, + memories: MemoriesResponse.fromJson(json[r'memories'])!, + people: PeopleResponse.fromJson(json[r'people'])!, purchase: PurchaseResponse.fromJson(json[r'purchase'])!, - rating: RatingResponse.fromJson(json[r'rating'])!, + ratings: RatingsResponse.fromJson(json[r'ratings'])!, + tags: TagsResponse.fromJson(json[r'tags'])!, ); } return null; @@ -130,9 +151,12 @@ class UserPreferencesResponseDto { 'avatar', 'download', 'emailNotifications', + 'folders', 'memories', + 'people', 'purchase', - 'rating', + 'ratings', + 'tags', }; } diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index cf55aebf97df7..2841c2f572c11 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -16,9 +16,12 @@ class UserPreferencesUpdateDto { this.avatar, this.download, this.emailNotifications, + this.folders, this.memories, + this.people, this.purchase, - this.rating, + this.ratings, + this.tags, }); /// @@ -51,7 +54,23 @@ class UserPreferencesUpdateDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - MemoryUpdate? memories; + FoldersUpdate? folders; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + MemoriesUpdate? memories; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + PeopleUpdate? people; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -67,16 +86,27 @@ class UserPreferencesUpdateDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - RatingUpdate? rating; + RatingsUpdate? ratings; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + TagsUpdate? tags; @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto && other.avatar == avatar && other.download == download && other.emailNotifications == emailNotifications && + other.folders == folders && other.memories == memories && + other.people == people && other.purchase == purchase && - other.rating == rating; + other.ratings == ratings && + other.tags == tags; @override int get hashCode => @@ -84,12 +114,15 @@ class UserPreferencesUpdateDto { (avatar == null ? 0 : avatar!.hashCode) + (download == null ? 0 : download!.hashCode) + (emailNotifications == null ? 0 : emailNotifications!.hashCode) + + (folders == null ? 0 : folders!.hashCode) + (memories == null ? 0 : memories!.hashCode) + + (people == null ? 0 : people!.hashCode) + (purchase == null ? 0 : purchase!.hashCode) + - (rating == null ? 0 : rating!.hashCode); + (ratings == null ? 0 : ratings!.hashCode) + + (tags == null ? 0 : tags!.hashCode); @override - String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories, purchase=$purchase, rating=$rating]'; + String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]'; Map toJson() { final json = {}; @@ -108,20 +141,35 @@ class UserPreferencesUpdateDto { } else { // json[r'emailNotifications'] = null; } + if (this.folders != null) { + json[r'folders'] = this.folders; + } else { + // json[r'folders'] = null; + } if (this.memories != null) { json[r'memories'] = this.memories; } else { // json[r'memories'] = null; } + if (this.people != null) { + json[r'people'] = this.people; + } else { + // json[r'people'] = null; + } if (this.purchase != null) { json[r'purchase'] = this.purchase; } else { // json[r'purchase'] = null; } - if (this.rating != null) { - json[r'rating'] = this.rating; + if (this.ratings != null) { + json[r'ratings'] = this.ratings; } else { - // json[r'rating'] = null; + // json[r'ratings'] = null; + } + if (this.tags != null) { + json[r'tags'] = this.tags; + } else { + // json[r'tags'] = null; } return json; } @@ -137,9 +185,12 @@ class UserPreferencesUpdateDto { avatar: AvatarUpdate.fromJson(json[r'avatar']), download: DownloadUpdate.fromJson(json[r'download']), emailNotifications: EmailNotificationsUpdate.fromJson(json[r'emailNotifications']), - memories: MemoryUpdate.fromJson(json[r'memories']), + folders: FoldersUpdate.fromJson(json[r'folders']), + memories: MemoriesUpdate.fromJson(json[r'memories']), + people: PeopleUpdate.fromJson(json[r'people']), purchase: PurchaseUpdate.fromJson(json[r'purchase']), - rating: RatingUpdate.fromJson(json[r'rating']), + ratings: RatingsUpdate.fromJson(json[r'ratings']), + tags: TagsUpdate.fromJson(json[r'tags']), ); } return null; diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 51a31a24e3aa6..7b31e4f231b9d 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.112.1+154 +version: 1.113.0+156 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index b173dd2ac5b9b..26108d63b2f46 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -17,7 +17,6 @@ final class AssetStub { isFavorite: true, isArchived: false, isTrashed: false, - stackCount: 0, ); static final image2 = Asset( @@ -34,6 +33,5 @@ final class AssetStub { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ); } diff --git a/mobile/test/modules/extensions/asset_extensions_test.dart b/mobile/test/modules/extensions/asset_extensions_test.dart index b90879acc7f70..d2b9b93d6274a 100644 --- a/mobile/test/modules/extensions/asset_extensions_test.dart +++ b/mobile/test/modules/extensions/asset_extensions_test.dart @@ -34,7 +34,6 @@ Asset makeAsset({ isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, exifInfo: exifInfo, ); } diff --git a/mobile/test/modules/home/asset_grid_data_structure_test.dart b/mobile/test/modules/home/asset_grid_data_structure_test.dart index f12b9b219021a..b4ee85196986d 100644 --- a/mobile/test/modules/home/asset_grid_data_structure_test.dart +++ b/mobile/test/modules/home/asset_grid_data_structure_test.dart @@ -25,7 +25,6 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ), ); } diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 24f0c443ba833..07437289beeff 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -32,7 +32,6 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ); } diff --git a/mobile/test/modules/utils/openapi_patching_test.dart b/mobile/test/modules/utils/openapi_patching_test.dart new file mode 100644 index 0000000000000..b956c4bfb9d80 --- /dev/null +++ b/mobile/test/modules/utils/openapi_patching_test.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openapi/api.dart'; +import 'package:immich_mobile/utils/openapi_patching.dart'; + +void main() { + group('Test OpenApi Patching', () { + test('upgradeDto', () { + dynamic value; + String targetType; + + targetType = 'UserPreferencesResponseDto'; + value = jsonDecode(""" +{ + "download": { + "archiveSize": 4294967296, + "includeEmbeddedVideos": false + } +} +"""); + + upgradeDto(value, targetType); + expect(value['tags'], TagsResponse().toJson()); + expect(value['download']['includeEmbeddedVideos'], false); + }); + + test('addDefault', () { + dynamic value = jsonDecode(""" +{ + "download": { + "archiveSize": 4294967296, + "includeEmbeddedVideos": false + } +} +"""); + String keys = 'download.unknownKey'; + dynamic defaultValue = 69420; + + addDefault(value, keys, defaultValue); + expect(value['download']['unknownKey'], 69420); + + keys = 'alpha.beta'; + defaultValue = 'gamma'; + addDefault(value, keys, defaultValue); + expect(value['alpha']['beta'], 'gamma'); + }); + }); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0d0793c263aae..0b0e40b478df4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -660,16 +660,16 @@ ] } }, - "/albums/count": { + "/albums/statistics": { "get": { - "operationId": "getAlbumCount", + "operationId": "getAlbumStatistics", "parameters": [], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlbumCountResponseDto" + "$ref": "#/components/schemas/AlbumStatisticsResponseDto" } } }, @@ -1689,41 +1689,6 @@ ] } }, - "/assets/stack/parent": { - "put": { - "operationId": "updateStackParent", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateStackParentDto" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Assets" - ] - } - }, "/assets/statistics": { "get": { "operationId": "getAssetStatistics", @@ -3678,7 +3643,7 @@ "operationId": "unlinkOAuthAccount", "parameters": [], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { @@ -4806,10 +4771,8 @@ ] } }, - "/server-info/about": { + "/server/about": { "get": { - "deprecated": true, - "description": "This property was deprecated in v1.107.0", "operationId": "getAboutInfo", "parameters": [], "responses": { @@ -4836,18 +4799,12 @@ } ], "tags": [ - "Server Info", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.107.0" - } + "Server" + ] } }, - "/server-info/config": { + "/server/config": { "get": { - "deprecated": true, - "description": "This property was deprecated in v1.107.0", "operationId": "getServerConfig", "parameters": [], "responses": { @@ -4863,18 +4820,12 @@ } }, "tags": [ - "Server Info", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.107.0" - } + "Server" + ] } }, - "/server-info/features": { + "/server/features": { "get": { - "deprecated": true, - "description": "This property was deprecated in v1.107.0", "operationId": "getServerFeatures", "parameters": [], "responses": { @@ -4890,196 +4841,8 @@ } }, "tags": [ - "Server Info", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.107.0" - } - } - }, - "/server-info/media-types": { - "get": { - "deprecated": true, - "description": "This property was deprecated in v1.107.0", - "operationId": "getSupportedMediaTypes", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerMediaTypesResponseDto" - } - } - }, - "description": "" - } - }, - "tags": [ - "Server Info", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.107.0" - } - } - }, - "/server-info/ping": { - "get": { - "deprecated": true, - "description": "This property was deprecated in v1.107.0", - "operationId": "pingServer", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerPingResponse" - } - } - }, - "description": "" - } - }, - "tags": [ - "Server Info", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.107.0" - } - } - }, - "/server-info/statistics": { - "get": { - "deprecated": true, - "description": "This property was deprecated in v1.107.0", - "operationId": "getServerStatistics", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerStatsResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Server Info", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.107.0" - } - } - }, - "/server-info/storage": { - "get": { - "deprecated": true, - "description": "This property was deprecated in v1.107.0", - "operationId": "getStorage", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerStorageResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Server Info", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.107.0" - } - } - }, - "/server-info/theme": { - "get": { - "deprecated": true, - "description": "This property was deprecated in v1.107.0", - "operationId": "getTheme", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerThemeDto" - } - } - }, - "description": "" - } - }, - "tags": [ - "Server Info", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.107.0" - } - } - }, - "/server-info/version": { - "get": { - "deprecated": true, - "description": "This property was deprecated in v1.107.0", - "operationId": "getServerVersion", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerVersionResponseDto" - } - } - }, - "description": "" - } - }, - "tags": [ - "Server Info", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.107.0" - } + "Server" + ] } }, "/server/license": { @@ -5180,6 +4943,154 @@ ] } }, + "/server/media-types": { + "get": { + "operationId": "getSupportedMediaTypes", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerMediaTypesResponseDto" + } + } + }, + "description": "" + } + }, + "tags": [ + "Server" + ] + } + }, + "/server/ping": { + "get": { + "operationId": "pingServer", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerPingResponse" + } + } + }, + "description": "" + } + }, + "tags": [ + "Server" + ] + } + }, + "/server/statistics": { + "get": { + "operationId": "getServerStatistics", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerStatsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server" + ] + } + }, + "/server/storage": { + "get": { + "operationId": "getStorage", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerStorageResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server" + ] + } + }, + "/server/theme": { + "get": { + "operationId": "getTheme", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerThemeDto" + } + } + }, + "description": "" + } + }, + "tags": [ + "Server" + ] + } + }, + "/server/version": { + "get": { + "operationId": "getServerVersion", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerVersionResponseDto" + } + } + }, + "description": "" + } + }, + "tags": [ + "Server" + ] + } + }, "/sessions": { "delete": { "operationId": "deleteAllSessions", @@ -5655,6 +5566,248 @@ ] } }, + "/stacks": { + "delete": { + "operationId": "deleteStacks", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkIdsDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "get": { + "operationId": "searchStacks", + "parameters": [ + { + "name": "primaryAssetId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/StackResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "post": { + "operationId": "createStack", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + } + }, + "/stacks/{id}": { + "delete": { + "operationId": "deleteStack", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "get": { + "operationId": "getStack", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "put": { + "operationId": "updateStack", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + } + }, "/sync/delta-sync": { "post": { "operationId": "getDeltaSync", @@ -6016,7 +6169,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTagDto" + "$ref": "#/components/schemas/TagCreateDto" } } }, @@ -6048,6 +6201,91 @@ "tags": [ "Tags" ] + }, + "put": { + "operationId": "upsertTags", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TagResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Tags" + ] + } + }, + "/tags/assets": { + "put": { + "operationId": "bulkTagAssets", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagBulkAssetsDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagBulkAssetsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Tags" + ] } }, "/tags/{id}": { @@ -6065,7 +6303,7 @@ } ], "responses": { - "200": { + "204": { "description": "" } }, @@ -6124,7 +6362,7 @@ "Tags" ] }, - "patch": { + "put": { "operationId": "updateTag", "parameters": [ { @@ -6141,7 +6379,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateTagDto" + "$ref": "#/components/schemas/TagUpdateDto" } } }, @@ -6193,7 +6431,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetIdsDto" + "$ref": "#/components/schemas/BulkIdsDto" } } }, @@ -6205,50 +6443,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/AssetIdsResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Tags" - ] - }, - "get": { - "operationId": "getTagAssets", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" + "$ref": "#/components/schemas/BulkIdResponseDto" }, "type": "array" } @@ -6289,7 +6484,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetIdsDto" + "$ref": "#/components/schemas/BulkIdsDto" } } }, @@ -6301,7 +6496,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/AssetIdsResponseDto" + "$ref": "#/components/schemas/BulkIdResponseDto" }, "type": "array" } @@ -6396,6 +6591,15 @@ "$ref": "#/components/schemas/TimeBucketSize" } }, + { + "name": "tagId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "timeBucket", "required": true, @@ -6531,6 +6735,15 @@ "$ref": "#/components/schemas/TimeBucketSize" } }, + { + "name": "tagId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "userId", "required": false, @@ -7097,12 +7310,91 @@ "Users" ] } + }, + "/view/folder": { + "get": { + "operationId": "getAssetsByOriginalPath", + "parameters": [ + { + "name": "path", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "View" + ] + } + }, + "/view/folder/unique-paths": { + "get": { + "operationId": "getUniqueOriginalPaths", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "View" + ] + } } }, "info": { "title": "Immich", "description": "Immich API", - "version": "1.112.1", + "version": "1.113.0", "contact": {} }, "tags": [], @@ -7298,25 +7590,6 @@ ], "type": "object" }, - "AlbumCountResponseDto": { - "properties": { - "notShared": { - "type": "integer" - }, - "owned": { - "type": "integer" - }, - "shared": { - "type": "integer" - } - }, - "required": [ - "notShared", - "owned", - "shared" - ], - "type": "object" - }, "AlbumResponseDto": { "properties": { "albumName": { @@ -7404,6 +7677,25 @@ ], "type": "object" }, + "AlbumStatisticsResponseDto": { + "properties": { + "notShared": { + "type": "integer" + }, + "owned": { + "type": "integer" + }, + "shared": { + "type": "integer" + } + }, + "required": [ + "notShared", + "owned", + "shared" + ], + "type": "object" + }, "AlbumUserAddDto": { "properties": { "role": { @@ -7570,13 +7862,6 @@ "maximum": 5, "minimum": 0, "type": "number" - }, - "removeParent": { - "type": "boolean" - }, - "stackParentId": { - "format": "uuid", - "type": "string" } }, "required": [ @@ -8111,24 +8396,20 @@ "type": "array" }, "resized": { + "deprecated": true, + "description": "This property was deprecated in v1.113.0", "type": "boolean" }, "smartInfo": { "$ref": "#/components/schemas/SmartInfoResponseDto" }, "stack": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - }, - "stackCount": { - "nullable": true, - "type": "integer" - }, - "stackParentId": { - "nullable": true, - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/AssetStackResponseDto" + } + ], + "nullable": true }, "tags": { "items": { @@ -8171,14 +8452,31 @@ "originalFileName", "originalPath", "ownerId", - "resized", - "stackCount", "thumbhash", "type", "updatedAt" ], "type": "object" }, + "AssetStackResponseDto": { + "properties": { + "assetCount": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "primaryAssetId": { + "type": "string" + } + }, + "required": [ + "assetCount", + "id", + "primaryAssetId" + ], + "type": "object" + }, "AssetStatsResponseDto": { "properties": { "images": { @@ -8452,21 +8750,6 @@ ], "type": "object" }, - "CreateTagDto": { - "properties": { - "name": { - "type": "string" - }, - "type": { - "$ref": "#/components/schemas/TagTypeEnum" - } - }, - "required": [ - "name", - "type" - ], - "type": "object" - }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -8886,6 +9169,34 @@ ], "type": "object" }, + "FoldersResponse": { + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "sidebarWeb": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "enabled", + "sidebarWeb" + ], + "type": "object" + }, + "FoldersUpdate": { + "properties": { + "enabled": { + "type": "boolean" + }, + "sidebarWeb": { + "type": "boolean" + } + }, + "type": "object" + }, "ImageFormat": { "enum": [ "jpeg", @@ -9256,6 +9567,26 @@ ], "type": "string" }, + "MemoriesResponse": { + "properties": { + "enabled": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "MemoriesUpdate": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, "MemoryCreateDto": { "properties": { "assetIds": { @@ -9308,17 +9639,6 @@ ], "type": "object" }, - "MemoryResponse": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "MemoryResponseDto": { "properties": { "assets": { @@ -9382,14 +9702,6 @@ ], "type": "string" }, - "MemoryUpdate": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "type": "object" - }, "MemoryUpdateDto": { "properties": { "isSaved": { @@ -9675,6 +9987,23 @@ ], "type": "string" }, + "PeopleResponse": { + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "sidebarWeb": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "enabled", + "sidebarWeb" + ], + "type": "object" + }, "PeopleResponseDto": { "properties": { "hasNextPage": { @@ -9701,6 +10030,17 @@ ], "type": "object" }, + "PeopleUpdate": { + "properties": { + "enabled": { + "type": "boolean" + }, + "sidebarWeb": { + "type": "boolean" + } + }, + "type": "object" + }, "PeopleUpdateDto": { "properties": { "people": { @@ -9760,7 +10100,6 @@ "asset.read", "asset.update", "asset.delete", - "asset.restore", "asset.share", "asset.view", "asset.download", @@ -9802,10 +10141,17 @@ "person.statistics", "person.merge", "person.reassign", + "session.read", + "session.update", + "session.delete", "sharedLink.create", "sharedLink.read", "sharedLink.update", "sharedLink.delete", + "stack.create", + "stack.read", + "stack.update", + "stack.delete", "systemConfig.read", "systemConfig.update", "systemMetadata.read", @@ -9814,6 +10160,7 @@ "tag.read", "tag.update", "tag.delete", + "tag.asset", "admin.user.create", "admin.user.read", "admin.user.update", @@ -10015,7 +10362,7 @@ ], "type": "object" }, - "RatingResponse": { + "RatingsResponse": { "properties": { "enabled": { "default": false, @@ -10027,7 +10374,7 @@ ], "type": "object" }, - "RatingUpdate": { + "RatingsUpdate": { "properties": { "enabled": { "type": "boolean" @@ -10882,6 +11229,53 @@ ], "type": "object" }, + "StackCreateDto": { + "properties": { + "assetIds": { + "description": "first asset becomes the primary", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "assetIds" + ], + "type": "object" + }, + "StackResponseDto": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "primaryAssetId": { + "type": "string" + } + }, + "required": [ + "assets", + "id", + "primaryAssetId" + ], + "type": "object" + }, + "StackUpdateDto": { + "properties": { + "primaryAssetId": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, "SystemConfigDto": { "properties": { "ffmpeg": { @@ -11562,36 +11956,144 @@ ], "type": "object" }, + "TagBulkAssetsDto": { + "properties": { + "assetIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "assetIds", + "tagIds" + ], + "type": "object" + }, + "TagBulkAssetsResponseDto": { + "properties": { + "count": { + "type": "integer" + } + }, + "required": [ + "count" + ], + "type": "object" + }, + "TagCreateDto": { + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parentId": { + "format": "uuid", + "nullable": true, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "TagResponseDto": { "properties": { + "color": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, "id": { "type": "string" }, "name": { "type": "string" }, - "type": { - "$ref": "#/components/schemas/TagTypeEnum" + "parentId": { + "type": "string" }, - "userId": { + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "value": { "type": "string" } }, "required": [ + "createdAt", "id", "name", - "type", - "userId" + "updatedAt", + "value" ], "type": "object" }, - "TagTypeEnum": { - "enum": [ - "OBJECT", - "FACE", - "CUSTOM" + "TagUpdateDto": { + "properties": { + "color": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "TagUpsertDto": { + "properties": { + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tags" ], - "type": "string" + "type": "object" + }, + "TagsResponse": { + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "sidebarWeb": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "enabled", + "sidebarWeb" + ], + "type": "object" + }, + "TagsUpdate": { + "properties": { + "enabled": { + "type": "boolean" + }, + "sidebarWeb": { + "type": "boolean" + } + }, + "type": "object" }, "TimeBucketResponseDto": { "properties": { @@ -11735,31 +12237,6 @@ ], "type": "object" }, - "UpdateStackParentDto": { - "properties": { - "newParentId": { - "format": "uuid", - "type": "string" - }, - "oldParentId": { - "format": "uuid", - "type": "string" - } - }, - "required": [ - "newParentId", - "oldParentId" - ], - "type": "object" - }, - "UpdateTagDto": { - "properties": { - "name": { - "type": "string" - } - }, - "type": "object" - }, "UsageByUserDto": { "properties": { "photos": { @@ -11995,23 +12472,35 @@ "emailNotifications": { "$ref": "#/components/schemas/EmailNotificationsResponse" }, + "folders": { + "$ref": "#/components/schemas/FoldersResponse" + }, "memories": { - "$ref": "#/components/schemas/MemoryResponse" + "$ref": "#/components/schemas/MemoriesResponse" + }, + "people": { + "$ref": "#/components/schemas/PeopleResponse" }, "purchase": { "$ref": "#/components/schemas/PurchaseResponse" }, - "rating": { - "$ref": "#/components/schemas/RatingResponse" + "ratings": { + "$ref": "#/components/schemas/RatingsResponse" + }, + "tags": { + "$ref": "#/components/schemas/TagsResponse" } }, "required": [ "avatar", "download", "emailNotifications", + "folders", "memories", + "people", "purchase", - "rating" + "ratings", + "tags" ], "type": "object" }, @@ -12026,14 +12515,23 @@ "emailNotifications": { "$ref": "#/components/schemas/EmailNotificationsUpdate" }, + "folders": { + "$ref": "#/components/schemas/FoldersUpdate" + }, "memories": { - "$ref": "#/components/schemas/MemoryUpdate" + "$ref": "#/components/schemas/MemoriesUpdate" + }, + "people": { + "$ref": "#/components/schemas/PeopleUpdate" }, "purchase": { "$ref": "#/components/schemas/PurchaseUpdate" }, - "rating": { - "$ref": "#/components/schemas/RatingUpdate" + "ratings": { + "$ref": "#/components/schemas/RatingsUpdate" + }, + "tags": { + "$ref": "#/components/schemas/TagsUpdate" } }, "type": "object" diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 8ce7030825b5e..3516580bbbc04 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 53ef27fd29670..312858d0a3082 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,18 +1,18 @@ { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "typescript": "^5.3.3" } }, @@ -22,13 +22,13 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "20.14.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", - "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "version": "20.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", + "integrity": "sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/typescript": { @@ -46,10 +46,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" } } } diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index bbf7c962a0a44..c86a58ffb96b2 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "typescript": "^5.3.3" }, "repository": { @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 89e03603689a8..e7e4e6adbef34 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.112.1 + * 1.113.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -93,23 +93,38 @@ export type EmailNotificationsResponse = { albumUpdate: boolean; enabled: boolean; }; -export type MemoryResponse = { +export type FoldersResponse = { enabled: boolean; + sidebarWeb: boolean; +}; +export type MemoriesResponse = { + enabled: boolean; +}; +export type PeopleResponse = { + enabled: boolean; + sidebarWeb: boolean; }; export type PurchaseResponse = { hideBuyButtonUntil: string; showSupportBadge: boolean; }; -export type RatingResponse = { +export type RatingsResponse = { enabled: boolean; }; +export type TagsResponse = { + enabled: boolean; + sidebarWeb: boolean; +}; export type UserPreferencesResponseDto = { avatar: AvatarResponse; download: DownloadResponse; emailNotifications: EmailNotificationsResponse; - memories: MemoryResponse; + folders: FoldersResponse; + memories: MemoriesResponse; + people: PeopleResponse; purchase: PurchaseResponse; - rating: RatingResponse; + ratings: RatingsResponse; + tags: TagsResponse; }; export type AvatarUpdate = { color?: UserAvatarColor; @@ -123,23 +138,38 @@ export type EmailNotificationsUpdate = { albumUpdate?: boolean; enabled?: boolean; }; -export type MemoryUpdate = { +export type FoldersUpdate = { enabled?: boolean; + sidebarWeb?: boolean; +}; +export type MemoriesUpdate = { + enabled?: boolean; +}; +export type PeopleUpdate = { + enabled?: boolean; + sidebarWeb?: boolean; }; export type PurchaseUpdate = { hideBuyButtonUntil?: string; showSupportBadge?: boolean; }; -export type RatingUpdate = { +export type RatingsUpdate = { enabled?: boolean; }; +export type TagsUpdate = { + enabled?: boolean; + sidebarWeb?: boolean; +}; export type UserPreferencesUpdateDto = { avatar?: AvatarUpdate; download?: DownloadUpdate; emailNotifications?: EmailNotificationsUpdate; - memories?: MemoryUpdate; + folders?: FoldersUpdate; + memories?: MemoriesUpdate; + people?: PeopleUpdate; purchase?: PurchaseUpdate; - rating?: RatingUpdate; + ratings?: RatingsUpdate; + tags?: TagsUpdate; }; export type AlbumUserResponseDto = { role: AlbumUserRole; @@ -192,11 +222,19 @@ export type SmartInfoResponseDto = { objects?: string[] | null; tags?: string[] | null; }; +export type AssetStackResponseDto = { + assetCount: number; + id: string; + primaryAssetId: string; +}; export type TagResponseDto = { + color?: string; + createdAt: string; id: string; name: string; - "type": TagTypeEnum; - userId: string; + parentId?: string; + updatedAt: string; + value: string; }; export type AssetResponseDto = { /** base64 encoded sha1 hash */ @@ -224,11 +262,10 @@ export type AssetResponseDto = { owner?: UserResponseDto; ownerId: string; people?: PersonWithFacesResponseDto[]; - resized: boolean; + /** This property was deprecated in v1.113.0 */ + resized?: boolean; smartInfo?: SmartInfoResponseDto; - stack?: AssetResponseDto[]; - stackCount: number | null; - stackParentId?: string | null; + stack?: (AssetStackResponseDto) | null; tags?: TagResponseDto[]; thumbhash: string | null; "type": AssetTypeEnum; @@ -265,7 +302,7 @@ export type CreateAlbumDto = { assetIds?: string[]; description?: string; }; -export type AlbumCountResponseDto = { +export type AlbumStatisticsResponseDto = { notShared: number; owned: number; shared: number; @@ -344,8 +381,6 @@ export type AssetBulkUpdateDto = { latitude?: number; longitude?: number; rating?: number; - removeParent?: boolean; - stackParentId?: string; }; export type AssetBulkUploadCheckItem = { /** base64 or hex encoded sha1 hash */ @@ -379,10 +414,6 @@ export type MemoryLaneResponseDto = { assets: AssetResponseDto[]; yearsAgo: number; }; -export type UpdateStackParentDto = { - newParentId: string; - oldParentId: string; -}; export type AssetStatsResponseDto = { images: number; total: number; @@ -872,6 +903,15 @@ export type ServerFeaturesDto = { smartSearch: boolean; trash: boolean; }; +export type LicenseResponseDto = { + activatedAt: string; + activationKey: string; + licenseKey: string; +}; +export type LicenseKeyDto = { + activationKey: string; + licenseKey: string; +}; export type ServerMediaTypesResponseDto = { image: string[]; sidecar: string[]; @@ -912,15 +952,6 @@ export type ServerVersionResponseDto = { minor: number; patch: number; }; -export type LicenseResponseDto = { - activatedAt: string; - activationKey: string; - licenseKey: string; -}; -export type LicenseKeyDto = { - activationKey: string; - licenseKey: string; -}; export type SessionResponseDto = { createdAt: string; current: boolean; @@ -973,6 +1004,18 @@ export type AssetIdsResponseDto = { error?: Error2; success: boolean; }; +export type StackResponseDto = { + assets: AssetResponseDto[]; + id: string; + primaryAssetId: string; +}; +export type StackCreateDto = { + /** first asset becomes the primary */ + assetIds: string[]; +}; +export type StackUpdateDto = { + primaryAssetId?: string; +}; export type AssetDeltaSyncDto = { updatedAfter: string; userIds: string[]; @@ -1163,12 +1206,23 @@ export type ReverseGeocodingStateResponseDto = { lastImportFileName: string | null; lastUpdate: string | null; }; -export type CreateTagDto = { +export type TagCreateDto = { + color?: string; name: string; - "type": TagTypeEnum; + parentId?: string | null; }; -export type UpdateTagDto = { - name?: string; +export type TagUpsertDto = { + tags: string[]; +}; +export type TagBulkAssetsDto = { + assetIds: string[]; + tagIds: string[]; +}; +export type TagBulkAssetsResponseDto = { + count: number; +}; +export type TagUpdateDto = { + color?: string | null; }; export type TimeBucketResponseDto = { count: number; @@ -1360,11 +1414,11 @@ export function createAlbum({ createAlbumDto }: { body: createAlbumDto }))); } -export function getAlbumCount(opts?: Oazapfts.RequestOpts) { +export function getAlbumStatistics(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AlbumCountResponseDto; - }>("/albums/count", { + data: AlbumStatisticsResponseDto; + }>("/albums/statistics", { ...opts })); } @@ -1632,15 +1686,6 @@ export function getRandom({ count }: { ...opts })); } -export function updateStackParent({ updateStackParentDto }: { - updateStackParentDto: UpdateStackParentDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/assets/stack/parent", oazapfts.json({ - ...opts, - method: "PUT", - body: updateStackParentDto - }))); -} export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: { isArchived?: boolean; isFavorite?: boolean; @@ -2168,7 +2213,7 @@ export function redirectOAuthToMobile(opts?: Oazapfts.RequestOpts) { } export function unlinkOAuthAccount(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; + status: 200; data: UserAdminResponseDto; }>("/oauth/unlink", { ...opts, @@ -2458,102 +2503,27 @@ export function getSearchSuggestions({ country, includeNull, make, model, state, ...opts })); } -/** - * This property was deprecated in v1.107.0 - */ export function getAboutInfo(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ServerAboutResponseDto; - }>("/server-info/about", { + }>("/server/about", { ...opts })); } -/** - * This property was deprecated in v1.107.0 - */ export function getServerConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ServerConfigDto; - }>("/server-info/config", { + }>("/server/config", { ...opts })); } -/** - * This property was deprecated in v1.107.0 - */ export function getServerFeatures(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ServerFeaturesDto; - }>("/server-info/features", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function getSupportedMediaTypes(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerMediaTypesResponseDto; - }>("/server-info/media-types", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function pingServer(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerPingResponseRead; - }>("/server-info/ping", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function getServerStatistics(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerStatsResponseDto; - }>("/server-info/statistics", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function getStorage(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerStorageResponseDto; - }>("/server-info/storage", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function getTheme(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerThemeDto; - }>("/server-info/theme", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function getServerVersion(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerVersionResponseDto; - }>("/server-info/version", { + }>("/server/features", { ...opts })); } @@ -2585,6 +2555,54 @@ export function setServerLicense({ licenseKeyDto }: { body: licenseKeyDto }))); } +export function getSupportedMediaTypes(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerMediaTypesResponseDto; + }>("/server/media-types", { + ...opts + })); +} +export function pingServer(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerPingResponseRead; + }>("/server/ping", { + ...opts + })); +} +export function getServerStatistics(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerStatsResponseDto; + }>("/server/statistics", { + ...opts + })); +} +export function getStorage(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerStorageResponseDto; + }>("/server/storage", { + ...opts + })); +} +export function getTheme(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerThemeDto; + }>("/server/theme", { + ...opts + })); +} +export function getServerVersion(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerVersionResponseDto; + }>("/server/version", { + ...opts + })); +} export function deleteAllSessions(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/sessions", { ...opts, @@ -2706,6 +2724,70 @@ export function addSharedLinkAssets({ id, key, assetIdsDto }: { body: assetIdsDto }))); } +export function deleteStacks({ bulkIdsDto }: { + bulkIdsDto: BulkIdsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/stacks", oazapfts.json({ + ...opts, + method: "DELETE", + body: bulkIdsDto + }))); +} +export function searchStacks({ primaryAssetId }: { + primaryAssetId?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto[]; + }>(`/stacks${QS.query(QS.explode({ + primaryAssetId + }))}`, { + ...opts + })); +} +export function createStack({ stackCreateDto }: { + stackCreateDto: StackCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: StackResponseDto; + }>("/stacks", oazapfts.json({ + ...opts, + method: "POST", + body: stackCreateDto + }))); +} +export function deleteStack({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/stacks/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +export function getStack({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto; + }>(`/stacks/${encodeURIComponent(id)}`, { + ...opts + })); +} +export function updateStack({ id, stackUpdateDto }: { + id: string; + stackUpdateDto: StackUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto; + }>(`/stacks/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: stackUpdateDto + }))); +} export function getDeltaSync({ assetDeltaSyncDto }: { assetDeltaSyncDto: AssetDeltaSyncDto; }, opts?: Oazapfts.RequestOpts) { @@ -2799,8 +2881,8 @@ export function getAllTags(opts?: Oazapfts.RequestOpts) { ...opts })); } -export function createTag({ createTagDto }: { - createTagDto: CreateTagDto; +export function createTag({ tagCreateDto }: { + tagCreateDto: TagCreateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; @@ -2808,7 +2890,31 @@ export function createTag({ createTagDto }: { }>("/tags", oazapfts.json({ ...opts, method: "POST", - body: createTagDto + body: tagCreateDto + }))); +} +export function upsertTags({ tagUpsertDto }: { + tagUpsertDto: TagUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TagResponseDto[]; + }>("/tags", oazapfts.json({ + ...opts, + method: "PUT", + body: tagUpsertDto + }))); +} +export function bulkTagAssets({ tagBulkAssetsDto }: { + tagBulkAssetsDto: TagBulkAssetsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TagBulkAssetsResponseDto; + }>("/tags/assets", oazapfts.json({ + ...opts, + method: "PUT", + body: tagBulkAssetsDto }))); } export function deleteTag({ id }: { @@ -2829,56 +2935,46 @@ export function getTagById({ id }: { ...opts })); } -export function updateTag({ id, updateTagDto }: { +export function updateTag({ id, tagUpdateDto }: { id: string; - updateTagDto: UpdateTagDto; + tagUpdateDto: TagUpdateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: TagResponseDto; }>(`/tags/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, - method: "PATCH", - body: updateTagDto + method: "PUT", + body: tagUpdateDto }))); } -export function untagAssets({ id, assetIdsDto }: { +export function untagAssets({ id, bulkIdsDto }: { id: string; - assetIdsDto: AssetIdsDto; + bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetIdsResponseDto[]; + data: BulkIdResponseDto[]; }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", - body: assetIdsDto + body: bulkIdsDto }))); } -export function getTagAssets({ id }: { +export function tagAssets({ id, bulkIdsDto }: { id: string; + bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetResponseDto[]; - }>(`/tags/${encodeURIComponent(id)}/assets`, { - ...opts - })); -} -export function tagAssets({ id, assetIdsDto }: { - id: string; - assetIdsDto: AssetIdsDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetIdsResponseDto[]; + data: BulkIdResponseDto[]; }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "PUT", - body: assetIdsDto + body: bulkIdsDto }))); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -2887,6 +2983,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order?: AssetOrder; personId?: string; size: TimeBucketSize; + tagId?: string; timeBucket: string; userId?: string; withPartners?: boolean; @@ -2904,6 +3001,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, + tagId, timeBucket, userId, withPartners, @@ -2912,7 +3010,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, ...opts })); } -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -2921,6 +3019,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key order?: AssetOrder; personId?: string; size: TimeBucketSize; + tagId?: string; userId?: string; withPartners?: boolean; withStacked?: boolean; @@ -2937,6 +3036,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key order, personId, size, + tagId, userId, withPartners, withStacked @@ -3077,6 +3177,26 @@ export function getProfileImage({ id }: { ...opts })); } +export function getAssetsByOriginalPath({ path }: { + path: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>(`/view/folder${QS.query(QS.explode({ + path + }))}`, { + ...opts + })); +} +export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: string[]; + }>("/view/folder/unique-paths", { + ...opts + })); +} export enum ReactionLevel { Album = "album", Asset = "asset" @@ -3106,11 +3226,6 @@ export enum AlbumUserRole { Editor = "editor", Viewer = "viewer" } -export enum TagTypeEnum { - Object = "OBJECT", - Face = "FACE", - Custom = "CUSTOM" -} export enum AssetTypeEnum { Image = "IMAGE", Video = "VIDEO", @@ -3141,7 +3256,6 @@ export enum Permission { AssetRead = "asset.read", AssetUpdate = "asset.update", AssetDelete = "asset.delete", - AssetRestore = "asset.restore", AssetShare = "asset.share", AssetView = "asset.view", AssetDownload = "asset.download", @@ -3183,10 +3297,17 @@ export enum Permission { PersonStatistics = "person.statistics", PersonMerge = "person.merge", PersonReassign = "person.reassign", + SessionRead = "session.read", + SessionUpdate = "session.update", + SessionDelete = "session.delete", SharedLinkCreate = "sharedLink.create", SharedLinkRead = "sharedLink.read", SharedLinkUpdate = "sharedLink.update", SharedLinkDelete = "sharedLink.delete", + StackCreate = "stack.create", + StackRead = "stack.read", + StackUpdate = "stack.update", + StackDelete = "stack.delete", SystemConfigRead = "systemConfig.read", SystemConfigUpdate = "systemConfig.update", SystemMetadataRead = "systemMetadata.read", @@ -3195,6 +3316,7 @@ export enum Permission { TagRead = "tag.read", TagUpdate = "tag.update", TagDelete = "tag.delete", + TagAsset = "tag.asset", AdminUserCreate = "admin.user.create", AdminUserRead = "admin.user.read", AdminUserUpdate = "admin.user.update", diff --git a/readme_i18n/README_zh_CN.md b/readme_i18n/README_zh_CN.md index 71a9b8a4d8076..d8a65fa6761c6 100644 --- a/readme_i18n/README_zh_CN.md +++ b/readme_i18n/README_zh_CN.md @@ -47,7 +47,7 @@ - ⚠️ 为了您宝贵的照片与视频,请始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案! > [!NOTE] -> 完整的项目文档以及安装教程请参见:https://immich.app/。 +> 完整的项目文档以及安装教程请参见:。 ## 目录 diff --git a/renovate.json b/renovate.json index 6f5424023b31d..ccfb75b19c157 100644 --- a/renovate.json +++ b/renovate.json @@ -79,7 +79,11 @@ "schedule": "on tuesday" } ], - "ignorePaths": ["mobile/openapi/pubspec.yaml", "mobile/ios", "mobile/android"], + "ignorePaths": [ + "mobile/openapi/pubspec.yaml", + "mobile/ios", + "mobile/android" + ], "ignoreDeps": ["http", "intl"], - "labels": ["dependencies"] + "labels": ["dependencies", "changelog:skip"] } diff --git a/server/.nvmrc b/server/.nvmrc index 8ce7030825b5e..3516580bbbc04 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/server/Dockerfile b/server/Dockerfile index fe1a07bf923d6..c961f0db64a5e 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240813@sha256:2e204a2256c088c9e4a0cf34cc9f70f9196c05e8744004000e7d2889466fc735 AS dev +FROM ghcr.io/immich-app/base-server-dev:20240827@sha256:c882c0a354faaac4f7256d30ecc2c45435eafa9b64d60793a171abb74ec5ca95 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl # web build -FROM node:20.16.0-alpine3.20@sha256:eb8101caae9ac02229bd64c024919fe3d4504ff7f329da79ca60a04db08cef52 AS web +FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 AS web WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ @@ -41,7 +41,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240813@sha256:51537e98ac601aa8401604a6aa9421e94aa55e03c303f355cc5870142adcc471 +FROM ghcr.io/immich-app/base-server-prod:20240827@sha256:72a419dd703b0f530c43f3e00f3aa56be9efb61b4eb9fe911bae8c6f98237967 WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 638b7b2959e58..d29b6f72385d5 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -63,6 +63,7 @@ export default [ '@typescript-eslint/require-await': 'error', curly: 2, 'prettier/prettier': 0, + 'object-shorthand': ['error', 'always'], 'no-restricted-imports': [ 'error', diff --git a/server/package-lock.json b/server/package-lock.json index 05d5fcac254ba..4fb6a04deb180 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.112.1", + "version": "1.113.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", @@ -24,7 +24,7 @@ "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.52.0", "@opentelemetry/sdk-node": "^0.52.0", - "@react-email/components": "^0.0.22", + "@react-email/components": "^0.0.23", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -45,15 +45,15 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "luxon": "^3.4.2", - "mnemonist": "^0.39.8", "nest-commander": "^3.11.1", "nestjs-cls": "^4.3.0", "nestjs-otel": "^6.0.0", "nodemailer": "^6.9.13", "openid-client": "^5.4.3", "pg": "^8.11.3", - "picomatch": "^4.0.0", - "react-email": "^2.1.2", + "picomatch": "^4.0.2", + "react": "^18.3.1", + "react-email": "^3.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", @@ -83,9 +83,10 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", + "@types/react": "^18.3.4", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.36", @@ -115,6 +116,7 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -123,6 +125,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "peer": true, "engines": { "node": ">=10" }, @@ -598,17 +601,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", - "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.6.tgz", @@ -732,29 +724,14 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.1.1.tgz", - "integrity": "sha512-3bfqkzuR1KLx57nZfjr2NLnFOobvyS0aTszaEGCGqmYMVDRaGvgIZbjGSV/MHSSmLgQ/b9JFHQ5xm5WRZYd+XQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, - "node_modules/@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "optional": true, - "dependencies": { - "@emotion/memoize": "0.7.4" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "optional": true - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -1127,6 +1104,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, "dependencies": { "eslint-visitor-keys": "^3.3.0" }, @@ -1141,6 +1119,7 @@ "version": "4.11.0", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -1149,6 +1128,7 @@ "version": "0.17.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", + "dev": true, "dependencies": { "@eslint/object-schema": "^2.1.4", "debug": "^4.3.1", @@ -1162,6 +1142,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.12.4", @@ -1185,6 +1166,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1200,6 +1182,7 @@ "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1211,13 +1194,14 @@ "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", - "license": "MIT", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1226,6 +1210,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1239,40 +1224,6 @@ "node": ">=14" } }, - "node_modules/@floating-ui/core": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.4.tgz", - "integrity": "sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==", - "dependencies": { - "@floating-ui/utils": "^0.2.4" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.7.tgz", - "integrity": "sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==", - "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.4" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", - "dependencies": { - "@floating-ui/dom": "^1.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz", - "integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==" - }, "node_modules/@golevelup/nestjs-discovery": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.1.tgz", @@ -1377,6 +1328,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "engines": { "node": ">=12.22" }, @@ -1389,6 +1341,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "dev": true, "engines": { "node": ">=18.18" }, @@ -1398,9 +1351,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.4.tgz", - "integrity": "sha512-p0suNqXufJs9t3RqLBO6vvrgr5OhgbWp76s5gTRvdmxmuv9E1rcaqGUsl3l4mKVmXPkTkTErXediAui4x+8PSA==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "cpu": [ "arm64" ], @@ -1409,23 +1362,19 @@ "darwin" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.2" + "@img/sharp-libvips-darwin-arm64": "1.0.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.4.tgz", - "integrity": "sha512-0l7yRObwtTi82Z6ebVI2PnHT8EB2NxBgpK2MiKJZJ7cz32R4lxd001ecMhzzsZig3Yv9oclvqqdV93jo9hy+Dw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", "cpu": [ "x64" ], @@ -1434,23 +1383,19 @@ "darwin" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.2" + "@img/sharp-libvips-darwin-x64": "1.0.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-tcK/41Rq8IKlSaKRCCAuuY3lDJjQnYIW1UXU1kxcEKrfL8WR7N6+rzNoOxoQRJWTAECuKwgAHnPvqXGN8XfkHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", "cpu": [ "arm64" ], @@ -1458,20 +1403,14 @@ "os": [ "darwin" ], - "engines": { - "macos": ">=11", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.2.tgz", - "integrity": "sha512-Ofw+7oaWa0HiiMiKWqqaZbaYV3/UGL2wAPeLuJTx+9cXpCRdvQhCLG0IH8YGwM0yGWGLpsF4Su9vM1o6aer+Fw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", "cpu": [ "x64" ], @@ -1479,20 +1418,14 @@ "os": [ "darwin" ], - "engines": { - "macos": ">=10.13", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.2.tgz", - "integrity": "sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", "cpu": [ "arm" ], @@ -1500,20 +1433,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.2.tgz", - "integrity": "sha512-x7kCt3N00ofFmmkkdshwj3vGPCnmiDh7Gwnd4nUwZln2YjqPxV1NlTyZOvoDWdKQVDL911487HOueBvrpflagw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", "cpu": [ "arm64" ], @@ -1521,20 +1448,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.26", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.2.tgz", - "integrity": "sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", "cpu": [ "s390x" ], @@ -1542,20 +1463,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.2.tgz", - "integrity": "sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", "cpu": [ "x64" ], @@ -1563,20 +1478,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.26", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.2.tgz", - "integrity": "sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", "cpu": [ "arm64" ], @@ -1584,20 +1493,14 @@ "os": [ "linux" ], - "engines": { - "musl": ">=1.2.2", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.2.tgz", - "integrity": "sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", "cpu": [ "x64" ], @@ -1605,20 +1508,14 @@ "os": [ "linux" ], - "engines": { - "musl": ">=1.2.2", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.4.tgz", - "integrity": "sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", "cpu": [ "arm" ], @@ -1627,23 +1524,19 @@ "linux" ], "engines": { - "glibc": ">=2.28", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.2" + "@img/sharp-libvips-linux-arm": "1.0.5" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.4.tgz", - "integrity": "sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", "cpu": [ "arm64" ], @@ -1652,23 +1545,19 @@ "linux" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.2" + "@img/sharp-libvips-linux-arm64": "1.0.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.4.tgz", - "integrity": "sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", "cpu": [ "s390x" ], @@ -1677,23 +1566,19 @@ "linux" ], "engines": { - "glibc": ">=2.31", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.2" + "@img/sharp-libvips-linux-s390x": "1.0.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.4.tgz", - "integrity": "sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", "cpu": [ "x64" ], @@ -1702,23 +1587,19 @@ "linux" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.2" + "@img/sharp-libvips-linux-x64": "1.0.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.4.tgz", - "integrity": "sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", "cpu": [ "arm64" ], @@ -1727,23 +1608,19 @@ "linux" ], "engines": { - "musl": ">=1.2.2", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.4.tgz", - "integrity": "sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", "cpu": [ "x64" ], @@ -1752,44 +1629,37 @@ "linux" ], "engines": { - "musl": ">=1.2.2", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.2" + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.4.tgz", - "integrity": "sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", "cpu": [ "wasm32" ], "optional": true, "dependencies": { - "@emnapi/runtime": "^1.1.1" + "@emnapi/runtime": "^1.2.0" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.4.tgz", - "integrity": "sha512-99SJ91XzUhYHbx7uhK3+9Lf7+LjwMGQZMDlO/E/YVJ7Nc3lyDFZPGhjwiYdctoH2BOzW9+TnfqcaMKt0jHLdqw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", "cpu": [ "ia32" ], @@ -1798,19 +1668,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.4.tgz", - "integrity": "sha512-3QLocdTRVIrFNye5YocZl+KKpYKP+fksi1QhmOArgx7GyhIbQp/WrJRu176jm8IxromS7RIkzMiMINVdBtC8Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", "cpu": [ "x64" ], @@ -1819,10 +1686,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1964,6 +1828,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -2082,9 +1947,9 @@ ] }, "node_modules/@nestjs/bull-shared": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.0.tgz", - "integrity": "sha512-cSi6CyPECHDFumnHWWfwLCnbc6hm5jXt7FqzJ0Id6EhGqdz5ja0FmgRwXoS4xoMA2RRjlxn2vGXr4YOaHBAeig==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.1.tgz", + "integrity": "sha512-zvnTvSq6OJ92omcsFUwaUmPbM3PRgWkIusHPB5TE3IFS7nNdM3OwF+kfe56sgKjMtQQMe/56lok0S04OtPMX5Q==", "dependencies": { "tslib": "2.6.3" }, @@ -2094,11 +1959,11 @@ } }, "node_modules/@nestjs/bullmq": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.0.tgz", - "integrity": "sha512-lHXWDocXh1Yl6unsUzGFEKmK02mu0DdI35cdBp3Fq/9D5V3oLuWjwAPFnTztedshIjlFmNW6x5mdaT5WZ0AV1Q==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.1.tgz", + "integrity": "sha512-nDR0hDabmtXt5gsb5R786BJsGIJoWh/79sVmRETXf4S45+fvdqG1XkCKAeHF9TO9USodw9m+XBNKysTnkY41gw==", "dependencies": { - "@nestjs/bull-shared": "^10.2.0", + "@nestjs/bull-shared": "^10.2.1", "tslib": "2.6.3" }, "peerDependencies": { @@ -2108,9 +1973,9 @@ } }, "node_modules/@nestjs/cli": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.2.tgz", - "integrity": "sha512-fQexIfLHfp6GUgX+CO4fOg+AEwV5ox/LHotQhyZi9wXUQDyIqS0NTTbumr//62EcX35qV4nU0359nYnuEdzG+A==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.4.tgz", + "integrity": "sha512-WKERbSZJGof0+9XeeMmWnb/9FpNxogcB5eTJTHjc9no0ymdTw3jTzT+KZL9iC/hGqBpuomDLaNFCYbAOt29nBw==", "dev": true, "dependencies": { "@angular-devkit/core": "17.3.8", @@ -2130,7 +1995,7 @@ "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.3.3", - "webpack": "5.92.1", + "webpack": "5.93.0", "webpack-node-externals": "3.0.0" }, "bin": { @@ -2166,9 +2031,9 @@ } }, "node_modules/@nestjs/common": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.10.tgz", - "integrity": "sha512-H8k0jZtxk1IdtErGDmxFRy0PfcOAUg41Prrqpx76DQusGGJjsaovs1zjXVD1rZWaVYchfT1uczJ6L4Kio10VNg==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", + "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", "dependencies": { "iterare": "1.2.1", "tslib": "2.6.3", @@ -2208,9 +2073,9 @@ } }, "node_modules/@nestjs/core": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.10.tgz", - "integrity": "sha512-ZbQ4jovQyzHtCGCrzK5NdtW1SYO2fHSsgSY1+/9WdruYCUra+JDkWEXgZ4M3Hv480Dl3OXehAmY1wCOojeMyMQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", + "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", @@ -2276,9 +2141,9 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.10.tgz", - "integrity": "sha512-wK2ow3CZI2KFqWeEpPmoR300OB6BcBLxARV1EiClJLCj4S1mZsoCmS0YWgpk3j1j6mo0SI8vNLi/cC2iZPEPQA==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", + "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -2296,9 +2161,9 @@ } }, "node_modules/@nestjs/platform-socket.io": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.10.tgz", - "integrity": "sha512-LRd+nGWhUu9hND1txCLPZd78Hea+qKJVENb+c9aDU04T24GRjsInDF2RANMR16JLQFcI9mclktDWX4plE95SHg==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", + "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", "dependencies": { "socket.io": "4.7.5", "tslib": "2.6.3" @@ -2339,9 +2204,9 @@ } }, "node_modules/@nestjs/schematics": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.3.tgz", - "integrity": "sha512-aLJ4Nl/K/u6ZlgLa0NjKw5CuBOIgc6vudF42QvmGueu5FaMGM6IJrAuEvB5T2kr0PAfVwYmDFBBHCWdYhTw4Tg==", + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.4.tgz", + "integrity": "sha512-QpY8ez9cTvXXPr3/KBrtSgXQHMSV6BkOUYy2c2TTe6cBqriEdGnCYqGl8cnfrQl3632q3lveQPaZ/c127dHsEw==", "dev": true, "dependencies": { "@angular-devkit/core": "17.3.8", @@ -2393,9 +2258,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.10.tgz", - "integrity": "sha512-i3HAtVQJijxNxJq1k39aelyJlyEIBRONys7IipH/4r8W0J+M1V+y5EKDOyi4j1SdNSb/vmNyWpZ2/ewZjl3kRA==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", + "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", "dev": true, "dependencies": { "tslib": "2.6.3" @@ -2435,9 +2300,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.10.tgz", - "integrity": "sha512-F/fhAC0ylAhjfCZj4Xrgc0yTJ/qltooDCa+fke7BFZLofLmE0yj7WzBVrBHsk/46kppyRcs5XrYjIQLqcDze8g==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", + "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -2457,14 +2322,14 @@ } }, "node_modules/@next/env": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.4.tgz", - "integrity": "sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==" + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", + "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==" }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.4.tgz", - "integrity": "sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", + "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", "cpu": [ "arm64" ], @@ -2477,9 +2342,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.4.tgz", - "integrity": "sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", + "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", "cpu": [ "x64" ], @@ -2492,9 +2357,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.4.tgz", - "integrity": "sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", + "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", "cpu": [ "arm64" ], @@ -2507,9 +2372,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.4.tgz", - "integrity": "sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", + "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", "cpu": [ "arm64" ], @@ -2522,9 +2387,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.4.tgz", - "integrity": "sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", + "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", "cpu": [ "x64" ], @@ -2537,9 +2402,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.4.tgz", - "integrity": "sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", + "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", "cpu": [ "x64" ], @@ -2552,9 +2417,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.4.tgz", - "integrity": "sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", + "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", "cpu": [ "arm64" ], @@ -2567,9 +2432,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.4.tgz", - "integrity": "sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", + "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", "cpu": [ "ia32" ], @@ -2582,9 +2447,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.4.tgz", - "integrity": "sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", + "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", "cpu": [ "x64" ], @@ -4317,628 +4182,29 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "node_modules/@radix-ui/colors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-1.0.1.tgz", - "integrity": "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg==" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", - "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", - "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.0.tgz", - "integrity": "sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", - "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", - "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz", - "integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", - "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", - "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", - "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", - "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-rect": "1.1.0", - "@radix-ui/react-use-size": "1.1.0", - "@radix-ui/rect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", - "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", - "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", - "dependencies": { - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", - "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", - "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", - "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-toggle": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.1.tgz", - "integrity": "sha512-LLE8nzNE4MzPMw3O2zlVlkLFid3y9hMUs7uCbSHyKSo+tCN4yMCf+ZCCcfrYgsOC0TiHBPQ1mtpJ2liY3ZT3SQ==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", - "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", - "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", - "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", - "dependencies": { - "@radix-ui/rect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", - "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", - "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" - }, "node_modules/@react-email/body": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.9.tgz", - "integrity": "sha512-bSGF6j+MbfQKYnnN+Kf57lGp/J+ci+435OMIv/BKAtfmNzHL+ptRrsINJELiO8QzwnZmQjTGKSMAMMJiQS+xwQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.10.tgz", + "integrity": "sha512-dMJyL9aU25ieatdPtVjCyQ/WHZYHwNc+Hy/XpF8Cc18gu21cUynVEeYQzFSeigDRMeBQ3PGAyjVDPIob7YlGwA==", "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/button": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.16.tgz", - "integrity": "sha512-paptUerzDhKHEUmBuT0UecCoqo3N6ZQSyDKC1hFALTwKReGW2xQATisinho9Ybh9ZGw6IZ3n1nGtmX5k2sX70Q==", + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.17.tgz", + "integrity": "sha512-ioHdsk+BpGS/PqjU6JS7tUrVy9yvbUx92Z+Cem2+MbYp55oEwQ9VHf7u4f5NoM0gdhfKSehBwRdYlHt/frEMcg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/code-block": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.6.tgz", - "integrity": "sha512-i+TEeI7AyG1pmtO2Mr+TblV08zQnOtTlYB/v45kFMlDWWKTkvIV33oLRqLYOFhCIvoO5fDZA9T+4m6PvhmcNwQ==", + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.7.tgz", + "integrity": "sha512-3lYLwn9rK16I4JmTR/sTzAJMVHzUmmcT1PT27+TXnQyBCfpfDV+VockSg1qhsgCusA/u6j0C97BMsa96AWEbbw==", "dependencies": { "prismjs": "1.29.0" }, @@ -4946,156 +4212,153 @@ "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/code-inline": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.3.tgz", - "integrity": "sha512-SY5Nn4KhjcqqEBHvUwFlOLNmUT78elIGR+Y14eg02LrVKQJ38mFCfXNGDLk4wbP/2dnidkLYq9+60nf7mFMhnQ==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.4.tgz", + "integrity": "sha512-zj3oMQiiUCZbddSNt3k0zNfIBFK0ZNDIzzDyBaJKy6ZASTtWfB+1WFX0cpTX8q0gUiYK+A94rk5Qp68L6YXjXQ==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/column": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.11.tgz", - "integrity": "sha512-KvrPuQFn0hlItRRL3vmRuOJgKG+8I0oO9HM5ReLMi5Ns313JSEQogCJaXuOEFkOVeuu5YyY6zy/+5Esccc1AxQ==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.12.tgz", + "integrity": "sha512-Rsl7iSdDaeHZO938xb+0wR5ud0Z3MVfdtPbNKJNojZi2hApwLAQXmDrnn/AcPDM5Lpl331ZljJS8vHTWxxkvKw==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/components": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.22.tgz", - "integrity": "sha512-GO6F+fS3c3aQ6OnqL8esQ/KqtrPGwz80U6uQ8Nd/ETpgFt7y1PXvSGfr8v12wyLffAagdowc/JjoThfIr0L6aA==", + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.23.tgz", + "integrity": "sha512-RcBoffx2IZG6quLBXo5sj3fF47rKmmkiMhG1ZBua4nFjHYlmW8j1uUMyO5HNglxIF9E52NYq4sF7XeZRp9jYjg==", "dependencies": { - "@react-email/body": "0.0.9", - "@react-email/button": "0.0.16", - "@react-email/code-block": "0.0.6", - "@react-email/code-inline": "0.0.3", - "@react-email/column": "0.0.11", - "@react-email/container": "0.0.13", - "@react-email/font": "0.0.7", - "@react-email/head": "0.0.10", - "@react-email/heading": "0.0.13", - "@react-email/hr": "0.0.9", - "@react-email/html": "0.0.9", - "@react-email/img": "0.0.9", - "@react-email/link": "0.0.9", - "@react-email/markdown": "0.0.11", - "@react-email/preview": "0.0.10", - "@react-email/render": "0.0.17", - "@react-email/row": "0.0.9", - "@react-email/section": "0.0.13", - "@react-email/tailwind": "0.0.19", - "@react-email/text": "0.0.9" + "@react-email/body": "0.0.10", + "@react-email/button": "0.0.17", + "@react-email/code-block": "0.0.7", + "@react-email/code-inline": "0.0.4", + "@react-email/column": "0.0.12", + "@react-email/container": "0.0.14", + "@react-email/font": "0.0.8", + "@react-email/head": "0.0.11", + "@react-email/heading": "0.0.14", + "@react-email/hr": "0.0.10", + "@react-email/html": "0.0.10", + "@react-email/img": "0.0.10", + "@react-email/link": "0.0.10", + "@react-email/markdown": "0.0.12", + "@react-email/preview": "0.0.11", + "@react-email/render": "1.0.0", + "@react-email/row": "0.0.10", + "@react-email/section": "0.0.14", + "@react-email/tailwind": "0.1.0", + "@react-email/text": "0.0.10" }, "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/container": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.13.tgz", - "integrity": "sha512-ftke0N1FZl8MX3XXxXiiOaiJOnrQz7ZXUyqNj81K+BK+DePWIVaSmgK6Bu8fFnsgwdKuBdqjZTEtF4sIkU3FuQ==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.14.tgz", + "integrity": "sha512-NgoaJJd9tTtsrveL86Ocr/AYLkGyN3prdXKd/zm5fQpfDhy/NXezyT3iF6VlwAOEUIu64ErHpAJd+P6ygR+vjg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/font": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.7.tgz", - "integrity": "sha512-R0/mfUV/XcUQIALjZUFT9GP+XGmIP1KPz20h9rpS5e4ji6VkQ3ENWlisxrdK5U+KA9iZQrlan+/6tUoTJ9bFsg==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.8.tgz", + "integrity": "sha512-fSBEqYyVPAyyACBBHcs3wEYzNknpHMuwcSAAKE8fOoDfGqURr/vSxKPdh4tOa9z7G4hlcEfgGrCYEa2iPT22cw==", "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/head": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.10.tgz", - "integrity": "sha512-VoH399w0/i3dJFnwH0Ixf9BTuiWhSA/y8PpsCJ7CPw8Mv8WNBqMAAsw0rmrITYI8uPd15LZ2zk2uwRDvqasMRw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.11.tgz", + "integrity": "sha512-skw5FUgyamIMK+LN+fZQ5WIKQYf0dPiRAvsUAUR2eYoZp9oRsfkIpFHr0GWPkKAYjFEj+uJjaxQ/0VzQH7svVg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/heading": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.13.tgz", - "integrity": "sha512-MYDzjJwljKHBLueLuyqkaHxu6N4aGOL1ms2NNyJ9WXC9mmBnLs4Y/QEf9SjE4Df3AW4iT9uyfVHuaNUb7uq5QA==", - "dependencies": { - "@radix-ui/react-slot": "1.1.0" - }, + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.14.tgz", + "integrity": "sha512-jZM7IVuZOXa0G110ES8OkxajPTypIKlzlO1K1RIe1auk76ukQRiCg1IRV4HZlWk1GGUbec5hNxsvZa2kU8cb9w==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/hr": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.9.tgz", - "integrity": "sha512-Rte+EZL3ptH3rkVU3a7fh8/06mZ6Q679tDaWDjsw3878RQC9afWqUPp5lwgA/1pTouLmJlDs2BjRnV6H84O7iw==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.10.tgz", + "integrity": "sha512-3AA4Yjgl3zEid/KVx6uf6TuLJHVZvUc2cG9Wm9ZpWeAX4ODA+8g9HyuC0tfnjbRsVMhMcCGiECuWWXINi+60vA==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/html": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.9.tgz", - "integrity": "sha512-NB74xwWaOJZxhpiy6pzkhHvugBa2vvmUa0KKnSwOEIX+WEQH8wj5UUhRN4F+Pmkiqz3QBTETUJiSsNWWFtrHgA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.10.tgz", + "integrity": "sha512-06uiuSKJBWQJfhCKv4MPupELei4Lepyz9Sth7Yq7Fq29CAeB1ejLgKkGqn1I+FZ72hQxPLdYF4iq4yloKv3JCg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/img": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.9.tgz", - "integrity": "sha512-zDlQWmlSANb2dBYhDaKD12Z4xaGD5mEf3peawBYHGxYySzMLwRT2ANGvFqpDNd7iT0C5po+/9EWR8fS1dLy0QQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.10.tgz", + "integrity": "sha512-pJ8glJjDNaJ53qoM95pvX9SK05yh0bNQY/oyBKmxlBDdUII6ixuMc3SCwYXPMl+tgkQUyDgwEBpSTrLAnjL3hA==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/link": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.9.tgz", - "integrity": "sha512-rRqWGPUTGFwwtMCtsdCHNh0ewOsd4UBG/D12UcwJYFKRb0U6hUG/6VJZE3tB1QYZpLIESdvOLL6ztznh+D749g==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.10.tgz", + "integrity": "sha512-tva3wvAWSR10lMJa9fVA09yRn7pbEki0ZZpHE6GD1jKbFhmzt38VgLO9B797/prqoDZdAr4rVK7LJFcdPx3GwA==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/markdown": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.11.tgz", - "integrity": "sha512-KeDTS0bAvvtgavYAIAmxKpRxWUSr1/jufckDzu9g4QsQtth8wYaSR5wCPXuTPmhFgJMIlNSlOiBnVp+oRbDtKA==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.12.tgz", + "integrity": "sha512-wsuvj1XAb6O63aizCLNEeqVgKR3oFjAwt9vjfg2y2oh4G1dZeo8zonZM2x1fmkEkBZhzwSHraNi70jSXhA3A9w==", "dependencies": { "md-to-react-email": "5.0.2" }, @@ -5103,24 +4366,24 @@ "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/preview": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.10.tgz", - "integrity": "sha512-bRrv8teMMBlF7ttLp1zZUejkPUzrwMQXrigdagtEBOqsB8HxvJU2MR6Yyb3XOqBYldaIDOQJ1z61zyD2wRlKAw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.11.tgz", + "integrity": "sha512-7O/CT4b16YlSGrj18htTPx3Vbhu2suCGv/cSe5c+fuSrIM/nMiBSZ3Js16Vj0XJbAmmmlVmYFZw9L20wXJ+LjQ==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/render": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.17.tgz", - "integrity": "sha512-xBQ+/73+WsGuXKY7r1U73zMBNV28xdV0cp9cFjhNYipBReDHhV97IpA6v7Hl0dDtDzt+yS/72dY5vYXrF1v8NA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.0.tgz", + "integrity": "sha512-seN2p3JRUSZhwIUiymh9N6ZfhRZ14ywOraQqAokY63DkDeHZW2pA2a6nWpNc/igfOcNyt09Wsoi1Aj0esxhdzw==", "dependencies": { "html-to-text": "9.0.5", "js-beautify": "^1.14.11", @@ -5130,52 +4393,52 @@ "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/row": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.9.tgz", - "integrity": "sha512-ZDASHVvyKrWBS00o5pSH5khfMf46UtZhrHcSAfPSiC4nj7R8A0bf+3Wmbk8YmsaV+qWXUCUSHWwIAAlMRnJoAA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.10.tgz", + "integrity": "sha512-jPyEhG3gsLX+Eb9U+A30fh0gK6hXJwF4ghJ+ZtFQtlKAKqHX+eCpWlqB3Xschd/ARJLod8WAswg0FB+JD9d0/A==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/section": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.13.tgz", - "integrity": "sha512-McsCQ5NQlNWEMEAR3EtCxHgRhxGmLD+jPvj7A3FD7y2X3fXG0hbmUGX12B63rIywSWjJoQi6tojx/8RpzbyeTA==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.14.tgz", + "integrity": "sha512-+fYWLb4tPU1A/+GE5J1+SEMA7/wR3V30lQ+OR9t2kAJqNrARDbMx0bLnYnR1QL5TiFRz0pCF05SQUobk6gHEDQ==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/tailwind": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.0.19.tgz", - "integrity": "sha512-bA0w4D7mSNowxWhcO0jBJauFIPf2Ok7QuKlrHwCcxyX35L2pb5D6ZmXYOrD9C6ADQuVz5oEX+oed3zpSLROgPg==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.1.0.tgz", + "integrity": "sha512-qysVUEY+M3SKUvu35XDpzn7yokhqFOT3tPU6Mj/pgc62TL5tQFj6msEbBtwoKs2qO3WZvai0DIHdLhaOxBQSow==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/text": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.9.tgz", - "integrity": "sha512-UNFPGerER3zywpb1ODOS2VgHP7rgOmiTxMHn75pjvQf/gi3/jN9edEQLYvRgPv/mNn4IpJFkOrlP8jcammLeew==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.10.tgz", + "integrity": "sha512-wNAnxeEAiFs6N+SxS0y6wTJWfewEzUETuyS2aZmT00xk50VijwyFRuhm4sYSjusMyshevomFwz5jNISCxRsGWw==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@rollup/pluginutils": { @@ -5483,9 +4746,9 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "node_modules/@swc/core": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.6.tgz", - "integrity": "sha512-FZxyao9eQks1MRmUshgsZTmlg/HB2oXK5fghkoWJm/1CU2q2kaJlVDll2as5j+rmWiwkp0Gidlq8wlXcEEAO+g==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.14.tgz", + "integrity": "sha512-9aeXeifnyuvc2pcuuhPQgVUwdpGEzZ+9nJu0W8/hNl/aESFsJGR5i9uQJRGu0atoNr01gK092fvmqMmQAPcKow==", "devOptional": true, "hasInstallScript": true, "dependencies": { @@ -5500,16 +4763,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.7.6", - "@swc/core-darwin-x64": "1.7.6", - "@swc/core-linux-arm-gnueabihf": "1.7.6", - "@swc/core-linux-arm64-gnu": "1.7.6", - "@swc/core-linux-arm64-musl": "1.7.6", - "@swc/core-linux-x64-gnu": "1.7.6", - "@swc/core-linux-x64-musl": "1.7.6", - "@swc/core-win32-arm64-msvc": "1.7.6", - "@swc/core-win32-ia32-msvc": "1.7.6", - "@swc/core-win32-x64-msvc": "1.7.6" + "@swc/core-darwin-arm64": "1.7.14", + "@swc/core-darwin-x64": "1.7.14", + "@swc/core-linux-arm-gnueabihf": "1.7.14", + "@swc/core-linux-arm64-gnu": "1.7.14", + "@swc/core-linux-arm64-musl": "1.7.14", + "@swc/core-linux-x64-gnu": "1.7.14", + "@swc/core-linux-x64-musl": "1.7.14", + "@swc/core-win32-arm64-msvc": "1.7.14", + "@swc/core-win32-ia32-msvc": "1.7.14", + "@swc/core-win32-x64-msvc": "1.7.14" }, "peerDependencies": { "@swc/helpers": "*" @@ -5521,9 +4784,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.6.tgz", - "integrity": "sha512-6lYHey84ZzsdtC7UuPheM4Rm0Inzxm6Sb8U6dmKc4eCx8JL0LfWG4LC5RsdsrTxnjTsbriWlnhZBffh8ijUHIQ==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.14.tgz", + "integrity": "sha512-V0OUXjOH+hdGxDYG8NkQzy25mKOpcNKFpqtZEzLe5V/CpLJPnpg1+pMz70m14s9ZFda9OxsjlvPbg1FLUwhgIQ==", "cpu": [ "arm64" ], @@ -5537,9 +4800,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.6.tgz", - "integrity": "sha512-Fyl+8aH9O5rpx4O7r2KnsPpoi32iWoKOYKiipeTbGjQ/E95tNPxbmsz4yqE8Ovldcga60IPJ5OKQA3HWRiuzdw==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.14.tgz", + "integrity": "sha512-9iFvUnxG6FC3An5ogp5jbBfQuUmTTwy8KMB+ZddUoPB3NR1eV+Y9vOh/tfWcenSJbgOKDLgYC5D/b1mHAprsrQ==", "cpu": [ "x64" ], @@ -5553,9 +4816,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.6.tgz", - "integrity": "sha512-2WxYTqFaOx48GKC2cbO1/IntA+w+kfCFy436Ij7qRqqtV/WAvTM9TC1OmiFbqq436rSot52qYmX8fkwdB5UcLQ==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.14.tgz", + "integrity": "sha512-zGJsef9qPivKSH8Vv4F/HiBXBTHZ5Hs3ZjVGo/UIdWPJF8fTL9OVADiRrl34Q7zOZEtGXRwEKLUW1SCQcbDvZA==", "cpu": [ "arm" ], @@ -5569,9 +4832,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.6.tgz", - "integrity": "sha512-TBEGMSe0LhvPe4S7E68c7VzgT3OMu4VTmBLS7B2aHv4v8uZO92Khpp7L0WqgYU1y5eMjk+XLDLi4kokiNHv/Hg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.14.tgz", + "integrity": "sha512-AxV3MPsoI7i4B8FXOew3dx3N8y00YoJYvIPfxelw07RegeCEH3aHp2U2DtgbP/NV1ugZMx0TL2Z2DEvocmA51g==", "cpu": [ "arm64" ], @@ -5585,9 +4848,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.6.tgz", - "integrity": "sha512-QI8QGL0HGT42tj7F1A+YAzhGkJjUcvvTfI1e2m704W0Enl2/UIK9v5D1zvQzYwusRyKuaQfbeBRYDh0NcLOGLg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.14.tgz", + "integrity": "sha512-JDLdNjUj3zPehd4+DrQD8Ltb3B5lD8D05IwePyDWw+uR/YPc7w/TX1FUVci5h3giJnlMCJRvi1IQYV7K1n7KtQ==", "cpu": [ "arm64" ], @@ -5601,9 +4864,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.6.tgz", - "integrity": "sha512-61AYVzhjuNQAVIKKWOJu3H0/pFD28RYJGxnGg3YMhvRLRyuWNyY5Nyyj2WkKcz/ON+g38Arlz00NT1LDIViRLg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.14.tgz", + "integrity": "sha512-Siy5OvPCLLWmMdx4msnEs8HvEVUEigSn0+3pbLjv78iwzXd0qSBNHUPZyC1xeurVaUbpNDxZTpPRIwpqNE2+Og==", "cpu": [ "x64" ], @@ -5617,9 +4880,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.6.tgz", - "integrity": "sha512-hQFznpfLK8XajfAAN9Cjs0w/aVmO7iu9VZvInyrTCRcPqxV5O+rvrhRxKvC1LRMZXr5M6JRSRtepp5w+TK4kAw==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.14.tgz", + "integrity": "sha512-FtEGm9mwtRYQNK43WMtUIadxHs/ja2rnDurB99os0ZoFTGG2IHuht2zD97W0wB8JbqEabT1XwSG9Y5wmN+ciEQ==", "cpu": [ "x64" ], @@ -5633,9 +4896,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.6.tgz", - "integrity": "sha512-Aqsd9afykVMuekzjm4X4TDqwxmG4CrzoOSFe0hZrn9SMio72l5eAPnMtYoe5LsIqtjV8MNprLfXaNbjHjTegmA==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.14.tgz", + "integrity": "sha512-Jp8KDlfq7Ntt2/BXr0y344cYgB1zf0DaLzDZ1ZJR6rYlAzWYSccLYcxHa97VGnsYhhPspMpmCvHid97oe2hl4A==", "cpu": [ "arm64" ], @@ -5649,9 +4912,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.6.tgz", - "integrity": "sha512-9h0hYnOeRVNeQgHQTvD1Im67faNSSzBZ7Adtxyu9urNLfBTJilMllFd2QuGHlKW5+uaT6ZH7ZWDb+c/enx7Lcg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.14.tgz", + "integrity": "sha512-I+cFsXF0OU0J9J4zdWiQKKLURO5dvCujH9Jr8N0cErdy54l9d4gfIxdctfTF+7FyXtWKLTCkp+oby9BQhkFGWA==", "cpu": [ "ia32" ], @@ -5665,9 +4928,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.6.tgz", - "integrity": "sha512-izeoB8glCSe6IIDQmrVm6bvR9muk9TeKgmtY7b6l1BwL4BFnTUk4dMmpbntT90bEVQn3JPCaPtUG4HfL8VuyuA==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.14.tgz", + "integrity": "sha512-NNrprQCK6d28mG436jVo2TD+vACHseUECacEBGZ9Ef0qfOIWS1XIt2MisQKG0Oea2VvLFl6tF/V4Lnx/H0Sn3Q==", "cpu": [ "x64" ], @@ -5686,10 +4949,11 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "node_modules/@swc/helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", - "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", "dependencies": { + "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, @@ -5697,6 +4961,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==", + "devOptional": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -5877,6 +5142,7 @@ "version": "8.44.3", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.3.tgz", "integrity": "sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g==", + "dev": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5886,6 +5152,7 @@ "version": "3.7.5", "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.5.tgz", "integrity": "sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA==", + "dev": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -5894,7 +5161,8 @@ "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true }, "node_modules/@types/express": { "version": "4.17.21", @@ -5954,7 +5222,8 @@ "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true }, "node_modules/@types/lodash": { "version": "4.17.7", @@ -6014,11 +5283,11 @@ } }, "node_modules/@types/node": { - "version": "20.14.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", - "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "version": "20.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", + "integrity": "sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/nodemailer": { @@ -6112,15 +5381,11 @@ "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, - "node_modules/@types/prismjs": { - "version": "1.26.3", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.3.tgz", - "integrity": "sha512-A0D0aTXvjlqJ5ZILMz3rNfDBOx9hHxLZYv2by47Sm/pqW35zzjusrZTryatjN/Rf8Us2gZrJD+KeHbUSTux1Cw==" - }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "dev": true }, "node_modules/@types/qs": { "version": "6.9.8", @@ -6135,22 +5400,15 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", - "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", + "version": "18.3.4", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz", + "integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==", + "dev": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, - "node_modules/@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/readdir-glob": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.2.tgz", @@ -6160,11 +5418,6 @@ "@types/node": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==" - }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -6265,27 +5518,17 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.8.tgz", "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, - "node_modules/@types/webpack": { - "version": "5.28.5", - "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", - "integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==", - "dependencies": { - "@types/node": "*", - "tapable": "^2.2.0", - "webpack": "^5" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", + "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/type-utils": "8.2.0", + "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -6309,15 +5552,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", + "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4" }, "engines": { @@ -6337,13 +5580,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6354,13 +5597,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", + "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/utils": "8.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -6378,9 +5621,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6391,13 +5634,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -6443,15 +5686,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6465,12 +5708,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/types": "8.2.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -6610,6 +5853,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dev": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -6618,22 +5862,26 @@ "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -6643,12 +5891,14 @@ "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -6660,6 +5910,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -6668,6 +5919,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -6675,12 +5927,14 @@ "node_modules/@webassemblyjs/utf8": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -6696,6 +5950,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", @@ -6708,6 +5963,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -6719,6 +5975,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", @@ -6732,6 +5989,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" @@ -6740,12 +5998,14 @@ "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true }, "node_modules/abbrev": { "version": "1.1.1", @@ -6798,6 +6058,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -7100,17 +6361,6 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, - "node_modules/aria-hidden": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", - "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -7164,38 +6414,6 @@ "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==" }, - "node_modules/autoprefixer": { - "version": "10.4.14", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", - "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - } - ], - "dependencies": { - "browserslist": "^4.21.5", - "caniuse-lite": "^1.0.30001464", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/b4a": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", @@ -7596,6 +6814,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "peer": true, "engines": { "node": ">= 6" } @@ -7699,6 +6918,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, "engines": { "node": ">=6.0" } @@ -7863,14 +7083,6 @@ "node": ">=0.8" } }, - "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", - "engines": { - "node": ">=6" - } - }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -8271,6 +7483,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "peer": true, "bin": { "cssesc": "bin/cssesc" }, @@ -8281,7 +7494,8 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true }, "node_modules/dayjs": { "version": "1.11.10", @@ -8327,7 +7541,8 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true }, "node_modules/deepmerge": { "version": "4.3.1", @@ -8403,11 +7618,6 @@ "node": ">=8" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, "node_modules/diacritics": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", @@ -8416,7 +7626,8 @@ "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "peer": true }, "node_modules/diff": { "version": "4.0.2", @@ -8449,7 +7660,8 @@ "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "peer": true }, "node_modules/docker-compose": { "version": "0.24.8", @@ -8700,18 +7912,6 @@ "node": ">=10.2.0" } }, - "node_modules/engine.io-client": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", - "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", - "xmlhttprequest-ssl": "~2.0.0" - } - }, "node_modules/engine.io-parser": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", @@ -8724,6 +7924,7 @@ "version": "5.17.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "dev": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -8773,7 +7974,8 @@ "node_modules/es-module-lexer": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz", - "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==" + "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==", + "dev": true }, "node_modules/esbuild": { "version": "0.20.2", @@ -8830,6 +8032,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "engines": { "node": ">=10" }, @@ -8838,15 +8041,16 @@ } }, "node_modules/eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", + "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -8885,6 +8089,14 @@ }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -8899,17 +8111,6 @@ "eslint": ">=7.0.0" } }, - "node_modules/eslint-config-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-config-turbo/-/eslint-config-turbo-1.10.12.tgz", - "integrity": "sha512-z3jfh+D7UGYlzMWGh+Kqz++hf8LOE96q3o5R8X4HTjmxaBWlLAWG+0Ounr38h+JLR2TJno0hU9zfzoPNkR9BdA==", - "dependencies": { - "eslint-plugin-turbo": "1.10.12" - }, - "peerDependencies": { - "eslint": ">6.6.0" - } - }, "node_modules/eslint-plugin-prettier": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", @@ -8940,25 +8141,6 @@ } } }, - "node_modules/eslint-plugin-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-plugin-turbo/-/eslint-plugin-turbo-1.10.12.tgz", - "integrity": "sha512-uNbdj+ohZaYo4tFJ6dStRXu2FZigwulR1b3URPXe0Q8YaE7thuekKNP+54CHtZPH9Zey9dmDx5btAQl9mfzGOw==", - "dependencies": { - "dotenv": "16.0.3" - }, - "peerDependencies": { - "eslint": ">6.6.0" - } - }, - "node_modules/eslint-plugin-turbo/node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", - "engines": { - "node": ">=12" - } - }, "node_modules/eslint-plugin-unicorn": { "version": "55.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-55.0.0.tgz", @@ -8996,6 +8178,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", + "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -9011,6 +8194,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -9022,6 +8206,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9037,6 +8222,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -9048,6 +8234,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -9058,12 +8245,14 @@ "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/espree": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "dev": true, "dependencies": { "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", @@ -9080,6 +8269,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -9104,6 +8294,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -9115,6 +8306,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -9126,6 +8318,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "engines": { "node": ">=4.0" } @@ -9143,6 +8336,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -9239,15 +8433,15 @@ } }, "node_modules/exiftool-vendored": { - "version": "28.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.0.tgz", - "integrity": "sha512-s2k92EB8LSeYjXv4agtpANeH8y1CsEThYqMm7AF1jP64PyFb40AoD0RGf69j28G6RqXkT5JGl4Xwk9kOy3IkjQ==", + "version": "28.2.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.1.tgz", + "integrity": "sha512-D3YsKErr3BbjKeJzUVsv6CVZ+SQNgAJKPFWVLXu0CBtr24FNuE3CZBXWKWysGu0MjzeDCNwQrQI5+bXUFeiYVA==", "dependencies": { "@photostructure/tz-lookup": "^10.0.0", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", "he": "^1.2.0", - "luxon": "^3.4.4" + "luxon": "^3.5.0" }, "optionalDependencies": { "exiftool-vendored.exe": "12.91.0", @@ -9360,7 +8554,8 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "node_modules/fast-diff": { "version": "1.3.0", @@ -9391,12 +8586,14 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -9437,6 +8634,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -9497,6 +8695,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -9512,6 +8711,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -9523,7 +8723,8 @@ "node_modules/flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true }, "node_modules/fluent-ffmpeg": { "version": "2.1.3", @@ -9604,41 +8805,6 @@ "node": ">= 0.6" } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/framer-motion": { - "version": "10.17.4", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.17.4.tgz", - "integrity": "sha512-CYBSs6cWfzcasAX8aofgKFZootmkQtR4qxbfTOksBLny/lbUfkGbQAFOS3qnl6Uau1N9y8tUpI7mVIrHgkFjLQ==", - "dependencies": { - "tslib": "^2.4.0" - }, - "optionalDependencies": { - "@emotion/is-prop-valid": "^0.8.2" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -9874,14 +9040,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "engines": { - "node": ">=6" - } - }, "node_modules/get-port": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", @@ -9954,7 +9112,8 @@ "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.1", @@ -10303,6 +9462,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, "engines": { "node": ">= 4" } @@ -10337,6 +9497,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "engines": { "node": ">=0.8.19" } @@ -10394,14 +9555,6 @@ "node": ">=12.0.0" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/ioredis": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", @@ -10522,6 +9675,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10652,6 +9806,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -10765,7 +9920,8 @@ "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -10781,7 +9937,8 @@ "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, "node_modules/json5": { "version": "2.2.3", @@ -10816,6 +9973,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "dependencies": { "json-buffer": "3.0.1" } @@ -10870,6 +10028,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -10887,6 +10046,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "peer": true, "engines": { "node": ">=10" } @@ -10909,6 +10069,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, "engines": { "node": ">=6.11.5" } @@ -10917,6 +10078,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -11110,7 +10272,8 @@ "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true }, "node_modules/merge2": { "version": "1.4.1", @@ -11270,14 +10433,6 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, - "node_modules/mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", - "dependencies": { - "obliterator": "^2.0.1" - } - }, "node_modules/mock-fs": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", @@ -11440,7 +10595,8 @@ "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true }, "node_modules/nearley": { "version": "2.20.1", @@ -11484,9 +10640,9 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/nest-commander": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.14.0.tgz", - "integrity": "sha512-3HEfsEzoKEZ/5/cptkXlL8/31qohPxtMevoFo4j9NMe3q5PgI/0TgTYN/6py9GnFD51jSasEfFGChs1BJ+Enag==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.15.0.tgz", + "integrity": "sha512-o9VEfFj/w2nm+hQi6fnkxL1GAFZW/KmuGcIE7/B/TX0gwm0QVy8svAF75EQm8wrDjcvWS7Cx/ArnkFn2C+iM2w==", "dependencies": { "@fig/complete-commander": "^3.0.0", "@golevelup/nestjs-discovery": "4.0.1", @@ -11548,12 +10704,12 @@ } }, "node_modules/next": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-14.1.4.tgz", - "integrity": "sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", + "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", "dependencies": { - "@next/env": "14.1.4", - "@swc/helpers": "0.5.2", + "@next/env": "14.2.3", + "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", @@ -11567,18 +10723,19 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.1.4", - "@next/swc-darwin-x64": "14.1.4", - "@next/swc-linux-arm64-gnu": "14.1.4", - "@next/swc-linux-arm64-musl": "14.1.4", - "@next/swc-linux-x64-gnu": "14.1.4", - "@next/swc-linux-x64-musl": "14.1.4", - "@next/swc-win32-arm64-msvc": "14.1.4", - "@next/swc-win32-ia32-msvc": "14.1.4", - "@next/swc-win32-x64-msvc": "14.1.4" + "@next/swc-darwin-arm64": "14.2.3", + "@next/swc-darwin-x64": "14.2.3", + "@next/swc-linux-arm64-gnu": "14.2.3", + "@next/swc-linux-arm64-musl": "14.2.3", + "@next/swc-linux-x64-gnu": "14.2.3", + "@next/swc-linux-x64-musl": "14.2.3", + "@next/swc-win32-arm64-msvc": "14.2.3", + "@next/swc-win32-ia32-msvc": "14.2.3", + "@next/swc-win32-x64-msvc": "14.2.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" @@ -11587,6 +10744,9 @@ "@opentelemetry/api": { "optional": true }, + "@playwright/test": { + "optional": true + }, "sass": { "optional": true } @@ -11719,14 +10879,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/notepack.io": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", @@ -11794,11 +10946,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obliterator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", - "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==" - }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -11895,6 +11042,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, "dependencies": { "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", @@ -11941,6 +11089,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -11955,6 +11104,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -12049,6 +11199,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "engines": { "node": ">=8" } @@ -12252,6 +11403,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", "engines": { "node": ">=12" }, @@ -12263,6 +11415,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12271,6 +11424,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "peer": true, "engines": { "node": ">= 6" } @@ -12315,6 +11469,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "peer": true, "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -12331,6 +11486,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "peer": true, "dependencies": { "camelcase-css": "^2.0.1" }, @@ -12359,6 +11515,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" @@ -12383,6 +11540,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "peer": true, "engines": { "node": ">=14" }, @@ -12394,6 +11552,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "peer": true, "dependencies": { "postcss-selector-parser": "^6.0.11" }, @@ -12412,6 +11571,7 @@ "version": "6.0.16", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12423,7 +11583,8 @@ "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "peer": true }, "node_modules/postgres-array": { "version": "2.0.0", @@ -12469,6 +11630,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "engines": { "node": ">= 0.8.0" } @@ -12519,26 +11681,6 @@ } } }, - "node_modules/prism-react-renderer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.1.0.tgz", - "integrity": "sha512-I5cvXHjA1PVGbGm1MsWCpvBCRrYyxEri0MC7/JbfIfYfcXAxHyO5PaUjs3A8H5GW6kJcLhTHxxMaOZZpRZD2iQ==", - "dependencies": { - "@types/prismjs": "^1.26.0", - "clsx": "^1.2.1" - }, - "peerDependencies": { - "react": ">=16.0.0" - } - }, - "node_modules/prism-react-renderer/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "engines": { - "node": ">=6" - } - }, "node_modules/prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -12664,6 +11806,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, "engines": { "node": ">=6" } @@ -12729,6 +11872,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -12770,6 +11914,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12779,53 +11924,27 @@ } }, "node_modules/react-email": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-2.1.6.tgz", - "integrity": "sha512-BtR9VI1CMq4953wfiBmzupKlWcRThaWG2dDgl1vWAllK3tNNmJNerwY4VlmASRDQZE3LpLXU3+lf8N/VAKdbZQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-3.0.1.tgz", + "integrity": "sha512-G4Bkx2ULIScy/0Z8nnWywHt0W1iTkaYCdh9rWNuQ3eVZ6B3ttTUDE9uUy3VNQ8dtQbmG0cpt8+XmImw7mMBW6Q==", "dependencies": { "@babel/core": "7.24.5", "@babel/parser": "7.24.5", - "@radix-ui/colors": "1.0.1", - "@radix-ui/react-collapsible": "1.1.0", - "@radix-ui/react-popover": "1.1.1", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-toggle-group": "1.1.0", - "@radix-ui/react-tooltip": "1.1.1", - "@swc/core": "1.3.101", - "@types/react": "18.2.47", - "@types/react-dom": "^18.2.0", - "@types/webpack": "5.28.5", - "autoprefixer": "10.4.14", "chalk": "4.1.2", - "chokidar": "3.5.3", - "clsx": "2.1.0", + "chokidar": "3.6.0", "commander": "11.1.0", "debounce": "2.0.0", "esbuild": "0.19.11", - "eslint-config-prettier": "9.0.0", - "eslint-config-turbo": "1.10.12", - "framer-motion": "10.17.4", "glob": "10.3.4", "log-symbols": "4.1.0", "mime-types": "2.1.35", - "next": "14.1.4", + "next": "14.2.3", "normalize-path": "3.0.0", "ora": "5.4.1", - "postcss": "8.4.38", - "prism-react-renderer": "2.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "socket.io": "4.7.3", - "socket.io-client": "4.7.3", - "sonner": "1.3.1", - "source-map-js": "1.0.2", - "stacktrace-parser": "0.1.10", - "tailwind-merge": "2.2.0", - "tailwindcss": "3.4.0", - "typescript": "5.1.6" + "socket.io": "4.7.5" }, "bin": { - "email": "cli/index.js" + "email": "dist/cli/index.js" }, "engines": { "node": ">=18.0.0" @@ -13176,208 +12295,6 @@ "node": ">=12" } }, - "node_modules/react-email/node_modules/@swc/core": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.101.tgz", - "integrity": "sha512-w5aQ9qYsd/IYmXADAnkXPGDMTqkQalIi+kfFf/MHRKTpaOL7DHjMXwPp/n8hJ0qNjRvchzmPtOqtPBiER50d8A==", - "hasInstallScript": true, - "dependencies": { - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.101", - "@swc/core-darwin-x64": "1.3.101", - "@swc/core-linux-arm-gnueabihf": "1.3.101", - "@swc/core-linux-arm64-gnu": "1.3.101", - "@swc/core-linux-arm64-musl": "1.3.101", - "@swc/core-linux-x64-gnu": "1.3.101", - "@swc/core-linux-x64-musl": "1.3.101", - "@swc/core-win32-arm64-msvc": "1.3.101", - "@swc/core-win32-ia32-msvc": "1.3.101", - "@swc/core-win32-x64-msvc": "1.3.101" - }, - "peerDependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/react-email/node_modules/@swc/core-darwin-arm64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.101.tgz", - "integrity": "sha512-mNFK+uHNPRXSnfTOG34zJOeMl2waM4hF4a2NY7dkMXrPqw9CoJn4MwTXJcyMiSz1/BnNjjTCHF3Yhj0jPxmkzQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-darwin-x64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.101.tgz", - "integrity": "sha512-B085j8XOx73Fg15KsHvzYWG262bRweGr3JooO1aW5ec5pYbz5Ew9VS5JKYS03w2UBSxf2maWdbPz2UFAxg0whw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.101.tgz", - "integrity": "sha512-9xLKRb6zSzRGPqdz52Hy5GuB1lSjmLqa0lST6MTFads3apmx4Vgs8Y5NuGhx/h2I8QM4jXdLbpqQlifpzTlSSw==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.101.tgz", - "integrity": "sha512-oE+r1lo7g/vs96Weh2R5l971dt+ZLuhaUX+n3BfDdPxNHfObXgKMjO7E+QS5RbGjv/AwiPCxQmbdCp/xN5ICJA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-arm64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.101.tgz", - "integrity": "sha512-OGjYG3H4BMOTnJWJyBIovCez6KiHF30zMIu4+lGJTCrxRI2fAjGLml3PEXj8tC3FMcud7U2WUn6TdG0/te2k6g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-x64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.101.tgz", - "integrity": "sha512-/kBMcoF12PRO/lwa8Z7w4YyiKDcXQEiLvM+S3G9EvkoKYGgkkz4Q6PSNhF5rwg/E3+Hq5/9D2R+6nrkF287ihg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-x64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.101.tgz", - "integrity": "sha512-kDN8lm4Eew0u1p+h1l3JzoeGgZPQ05qDE0czngnjmfpsH2sOZxVj1hdiCwS5lArpy7ktaLu5JdRnx70MkUzhXw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.101.tgz", - "integrity": "sha512-9Wn8TTLWwJKw63K/S+jjrZb9yoJfJwCE2RV5vPCCWmlMf3U1AXj5XuWOLUX+Rp2sGKau7wZKsvywhheWm+qndQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.101.tgz", - "integrity": "sha512-onO5KvICRVlu2xmr4//V2je9O2XgS1SGKpbX206KmmjcJhXN5EYLSxW9qgg+kgV5mip+sKTHTAu7IkzkAtElYA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-win32-x64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.101.tgz", - "integrity": "sha512-T3GeJtNQV00YmiVw/88/nxJ/H43CJvFnpvBHCVn17xbahiVUOPOduh3rc9LgAkKiNt/aV8vU3OJR+6PhfMR7UQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@types/react": { - "version": "18.2.47", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.47.tgz", - "integrity": "sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/react-email/node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, "node_modules/react-email/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -13386,32 +12303,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/react-email/node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/react-email/node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -13457,17 +12348,6 @@ "@esbuild/win32-x64": "0.19.11" } }, - "node_modules/react-email/node_modules/eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, "node_modules/react-email/node_modules/glob": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.4.tgz", @@ -13503,90 +12383,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/react-email/node_modules/socket.io": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.3.tgz", - "integrity": "sha512-SE+UIQXBQE+GPG2oszWMlsEmWtHVqw/h1VrYJGK5/MC7CH5p58N448HwIrtREcvR4jfdOJAY4ieQfxMr55qbbw==", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.5.2", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/react-email/node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-email/node_modules/tailwindcss": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", - "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/react-email/node_modules/tailwindcss/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/react-email/node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/react-promise-suspense": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", @@ -13600,77 +12396,11 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==" }, - "node_modules/react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "dependencies": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", - "dependencies": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "dependencies": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "peer": true, "dependencies": { "pify": "^2.3.0" } @@ -13863,11 +12593,6 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -14251,6 +12976,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -14259,6 +12985,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -14276,6 +13003,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14291,6 +13019,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, "peerDependencies": { "ajv": "^6.9.1" } @@ -14298,7 +13027,8 @@ "node_modules/schema-utils/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/selderee": { "version": "0.11.0", @@ -14367,6 +13097,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, "dependencies": { "randombytes": "^2.1.0" } @@ -14446,42 +13177,41 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/sharp": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.4.tgz", - "integrity": "sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "hasInstallScript": true, "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", - "semver": "^7.6.0" + "semver": "^7.6.3" }, "engines": { - "libvips": ">=8.15.2", "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.4", - "@img/sharp-darwin-x64": "0.33.4", - "@img/sharp-libvips-darwin-arm64": "1.0.2", - "@img/sharp-libvips-darwin-x64": "1.0.2", - "@img/sharp-libvips-linux-arm": "1.0.2", - "@img/sharp-libvips-linux-arm64": "1.0.2", - "@img/sharp-libvips-linux-s390x": "1.0.2", - "@img/sharp-libvips-linux-x64": "1.0.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2", - "@img/sharp-libvips-linuxmusl-x64": "1.0.2", - "@img/sharp-linux-arm": "0.33.4", - "@img/sharp-linux-arm64": "0.33.4", - "@img/sharp-linux-s390x": "0.33.4", - "@img/sharp-linux-x64": "0.33.4", - "@img/sharp-linuxmusl-arm64": "0.33.4", - "@img/sharp-linuxmusl-x64": "0.33.4", - "@img/sharp-wasm32": "0.33.4", - "@img/sharp-win32-ia32": "0.33.4", - "@img/sharp-win32-x64": "0.33.4" + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" } }, "node_modules/shebang-command": { @@ -14624,20 +13354,6 @@ } } }, - "node_modules/socket.io-client": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.3.tgz", - "integrity": "sha512-nU+ywttCyBitXIl9Xe0RSEfek4LneYkJxCeNnKCuhwoH4jGXO1ipIUw/VA/+Vvv2G1MTym11fzFC0SxkrcfXDw==", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", - "engine.io-client": "~6.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -14650,15 +13366,6 @@ "node": ">=10.0.0" } }, - "node_modules/sonner": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.3.1.tgz", - "integrity": "sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==", - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -14680,6 +13387,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -14689,6 +13397,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -14740,9 +13449,9 @@ } }, "node_modules/sql-formatter": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.3.2.tgz", - "integrity": "sha512-pNxSMf5DtwhpZ8gUcOGCGZIWtCcyAUx9oLgAtlO4ag7DvlfnETL0BGqXaISc84pNrXvTWmt8Wal1FWKxdTsL3Q==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.0.tgz", + "integrity": "sha512-h3uVulRmOfARvDejuSzs9GMbua/UmGCKiP08zyHT1PnG376zk9CHVsDAcKIc9TcIwIrDH3YULWwI4PrXdmLRVw==", "dev": true, "dependencies": { "argparse": "^2.0.1", @@ -14787,25 +13496,6 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, - "node_modules/stacktrace-parser": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz", - "integrity": "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==", - "dependencies": { - "type-fest": "^0.7.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/stacktrace-parser/node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", - "engines": { - "node": ">=8" - } - }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -14937,6 +13627,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "engines": { "node": ">=8" }, @@ -14970,6 +13661,7 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", @@ -15064,18 +13756,6 @@ "url": "https://www.buymeacoffee.com/systeminfo" } }, - "node_modules/tailwind-merge": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.0.tgz", - "integrity": "sha512-SqqhhaL0T06SW59+JVNfAqKdqLs0497esifRrZ7jOaefP3o64fdFNDMrAQWZFMxTLJPiHVjRLUywT8uFz1xNWQ==", - "dependencies": { - "@babel/runtime": "^7.23.5" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, "node_modules/tailwindcss": { "version": "3.4.6", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", @@ -15166,6 +13846,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, "engines": { "node": ">=6" } @@ -15238,6 +13919,7 @@ "version": "5.27.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", + "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -15255,6 +13937,7 @@ "version": "5.3.10", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", @@ -15288,6 +13971,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -15301,6 +13985,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -15314,7 +13999,8 @@ "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true }, "node_modules/test-exclude": { "version": "7.0.1", @@ -15403,7 +14089,8 @@ "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true }, "node_modules/thenify": { "version": "3.3.1", @@ -15550,7 +14237,8 @@ "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "peer": true }, "node_modules/ts-node": { "version": "10.9.2", @@ -15668,6 +14356,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -15959,9 +14648,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/universalify": { "version": "2.0.0", @@ -16042,51 +14731,11 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "dependencies": { "punycode": "^2.1.0" } }, - "node_modules/use-callback-ref": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", @@ -16343,6 +14992,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -16365,9 +15015,10 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.92.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", - "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.93.0.tgz", + "integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==", + "dev": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", @@ -16423,6 +15074,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, "engines": { "node": ">=10.13.0" } @@ -16437,6 +15089,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -16449,6 +15102,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, "engines": { "node": ">=4.0" } @@ -16560,14 +15214,6 @@ } } }, - "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -16644,6 +15290,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "engines": { "node": ">=10" }, @@ -16707,12 +15354,14 @@ "@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==" + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true }, "@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==" + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "peer": true }, "@ampproject/remapping": { "version": "2.3.0", @@ -17050,14 +15699,6 @@ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==" }, - "@babel/runtime": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", - "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", - "requires": { - "regenerator-runtime": "^0.14.0" - } - }, "@babel/template": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.6.tgz", @@ -17157,29 +15798,14 @@ } }, "@emnapi/runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.1.1.tgz", - "integrity": "sha512-3bfqkzuR1KLx57nZfjr2NLnFOobvyS0aTszaEGCGqmYMVDRaGvgIZbjGSV/MHSSmLgQ/b9JFHQ5xm5WRZYd+XQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", "optional": true, "requires": { "tslib": "^2.4.0" } }, - "@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "optional": true, - "requires": { - "@emotion/memoize": "0.7.4" - } - }, - "@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "optional": true - }, "@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -17345,6 +15971,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, "requires": { "eslint-visitor-keys": "^3.3.0" } @@ -17352,12 +15979,14 @@ "@eslint-community/regexpp": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==" + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true }, "@eslint/config-array": { "version": "0.17.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.17.1.tgz", "integrity": "sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==", + "dev": true, "requires": { "@eslint/object-schema": "^2.1.4", "debug": "^4.3.1", @@ -17368,6 +15997,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -17384,6 +16014,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -17394,24 +16025,28 @@ "globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==" + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, "@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==" + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", + "dev": true }, "@eslint/object-schema": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", - "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==" + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true }, "@fastify/busboy": { "version": "2.1.1", @@ -17419,36 +16054,6 @@ "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", "dev": true }, - "@floating-ui/core": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.4.tgz", - "integrity": "sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==", - "requires": { - "@floating-ui/utils": "^0.2.4" - } - }, - "@floating-ui/dom": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.7.tgz", - "integrity": "sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==", - "requires": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.4" - } - }, - "@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", - "requires": { - "@floating-ui/dom": "^1.0.0" - } - }, - "@floating-ui/utils": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz", - "integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==" - }, "@golevelup/nestjs-discovery": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.1.tgz", @@ -17529,152 +16134,154 @@ "@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true }, "@humanwhocodes/retry": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", - "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==" + "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "dev": true }, "@img/sharp-darwin-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.4.tgz", - "integrity": "sha512-p0suNqXufJs9t3RqLBO6vvrgr5OhgbWp76s5gTRvdmxmuv9E1rcaqGUsl3l4mKVmXPkTkTErXediAui4x+8PSA==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "optional": true, "requires": { - "@img/sharp-libvips-darwin-arm64": "1.0.2" + "@img/sharp-libvips-darwin-arm64": "1.0.4" } }, "@img/sharp-darwin-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.4.tgz", - "integrity": "sha512-0l7yRObwtTi82Z6ebVI2PnHT8EB2NxBgpK2MiKJZJ7cz32R4lxd001ecMhzzsZig3Yv9oclvqqdV93jo9hy+Dw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", "optional": true, "requires": { - "@img/sharp-libvips-darwin-x64": "1.0.2" + "@img/sharp-libvips-darwin-x64": "1.0.4" } }, "@img/sharp-libvips-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-tcK/41Rq8IKlSaKRCCAuuY3lDJjQnYIW1UXU1kxcEKrfL8WR7N6+rzNoOxoQRJWTAECuKwgAHnPvqXGN8XfkHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", "optional": true }, "@img/sharp-libvips-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.2.tgz", - "integrity": "sha512-Ofw+7oaWa0HiiMiKWqqaZbaYV3/UGL2wAPeLuJTx+9cXpCRdvQhCLG0IH8YGwM0yGWGLpsF4Su9vM1o6aer+Fw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", "optional": true }, "@img/sharp-libvips-linux-arm": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.2.tgz", - "integrity": "sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", "optional": true }, "@img/sharp-libvips-linux-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.2.tgz", - "integrity": "sha512-x7kCt3N00ofFmmkkdshwj3vGPCnmiDh7Gwnd4nUwZln2YjqPxV1NlTyZOvoDWdKQVDL911487HOueBvrpflagw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", "optional": true }, "@img/sharp-libvips-linux-s390x": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.2.tgz", - "integrity": "sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", "optional": true }, "@img/sharp-libvips-linux-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.2.tgz", - "integrity": "sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", "optional": true }, "@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.2.tgz", - "integrity": "sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", "optional": true }, "@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.2.tgz", - "integrity": "sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", "optional": true }, "@img/sharp-linux-arm": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.4.tgz", - "integrity": "sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", "optional": true, "requires": { - "@img/sharp-libvips-linux-arm": "1.0.2" + "@img/sharp-libvips-linux-arm": "1.0.5" } }, "@img/sharp-linux-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.4.tgz", - "integrity": "sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", "optional": true, "requires": { - "@img/sharp-libvips-linux-arm64": "1.0.2" + "@img/sharp-libvips-linux-arm64": "1.0.4" } }, "@img/sharp-linux-s390x": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.4.tgz", - "integrity": "sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", "optional": true, "requires": { - "@img/sharp-libvips-linux-s390x": "1.0.2" + "@img/sharp-libvips-linux-s390x": "1.0.4" } }, "@img/sharp-linux-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.4.tgz", - "integrity": "sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", "optional": true, "requires": { - "@img/sharp-libvips-linux-x64": "1.0.2" + "@img/sharp-libvips-linux-x64": "1.0.4" } }, "@img/sharp-linuxmusl-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.4.tgz", - "integrity": "sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", "optional": true, "requires": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" } }, "@img/sharp-linuxmusl-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.4.tgz", - "integrity": "sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", "optional": true, "requires": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.2" + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, "@img/sharp-wasm32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.4.tgz", - "integrity": "sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", "optional": true, "requires": { - "@emnapi/runtime": "^1.1.1" + "@emnapi/runtime": "^1.2.0" } }, "@img/sharp-win32-ia32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.4.tgz", - "integrity": "sha512-99SJ91XzUhYHbx7uhK3+9Lf7+LjwMGQZMDlO/E/YVJ7Nc3lyDFZPGhjwiYdctoH2BOzW9+TnfqcaMKt0jHLdqw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", "optional": true }, "@img/sharp-win32-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.4.tgz", - "integrity": "sha512-3QLocdTRVIrFNye5YocZl+KKpYKP+fksi1QhmOArgx7GyhIbQp/WrJRu176jm8IxromS7RIkzMiMINVdBtC8Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", "optional": true }, "@ioredis/commands": { @@ -17770,6 +16377,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, "requires": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -17859,26 +16467,26 @@ "optional": true }, "@nestjs/bull-shared": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.0.tgz", - "integrity": "sha512-cSi6CyPECHDFumnHWWfwLCnbc6hm5jXt7FqzJ0Id6EhGqdz5ja0FmgRwXoS4xoMA2RRjlxn2vGXr4YOaHBAeig==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.1.tgz", + "integrity": "sha512-zvnTvSq6OJ92omcsFUwaUmPbM3PRgWkIusHPB5TE3IFS7nNdM3OwF+kfe56sgKjMtQQMe/56lok0S04OtPMX5Q==", "requires": { "tslib": "2.6.3" } }, "@nestjs/bullmq": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.0.tgz", - "integrity": "sha512-lHXWDocXh1Yl6unsUzGFEKmK02mu0DdI35cdBp3Fq/9D5V3oLuWjwAPFnTztedshIjlFmNW6x5mdaT5WZ0AV1Q==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.1.tgz", + "integrity": "sha512-nDR0hDabmtXt5gsb5R786BJsGIJoWh/79sVmRETXf4S45+fvdqG1XkCKAeHF9TO9USodw9m+XBNKysTnkY41gw==", "requires": { - "@nestjs/bull-shared": "^10.2.0", + "@nestjs/bull-shared": "^10.2.1", "tslib": "2.6.3" } }, "@nestjs/cli": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.2.tgz", - "integrity": "sha512-fQexIfLHfp6GUgX+CO4fOg+AEwV5ox/LHotQhyZi9wXUQDyIqS0NTTbumr//62EcX35qV4nU0359nYnuEdzG+A==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.4.tgz", + "integrity": "sha512-WKERbSZJGof0+9XeeMmWnb/9FpNxogcB5eTJTHjc9no0ymdTw3jTzT+KZL9iC/hGqBpuomDLaNFCYbAOt29nBw==", "dev": true, "requires": { "@angular-devkit/core": "17.3.8", @@ -17898,7 +16506,7 @@ "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.3.3", - "webpack": "5.92.1", + "webpack": "5.93.0", "webpack-node-externals": "3.0.0" }, "dependencies": { @@ -17911,9 +16519,9 @@ } }, "@nestjs/common": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.10.tgz", - "integrity": "sha512-H8k0jZtxk1IdtErGDmxFRy0PfcOAUg41Prrqpx76DQusGGJjsaovs1zjXVD1rZWaVYchfT1uczJ6L4Kio10VNg==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", + "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", "requires": { "iterare": "1.2.1", "tslib": "2.6.3", @@ -17931,9 +16539,9 @@ } }, "@nestjs/core": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.10.tgz", - "integrity": "sha512-ZbQ4jovQyzHtCGCrzK5NdtW1SYO2fHSsgSY1+/9WdruYCUra+JDkWEXgZ4M3Hv480Dl3OXehAmY1wCOojeMyMQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", + "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", "requires": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -17958,9 +16566,9 @@ "requires": {} }, "@nestjs/platform-express": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.10.tgz", - "integrity": "sha512-wK2ow3CZI2KFqWeEpPmoR300OB6BcBLxARV1EiClJLCj4S1mZsoCmS0YWgpk3j1j6mo0SI8vNLi/cC2iZPEPQA==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", + "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", "requires": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -17970,9 +16578,9 @@ } }, "@nestjs/platform-socket.io": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.10.tgz", - "integrity": "sha512-LRd+nGWhUu9hND1txCLPZd78Hea+qKJVENb+c9aDU04T24GRjsInDF2RANMR16JLQFcI9mclktDWX4plE95SHg==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", + "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", "requires": { "socket.io": "4.7.5", "tslib": "2.6.3" @@ -17995,9 +16603,9 @@ } }, "@nestjs/schematics": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.3.tgz", - "integrity": "sha512-aLJ4Nl/K/u6ZlgLa0NjKw5CuBOIgc6vudF42QvmGueu5FaMGM6IJrAuEvB5T2kr0PAfVwYmDFBBHCWdYhTw4Tg==", + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.4.tgz", + "integrity": "sha512-QpY8ez9cTvXXPr3/KBrtSgXQHMSV6BkOUYy2c2TTe6cBqriEdGnCYqGl8cnfrQl3632q3lveQPaZ/c127dHsEw==", "dev": true, "requires": { "@angular-devkit/core": "17.3.8", @@ -18029,9 +16637,9 @@ } }, "@nestjs/testing": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.10.tgz", - "integrity": "sha512-i3HAtVQJijxNxJq1k39aelyJlyEIBRONys7IipH/4r8W0J+M1V+y5EKDOyi4j1SdNSb/vmNyWpZ2/ewZjl3kRA==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", + "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", "dev": true, "requires": { "tslib": "2.6.3" @@ -18046,9 +16654,9 @@ } }, "@nestjs/websockets": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.10.tgz", - "integrity": "sha512-F/fhAC0ylAhjfCZj4Xrgc0yTJ/qltooDCa+fke7BFZLofLmE0yj7WzBVrBHsk/46kppyRcs5XrYjIQLqcDze8g==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", + "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", "requires": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -18056,62 +16664,62 @@ } }, "@next/env": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.4.tgz", - "integrity": "sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==" + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", + "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==" }, "@next/swc-darwin-arm64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.4.tgz", - "integrity": "sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", + "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", "optional": true }, "@next/swc-darwin-x64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.4.tgz", - "integrity": "sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", + "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", "optional": true }, "@next/swc-linux-arm64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.4.tgz", - "integrity": "sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", + "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", "optional": true }, "@next/swc-linux-arm64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.4.tgz", - "integrity": "sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", + "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", "optional": true }, "@next/swc-linux-x64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.4.tgz", - "integrity": "sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", + "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", "optional": true }, "@next/swc-linux-x64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.4.tgz", - "integrity": "sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", + "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", "optional": true }, "@next/swc-win32-arm64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.4.tgz", - "integrity": "sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", + "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", "optional": true }, "@next/swc-win32-ia32-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.4.tgz", - "integrity": "sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", + "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", "optional": true }, "@next/swc-win32-x64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.4.tgz", - "integrity": "sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", + "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", "optional": true }, "@nodelib/fs.scandir": { @@ -19267,420 +17875,131 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "@radix-ui/colors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-1.0.1.tgz", - "integrity": "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg==" - }, - "@radix-ui/primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", - "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" - }, - "@radix-ui/react-arrow": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", - "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", - "requires": { - "@radix-ui/react-primitive": "2.0.0" - } - }, - "@radix-ui/react-collapsible": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.0.tgz", - "integrity": "sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-collection": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", - "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" - } - }, - "@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", - "requires": {} - }, - "@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "requires": {} - }, - "@radix-ui/react-direction": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", - "requires": {} - }, - "@radix-ui/react-dismissable-layer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", - "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" - } - }, - "@radix-ui/react-focus-guards": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz", - "integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==", - "requires": {} - }, - "@radix-ui/react-focus-scope": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", - "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0" - } - }, - "@radix-ui/react-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", - "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", - "requires": { - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-popover": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", - "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" - } - }, - "@radix-ui/react-popper": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", - "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", - "requires": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-rect": "1.1.0", - "@radix-ui/react-use-size": "1.1.0", - "@radix-ui/rect": "1.1.0" - } - }, - "@radix-ui/react-portal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", - "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", - "requires": { - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-presence": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", - "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", - "requires": { - "@radix-ui/react-slot": "1.1.0" - } - }, - "@radix-ui/react-roving-focus": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", - "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - } - }, - "@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0" - } - }, - "@radix-ui/react-toggle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", - "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - } - }, - "@radix-ui/react-toggle-group": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", - "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-toggle": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - } - }, - "@radix-ui/react-tooltip": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.1.tgz", - "integrity": "sha512-LLE8nzNE4MzPMw3O2zlVlkLFid3y9hMUs7uCbSHyKSo+tCN4yMCf+ZCCcfrYgsOC0TiHBPQ1mtpJ2liY3ZT3SQ==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", - "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", - "requires": {} - }, - "@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", - "requires": { - "@radix-ui/react-use-callback-ref": "1.1.0" - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", - "requires": { - "@radix-ui/react-use-callback-ref": "1.1.0" - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", - "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", - "requires": {} - }, - "@radix-ui/react-use-rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", - "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", - "requires": { - "@radix-ui/rect": "1.1.0" - } - }, - "@radix-ui/react-use-size": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", - "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", - "requires": { - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-visually-hidden": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", - "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", - "requires": { - "@radix-ui/react-primitive": "2.0.0" - } - }, - "@radix-ui/rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" - }, "@react-email/body": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.9.tgz", - "integrity": "sha512-bSGF6j+MbfQKYnnN+Kf57lGp/J+ci+435OMIv/BKAtfmNzHL+ptRrsINJELiO8QzwnZmQjTGKSMAMMJiQS+xwQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.10.tgz", + "integrity": "sha512-dMJyL9aU25ieatdPtVjCyQ/WHZYHwNc+Hy/XpF8Cc18gu21cUynVEeYQzFSeigDRMeBQ3PGAyjVDPIob7YlGwA==", "requires": {} }, "@react-email/button": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.16.tgz", - "integrity": "sha512-paptUerzDhKHEUmBuT0UecCoqo3N6ZQSyDKC1hFALTwKReGW2xQATisinho9Ybh9ZGw6IZ3n1nGtmX5k2sX70Q==", + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.17.tgz", + "integrity": "sha512-ioHdsk+BpGS/PqjU6JS7tUrVy9yvbUx92Z+Cem2+MbYp55oEwQ9VHf7u4f5NoM0gdhfKSehBwRdYlHt/frEMcg==", "requires": {} }, "@react-email/code-block": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.6.tgz", - "integrity": "sha512-i+TEeI7AyG1pmtO2Mr+TblV08zQnOtTlYB/v45kFMlDWWKTkvIV33oLRqLYOFhCIvoO5fDZA9T+4m6PvhmcNwQ==", + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.7.tgz", + "integrity": "sha512-3lYLwn9rK16I4JmTR/sTzAJMVHzUmmcT1PT27+TXnQyBCfpfDV+VockSg1qhsgCusA/u6j0C97BMsa96AWEbbw==", "requires": { "prismjs": "1.29.0" } }, "@react-email/code-inline": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.3.tgz", - "integrity": "sha512-SY5Nn4KhjcqqEBHvUwFlOLNmUT78elIGR+Y14eg02LrVKQJ38mFCfXNGDLk4wbP/2dnidkLYq9+60nf7mFMhnQ==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.4.tgz", + "integrity": "sha512-zj3oMQiiUCZbddSNt3k0zNfIBFK0ZNDIzzDyBaJKy6ZASTtWfB+1WFX0cpTX8q0gUiYK+A94rk5Qp68L6YXjXQ==", "requires": {} }, "@react-email/column": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.11.tgz", - "integrity": "sha512-KvrPuQFn0hlItRRL3vmRuOJgKG+8I0oO9HM5ReLMi5Ns313JSEQogCJaXuOEFkOVeuu5YyY6zy/+5Esccc1AxQ==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.12.tgz", + "integrity": "sha512-Rsl7iSdDaeHZO938xb+0wR5ud0Z3MVfdtPbNKJNojZi2hApwLAQXmDrnn/AcPDM5Lpl331ZljJS8vHTWxxkvKw==", "requires": {} }, "@react-email/components": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.22.tgz", - "integrity": "sha512-GO6F+fS3c3aQ6OnqL8esQ/KqtrPGwz80U6uQ8Nd/ETpgFt7y1PXvSGfr8v12wyLffAagdowc/JjoThfIr0L6aA==", + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.23.tgz", + "integrity": "sha512-RcBoffx2IZG6quLBXo5sj3fF47rKmmkiMhG1ZBua4nFjHYlmW8j1uUMyO5HNglxIF9E52NYq4sF7XeZRp9jYjg==", "requires": { - "@react-email/body": "0.0.9", - "@react-email/button": "0.0.16", - "@react-email/code-block": "0.0.6", - "@react-email/code-inline": "0.0.3", - "@react-email/column": "0.0.11", - "@react-email/container": "0.0.13", - "@react-email/font": "0.0.7", - "@react-email/head": "0.0.10", - "@react-email/heading": "0.0.13", - "@react-email/hr": "0.0.9", - "@react-email/html": "0.0.9", - "@react-email/img": "0.0.9", - "@react-email/link": "0.0.9", - "@react-email/markdown": "0.0.11", - "@react-email/preview": "0.0.10", - "@react-email/render": "0.0.17", - "@react-email/row": "0.0.9", - "@react-email/section": "0.0.13", - "@react-email/tailwind": "0.0.19", - "@react-email/text": "0.0.9" + "@react-email/body": "0.0.10", + "@react-email/button": "0.0.17", + "@react-email/code-block": "0.0.7", + "@react-email/code-inline": "0.0.4", + "@react-email/column": "0.0.12", + "@react-email/container": "0.0.14", + "@react-email/font": "0.0.8", + "@react-email/head": "0.0.11", + "@react-email/heading": "0.0.14", + "@react-email/hr": "0.0.10", + "@react-email/html": "0.0.10", + "@react-email/img": "0.0.10", + "@react-email/link": "0.0.10", + "@react-email/markdown": "0.0.12", + "@react-email/preview": "0.0.11", + "@react-email/render": "1.0.0", + "@react-email/row": "0.0.10", + "@react-email/section": "0.0.14", + "@react-email/tailwind": "0.1.0", + "@react-email/text": "0.0.10" } }, "@react-email/container": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.13.tgz", - "integrity": "sha512-ftke0N1FZl8MX3XXxXiiOaiJOnrQz7ZXUyqNj81K+BK+DePWIVaSmgK6Bu8fFnsgwdKuBdqjZTEtF4sIkU3FuQ==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.14.tgz", + "integrity": "sha512-NgoaJJd9tTtsrveL86Ocr/AYLkGyN3prdXKd/zm5fQpfDhy/NXezyT3iF6VlwAOEUIu64ErHpAJd+P6ygR+vjg==", "requires": {} }, "@react-email/font": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.7.tgz", - "integrity": "sha512-R0/mfUV/XcUQIALjZUFT9GP+XGmIP1KPz20h9rpS5e4ji6VkQ3ENWlisxrdK5U+KA9iZQrlan+/6tUoTJ9bFsg==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.8.tgz", + "integrity": "sha512-fSBEqYyVPAyyACBBHcs3wEYzNknpHMuwcSAAKE8fOoDfGqURr/vSxKPdh4tOa9z7G4hlcEfgGrCYEa2iPT22cw==", "requires": {} }, "@react-email/head": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.10.tgz", - "integrity": "sha512-VoH399w0/i3dJFnwH0Ixf9BTuiWhSA/y8PpsCJ7CPw8Mv8WNBqMAAsw0rmrITYI8uPd15LZ2zk2uwRDvqasMRw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.11.tgz", + "integrity": "sha512-skw5FUgyamIMK+LN+fZQ5WIKQYf0dPiRAvsUAUR2eYoZp9oRsfkIpFHr0GWPkKAYjFEj+uJjaxQ/0VzQH7svVg==", "requires": {} }, "@react-email/heading": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.13.tgz", - "integrity": "sha512-MYDzjJwljKHBLueLuyqkaHxu6N4aGOL1ms2NNyJ9WXC9mmBnLs4Y/QEf9SjE4Df3AW4iT9uyfVHuaNUb7uq5QA==", - "requires": { - "@radix-ui/react-slot": "1.1.0" - } + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.14.tgz", + "integrity": "sha512-jZM7IVuZOXa0G110ES8OkxajPTypIKlzlO1K1RIe1auk76ukQRiCg1IRV4HZlWk1GGUbec5hNxsvZa2kU8cb9w==", + "requires": {} }, "@react-email/hr": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.9.tgz", - "integrity": "sha512-Rte+EZL3ptH3rkVU3a7fh8/06mZ6Q679tDaWDjsw3878RQC9afWqUPp5lwgA/1pTouLmJlDs2BjRnV6H84O7iw==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.10.tgz", + "integrity": "sha512-3AA4Yjgl3zEid/KVx6uf6TuLJHVZvUc2cG9Wm9ZpWeAX4ODA+8g9HyuC0tfnjbRsVMhMcCGiECuWWXINi+60vA==", "requires": {} }, "@react-email/html": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.9.tgz", - "integrity": "sha512-NB74xwWaOJZxhpiy6pzkhHvugBa2vvmUa0KKnSwOEIX+WEQH8wj5UUhRN4F+Pmkiqz3QBTETUJiSsNWWFtrHgA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.10.tgz", + "integrity": "sha512-06uiuSKJBWQJfhCKv4MPupELei4Lepyz9Sth7Yq7Fq29CAeB1ejLgKkGqn1I+FZ72hQxPLdYF4iq4yloKv3JCg==", "requires": {} }, "@react-email/img": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.9.tgz", - "integrity": "sha512-zDlQWmlSANb2dBYhDaKD12Z4xaGD5mEf3peawBYHGxYySzMLwRT2ANGvFqpDNd7iT0C5po+/9EWR8fS1dLy0QQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.10.tgz", + "integrity": "sha512-pJ8glJjDNaJ53qoM95pvX9SK05yh0bNQY/oyBKmxlBDdUII6ixuMc3SCwYXPMl+tgkQUyDgwEBpSTrLAnjL3hA==", "requires": {} }, "@react-email/link": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.9.tgz", - "integrity": "sha512-rRqWGPUTGFwwtMCtsdCHNh0ewOsd4UBG/D12UcwJYFKRb0U6hUG/6VJZE3tB1QYZpLIESdvOLL6ztznh+D749g==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.10.tgz", + "integrity": "sha512-tva3wvAWSR10lMJa9fVA09yRn7pbEki0ZZpHE6GD1jKbFhmzt38VgLO9B797/prqoDZdAr4rVK7LJFcdPx3GwA==", "requires": {} }, "@react-email/markdown": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.11.tgz", - "integrity": "sha512-KeDTS0bAvvtgavYAIAmxKpRxWUSr1/jufckDzu9g4QsQtth8wYaSR5wCPXuTPmhFgJMIlNSlOiBnVp+oRbDtKA==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.12.tgz", + "integrity": "sha512-wsuvj1XAb6O63aizCLNEeqVgKR3oFjAwt9vjfg2y2oh4G1dZeo8zonZM2x1fmkEkBZhzwSHraNi70jSXhA3A9w==", "requires": { "md-to-react-email": "5.0.2" } }, "@react-email/preview": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.10.tgz", - "integrity": "sha512-bRrv8teMMBlF7ttLp1zZUejkPUzrwMQXrigdagtEBOqsB8HxvJU2MR6Yyb3XOqBYldaIDOQJ1z61zyD2wRlKAw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.11.tgz", + "integrity": "sha512-7O/CT4b16YlSGrj18htTPx3Vbhu2suCGv/cSe5c+fuSrIM/nMiBSZ3Js16Vj0XJbAmmmlVmYFZw9L20wXJ+LjQ==", "requires": {} }, "@react-email/render": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.17.tgz", - "integrity": "sha512-xBQ+/73+WsGuXKY7r1U73zMBNV28xdV0cp9cFjhNYipBReDHhV97IpA6v7Hl0dDtDzt+yS/72dY5vYXrF1v8NA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.0.tgz", + "integrity": "sha512-seN2p3JRUSZhwIUiymh9N6ZfhRZ14ywOraQqAokY63DkDeHZW2pA2a6nWpNc/igfOcNyt09Wsoi1Aj0esxhdzw==", "requires": { "html-to-text": "9.0.5", "js-beautify": "^1.14.11", @@ -19688,27 +18007,27 @@ } }, "@react-email/row": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.9.tgz", - "integrity": "sha512-ZDASHVvyKrWBS00o5pSH5khfMf46UtZhrHcSAfPSiC4nj7R8A0bf+3Wmbk8YmsaV+qWXUCUSHWwIAAlMRnJoAA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.10.tgz", + "integrity": "sha512-jPyEhG3gsLX+Eb9U+A30fh0gK6hXJwF4ghJ+ZtFQtlKAKqHX+eCpWlqB3Xschd/ARJLod8WAswg0FB+JD9d0/A==", "requires": {} }, "@react-email/section": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.13.tgz", - "integrity": "sha512-McsCQ5NQlNWEMEAR3EtCxHgRhxGmLD+jPvj7A3FD7y2X3fXG0hbmUGX12B63rIywSWjJoQi6tojx/8RpzbyeTA==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.14.tgz", + "integrity": "sha512-+fYWLb4tPU1A/+GE5J1+SEMA7/wR3V30lQ+OR9t2kAJqNrARDbMx0bLnYnR1QL5TiFRz0pCF05SQUobk6gHEDQ==", "requires": {} }, "@react-email/tailwind": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.0.19.tgz", - "integrity": "sha512-bA0w4D7mSNowxWhcO0jBJauFIPf2Ok7QuKlrHwCcxyX35L2pb5D6ZmXYOrD9C6ADQuVz5oEX+oed3zpSLROgPg==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.1.0.tgz", + "integrity": "sha512-qysVUEY+M3SKUvu35XDpzn7yokhqFOT3tPU6Mj/pgc62TL5tQFj6msEbBtwoKs2qO3WZvai0DIHdLhaOxBQSow==", "requires": {} }, "@react-email/text": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.9.tgz", - "integrity": "sha512-UNFPGerER3zywpb1ODOS2VgHP7rgOmiTxMHn75pjvQf/gi3/jN9edEQLYvRgPv/mNn4IpJFkOrlP8jcammLeew==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.10.tgz", + "integrity": "sha512-wNAnxeEAiFs6N+SxS0y6wTJWfewEzUETuyS2aZmT00xk50VijwyFRuhm4sYSjusMyshevomFwz5jNISCxRsGWw==", "requires": {} }, "@rollup/pluginutils": { @@ -19896,92 +18215,92 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "@swc/core": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.6.tgz", - "integrity": "sha512-FZxyao9eQks1MRmUshgsZTmlg/HB2oXK5fghkoWJm/1CU2q2kaJlVDll2as5j+rmWiwkp0Gidlq8wlXcEEAO+g==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.14.tgz", + "integrity": "sha512-9aeXeifnyuvc2pcuuhPQgVUwdpGEzZ+9nJu0W8/hNl/aESFsJGR5i9uQJRGu0atoNr01gK092fvmqMmQAPcKow==", "devOptional": true, "requires": { - "@swc/core-darwin-arm64": "1.7.6", - "@swc/core-darwin-x64": "1.7.6", - "@swc/core-linux-arm-gnueabihf": "1.7.6", - "@swc/core-linux-arm64-gnu": "1.7.6", - "@swc/core-linux-arm64-musl": "1.7.6", - "@swc/core-linux-x64-gnu": "1.7.6", - "@swc/core-linux-x64-musl": "1.7.6", - "@swc/core-win32-arm64-msvc": "1.7.6", - "@swc/core-win32-ia32-msvc": "1.7.6", - "@swc/core-win32-x64-msvc": "1.7.6", + "@swc/core-darwin-arm64": "1.7.14", + "@swc/core-darwin-x64": "1.7.14", + "@swc/core-linux-arm-gnueabihf": "1.7.14", + "@swc/core-linux-arm64-gnu": "1.7.14", + "@swc/core-linux-arm64-musl": "1.7.14", + "@swc/core-linux-x64-gnu": "1.7.14", + "@swc/core-linux-x64-musl": "1.7.14", + "@swc/core-win32-arm64-msvc": "1.7.14", + "@swc/core-win32-ia32-msvc": "1.7.14", + "@swc/core-win32-x64-msvc": "1.7.14", "@swc/counter": "^0.1.3", "@swc/types": "^0.1.12" } }, "@swc/core-darwin-arm64": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.6.tgz", - "integrity": "sha512-6lYHey84ZzsdtC7UuPheM4Rm0Inzxm6Sb8U6dmKc4eCx8JL0LfWG4LC5RsdsrTxnjTsbriWlnhZBffh8ijUHIQ==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.14.tgz", + "integrity": "sha512-V0OUXjOH+hdGxDYG8NkQzy25mKOpcNKFpqtZEzLe5V/CpLJPnpg1+pMz70m14s9ZFda9OxsjlvPbg1FLUwhgIQ==", "dev": true, "optional": true }, "@swc/core-darwin-x64": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.6.tgz", - "integrity": "sha512-Fyl+8aH9O5rpx4O7r2KnsPpoi32iWoKOYKiipeTbGjQ/E95tNPxbmsz4yqE8Ovldcga60IPJ5OKQA3HWRiuzdw==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.14.tgz", + "integrity": "sha512-9iFvUnxG6FC3An5ogp5jbBfQuUmTTwy8KMB+ZddUoPB3NR1eV+Y9vOh/tfWcenSJbgOKDLgYC5D/b1mHAprsrQ==", "dev": true, "optional": true }, "@swc/core-linux-arm-gnueabihf": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.6.tgz", - "integrity": "sha512-2WxYTqFaOx48GKC2cbO1/IntA+w+kfCFy436Ij7qRqqtV/WAvTM9TC1OmiFbqq436rSot52qYmX8fkwdB5UcLQ==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.14.tgz", + "integrity": "sha512-zGJsef9qPivKSH8Vv4F/HiBXBTHZ5Hs3ZjVGo/UIdWPJF8fTL9OVADiRrl34Q7zOZEtGXRwEKLUW1SCQcbDvZA==", "dev": true, "optional": true }, "@swc/core-linux-arm64-gnu": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.6.tgz", - "integrity": "sha512-TBEGMSe0LhvPe4S7E68c7VzgT3OMu4VTmBLS7B2aHv4v8uZO92Khpp7L0WqgYU1y5eMjk+XLDLi4kokiNHv/Hg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.14.tgz", + "integrity": "sha512-AxV3MPsoI7i4B8FXOew3dx3N8y00YoJYvIPfxelw07RegeCEH3aHp2U2DtgbP/NV1ugZMx0TL2Z2DEvocmA51g==", "dev": true, "optional": true }, "@swc/core-linux-arm64-musl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.6.tgz", - "integrity": "sha512-QI8QGL0HGT42tj7F1A+YAzhGkJjUcvvTfI1e2m704W0Enl2/UIK9v5D1zvQzYwusRyKuaQfbeBRYDh0NcLOGLg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.14.tgz", + "integrity": "sha512-JDLdNjUj3zPehd4+DrQD8Ltb3B5lD8D05IwePyDWw+uR/YPc7w/TX1FUVci5h3giJnlMCJRvi1IQYV7K1n7KtQ==", "dev": true, "optional": true }, "@swc/core-linux-x64-gnu": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.6.tgz", - "integrity": "sha512-61AYVzhjuNQAVIKKWOJu3H0/pFD28RYJGxnGg3YMhvRLRyuWNyY5Nyyj2WkKcz/ON+g38Arlz00NT1LDIViRLg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.14.tgz", + "integrity": "sha512-Siy5OvPCLLWmMdx4msnEs8HvEVUEigSn0+3pbLjv78iwzXd0qSBNHUPZyC1xeurVaUbpNDxZTpPRIwpqNE2+Og==", "dev": true, "optional": true }, "@swc/core-linux-x64-musl": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.6.tgz", - "integrity": "sha512-hQFznpfLK8XajfAAN9Cjs0w/aVmO7iu9VZvInyrTCRcPqxV5O+rvrhRxKvC1LRMZXr5M6JRSRtepp5w+TK4kAw==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.14.tgz", + "integrity": "sha512-FtEGm9mwtRYQNK43WMtUIadxHs/ja2rnDurB99os0ZoFTGG2IHuht2zD97W0wB8JbqEabT1XwSG9Y5wmN+ciEQ==", "dev": true, "optional": true }, "@swc/core-win32-arm64-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.6.tgz", - "integrity": "sha512-Aqsd9afykVMuekzjm4X4TDqwxmG4CrzoOSFe0hZrn9SMio72l5eAPnMtYoe5LsIqtjV8MNprLfXaNbjHjTegmA==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.14.tgz", + "integrity": "sha512-Jp8KDlfq7Ntt2/BXr0y344cYgB1zf0DaLzDZ1ZJR6rYlAzWYSccLYcxHa97VGnsYhhPspMpmCvHid97oe2hl4A==", "dev": true, "optional": true }, "@swc/core-win32-ia32-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.6.tgz", - "integrity": "sha512-9h0hYnOeRVNeQgHQTvD1Im67faNSSzBZ7Adtxyu9urNLfBTJilMllFd2QuGHlKW5+uaT6ZH7ZWDb+c/enx7Lcg==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.14.tgz", + "integrity": "sha512-I+cFsXF0OU0J9J4zdWiQKKLURO5dvCujH9Jr8N0cErdy54l9d4gfIxdctfTF+7FyXtWKLTCkp+oby9BQhkFGWA==", "dev": true, "optional": true }, "@swc/core-win32-x64-msvc": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.6.tgz", - "integrity": "sha512-izeoB8glCSe6IIDQmrVm6bvR9muk9TeKgmtY7b6l1BwL4BFnTUk4dMmpbntT90bEVQn3JPCaPtUG4HfL8VuyuA==", + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.14.tgz", + "integrity": "sha512-NNrprQCK6d28mG436jVo2TD+vACHseUECacEBGZ9Ef0qfOIWS1XIt2MisQKG0Oea2VvLFl6tF/V4Lnx/H0Sn3Q==", "dev": true, "optional": true }, @@ -19991,10 +18310,11 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "@swc/helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", - "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", "requires": { + "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, @@ -20002,6 +18322,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==", + "devOptional": true, "requires": { "@swc/counter": "^0.1.3" } @@ -20173,6 +18494,7 @@ "version": "8.44.3", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.3.tgz", "integrity": "sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g==", + "dev": true, "requires": { "@types/estree": "*", "@types/json-schema": "*" @@ -20182,6 +18504,7 @@ "version": "3.7.5", "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.5.tgz", "integrity": "sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA==", + "dev": true, "requires": { "@types/eslint": "*", "@types/estree": "*" @@ -20190,7 +18513,8 @@ "@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true }, "@types/express": { "version": "4.17.21", @@ -20250,7 +18574,8 @@ "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true }, "@types/lodash": { "version": "4.17.7", @@ -20310,11 +18635,11 @@ } }, "@types/node": { - "version": "20.14.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", - "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "version": "20.16.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", + "integrity": "sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==", "requires": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "@types/nodemailer": { @@ -20395,15 +18720,11 @@ "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, - "@types/prismjs": { - "version": "1.26.3", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.3.tgz", - "integrity": "sha512-A0D0aTXvjlqJ5ZILMz3rNfDBOx9hHxLZYv2by47Sm/pqW35zzjusrZTryatjN/Rf8Us2gZrJD+KeHbUSTux1Cw==" - }, "@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "dev": true }, "@types/qs": { "version": "6.9.8", @@ -20418,22 +18739,15 @@ "dev": true }, "@types/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", - "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", + "version": "18.3.4", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz", + "integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==", + "dev": true, "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, - "@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "requires": { - "@types/react": "*" - } - }, "@types/readdir-glob": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.2.tgz", @@ -20443,11 +18757,6 @@ "@types/node": "*" } }, - "@types/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==" - }, "@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -20548,27 +18857,17 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.8.tgz", "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, - "@types/webpack": { - "version": "5.28.5", - "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", - "integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==", - "requires": { - "@types/node": "*", - "tapable": "^2.2.0", - "webpack": "^5" - } - }, "@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", + "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/type-utils": "8.2.0", + "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -20576,54 +18875,54 @@ } }, "@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", + "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", "dev": true, "requires": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" } }, "@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", + "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/utils": "8.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" } }, "@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", "dev": true, "requires": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -20653,24 +18952,24 @@ } }, "@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" } }, "@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", "dev": true, "requires": { - "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/types": "8.2.0", "eslint-visitor-keys": "^3.4.3" } }, @@ -20783,6 +19082,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dev": true, "requires": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -20791,22 +19091,26 @@ "@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true }, "@webassemblyjs/helper-api-error": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true }, "@webassemblyjs/helper-buffer": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true }, "@webassemblyjs/helper-numbers": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, "requires": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -20816,12 +19120,14 @@ "@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true }, "@webassemblyjs/helper-wasm-section": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -20833,6 +19139,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, "requires": { "@xtuc/ieee754": "^1.2.0" } @@ -20841,6 +19148,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, "requires": { "@xtuc/long": "4.2.2" } @@ -20848,12 +19156,14 @@ "@webassemblyjs/utf8": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true }, "@webassemblyjs/wasm-edit": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -20869,6 +19179,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", @@ -20881,6 +19192,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -20892,6 +19204,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", @@ -20905,6 +19218,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" @@ -20913,12 +19227,14 @@ "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true }, "@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true }, "abbrev": { "version": "1.1.1", @@ -20957,6 +19273,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "requires": {} }, "acorn-walk": { @@ -21165,14 +19482,6 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, - "aria-hidden": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", - "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", - "requires": { - "tslib": "^2.0.0" - } - }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -21220,19 +19529,6 @@ "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==" }, - "autoprefixer": { - "version": "10.4.14", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", - "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", - "requires": { - "browserslist": "^4.21.5", - "caniuse-lite": "^1.0.30001464", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - } - }, "b4a": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", @@ -21528,7 +19824,8 @@ "camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "peer": true }, "caniuse-lite": { "version": "1.0.30001618", @@ -21591,7 +19888,8 @@ "chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==" + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true }, "ci-info": { "version": "4.0.0", @@ -21709,11 +20007,6 @@ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" }, - "clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==" - }, "cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -22006,12 +20299,14 @@ "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "peer": true }, "csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true }, "dayjs": { "version": "1.11.10", @@ -22040,7 +20335,8 @@ "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true }, "deepmerge": { "version": "4.3.1", @@ -22091,11 +20387,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==" }, - "detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, "diacritics": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", @@ -22104,7 +20395,8 @@ "didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "peer": true }, "diff": { "version": "4.0.2", @@ -22131,7 +20423,8 @@ "dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "peer": true }, "docker-compose": { "version": "0.24.8", @@ -22326,18 +20619,6 @@ "ws": "~8.11.0" } }, - "engine.io-client": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", - "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", - "requires": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", - "xmlhttprequest-ssl": "~2.0.0" - } - }, "engine.io-parser": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", @@ -22347,6 +20628,7 @@ "version": "5.17.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "dev": true, "requires": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -22381,7 +20663,8 @@ "es-module-lexer": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz", - "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==" + "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==", + "dev": true }, "esbuild": { "version": "0.20.2", @@ -22427,18 +20710,20 @@ "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true }, "eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", + "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -22474,6 +20759,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -22484,12 +20770,14 @@ "eslint-visitor-keys": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==" + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true }, "glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "requires": { "is-glob": "^4.0.3" } @@ -22497,7 +20785,8 @@ "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, @@ -22508,14 +20797,6 @@ "dev": true, "requires": {} }, - "eslint-config-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-config-turbo/-/eslint-config-turbo-1.10.12.tgz", - "integrity": "sha512-z3jfh+D7UGYlzMWGh+Kqz++hf8LOE96q3o5R8X4HTjmxaBWlLAWG+0Ounr38h+JLR2TJno0hU9zfzoPNkR9BdA==", - "requires": { - "eslint-plugin-turbo": "1.10.12" - } - }, "eslint-plugin-prettier": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", @@ -22526,21 +20807,6 @@ "synckit": "^0.9.1" } }, - "eslint-plugin-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-plugin-turbo/-/eslint-plugin-turbo-1.10.12.tgz", - "integrity": "sha512-uNbdj+ohZaYo4tFJ6dStRXu2FZigwulR1b3URPXe0Q8YaE7thuekKNP+54CHtZPH9Zey9dmDx5btAQl9mfzGOw==", - "requires": { - "dotenv": "16.0.3" - }, - "dependencies": { - "dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==" - } - } - }, "eslint-plugin-unicorn": { "version": "55.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-55.0.0.tgz", @@ -22569,6 +20835,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", + "dev": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -22577,12 +20844,14 @@ "eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true }, "espree": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "dev": true, "requires": { "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", @@ -22592,7 +20861,8 @@ "eslint-visitor-keys": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==" + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true } } }, @@ -22606,6 +20876,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, "requires": { "estraverse": "^5.1.0" } @@ -22614,6 +20885,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "requires": { "estraverse": "^5.2.0" } @@ -22621,7 +20893,8 @@ "estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true }, "estree-walker": { "version": "3.0.3", @@ -22635,7 +20908,8 @@ "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true }, "etag": { "version": "1.8.1", @@ -22698,9 +20972,9 @@ } }, "exiftool-vendored": { - "version": "28.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.0.tgz", - "integrity": "sha512-s2k92EB8LSeYjXv4agtpANeH8y1CsEThYqMm7AF1jP64PyFb40AoD0RGf69j28G6RqXkT5JGl4Xwk9kOy3IkjQ==", + "version": "28.2.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.1.tgz", + "integrity": "sha512-D3YsKErr3BbjKeJzUVsv6CVZ+SQNgAJKPFWVLXu0CBtr24FNuE3CZBXWKWysGu0MjzeDCNwQrQI5+bXUFeiYVA==", "requires": { "@photostructure/tz-lookup": "^10.0.0", "@types/luxon": "^3.4.2", @@ -22708,7 +20982,7 @@ "exiftool-vendored.exe": "12.91.0", "exiftool-vendored.pl": "12.91.0", "he": "^1.2.0", - "luxon": "^3.4.4" + "luxon": "^3.5.0" } }, "exiftool-vendored.exe": { @@ -22804,7 +21078,8 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "fast-diff": { "version": "1.3.0", @@ -22832,12 +21107,14 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true }, "fast-safe-stringify": { "version": "2.1.1", @@ -22871,6 +21148,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "requires": { "flat-cache": "^4.0.0" } @@ -22924,6 +21202,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "requires": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -22933,6 +21212,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "requires": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -22941,7 +21221,8 @@ "flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true }, "fluent-ffmpeg": { "version": "2.1.3", @@ -23001,20 +21282,6 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" }, - "fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==" - }, - "framer-motion": { - "version": "10.17.4", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.17.4.tgz", - "integrity": "sha512-CYBSs6cWfzcasAX8aofgKFZootmkQtR4qxbfTOksBLny/lbUfkGbQAFOS3qnl6Uau1N9y8tUpI7mVIrHgkFjLQ==", - "requires": { - "@emotion/is-prop-valid": "^0.8.2", - "tslib": "^2.4.0" - } - }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -23193,11 +21460,6 @@ "hasown": "^2.0.0" } }, - "get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" - }, "get-port": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", @@ -23267,7 +21529,8 @@ "glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true }, "globals": { "version": "15.9.0", @@ -23479,7 +21742,8 @@ "ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==" + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true }, "import-fresh": { "version": "3.3.0", @@ -23504,7 +21768,8 @@ "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true }, "indent-string": { "version": "4.0.0", @@ -23553,14 +21818,6 @@ "wrap-ansi": "^6.0.1" } }, - "invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "requires": { - "loose-envify": "^1.0.0" - } - }, "ioredis": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", @@ -23643,7 +21900,8 @@ "is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==" + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true }, "is-stream": { "version": "2.0.1", @@ -23731,7 +21989,8 @@ "jiti": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==" + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "peer": true }, "joi": { "version": "17.13.3", @@ -23812,7 +22071,8 @@ "json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true }, "json-parse-even-better-errors": { "version": "2.3.1", @@ -23828,7 +22088,8 @@ "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, "json5": { "version": "2.2.3", @@ -23855,6 +22116,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "requires": { "json-buffer": "3.0.1" } @@ -23905,6 +22167,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "requires": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -23918,7 +22181,8 @@ "lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==" + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "peer": true }, "lines-and-columns": { "version": "1.2.4", @@ -23934,12 +22198,14 @@ "loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==" + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "requires": { "p-locate": "^5.0.0" } @@ -24090,7 +22356,8 @@ "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true }, "merge2": { "version": "1.4.1", @@ -24203,14 +22470,6 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, - "mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", - "requires": { - "obliterator": "^2.0.1" - } - }, "mock-fs": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", @@ -24345,7 +22604,8 @@ "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true }, "nearley": { "version": "2.20.1", @@ -24378,9 +22638,9 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "nest-commander": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.14.0.tgz", - "integrity": "sha512-3HEfsEzoKEZ/5/cptkXlL8/31qohPxtMevoFo4j9NMe3q5PgI/0TgTYN/6py9GnFD51jSasEfFGChs1BJ+Enag==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.15.0.tgz", + "integrity": "sha512-o9VEfFj/w2nm+hQi6fnkxL1GAFZW/KmuGcIE7/B/TX0gwm0QVy8svAF75EQm8wrDjcvWS7Cx/ArnkFn2C+iM2w==", "requires": { "@fig/complete-commander": "^3.0.0", "@golevelup/nestjs-discovery": "4.0.1", @@ -24421,21 +22681,21 @@ } }, "next": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-14.1.4.tgz", - "integrity": "sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", + "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", "requires": { - "@next/env": "14.1.4", - "@next/swc-darwin-arm64": "14.1.4", - "@next/swc-darwin-x64": "14.1.4", - "@next/swc-linux-arm64-gnu": "14.1.4", - "@next/swc-linux-arm64-musl": "14.1.4", - "@next/swc-linux-x64-gnu": "14.1.4", - "@next/swc-linux-x64-musl": "14.1.4", - "@next/swc-win32-arm64-msvc": "14.1.4", - "@next/swc-win32-ia32-msvc": "14.1.4", - "@next/swc-win32-x64-msvc": "14.1.4", - "@swc/helpers": "0.5.2", + "@next/env": "14.2.3", + "@next/swc-darwin-arm64": "14.2.3", + "@next/swc-darwin-x64": "14.2.3", + "@next/swc-linux-arm64-gnu": "14.2.3", + "@next/swc-linux-arm64-musl": "14.2.3", + "@next/swc-linux-x64-gnu": "14.2.3", + "@next/swc-linux-x64-musl": "14.2.3", + "@next/swc-win32-arm64-msvc": "14.2.3", + "@next/swc-win32-ia32-msvc": "14.2.3", + "@next/swc-win32-x64-msvc": "14.2.3", + "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", @@ -24526,11 +22786,6 @@ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, - "normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==" - }, "notepack.io": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", @@ -24579,11 +22834,6 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" }, - "obliterator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", - "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==" - }, "obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -24658,6 +22908,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, "requires": { "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", @@ -24692,6 +22943,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "requires": { "yocto-queue": "^0.1.0" } @@ -24700,6 +22952,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "requires": { "p-limit": "^3.0.2" } @@ -24771,7 +23024,8 @@ "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true }, "path-is-absolute": { "version": "1.0.1", @@ -24927,12 +23181,14 @@ "pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==" + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "peer": true }, "pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==" + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "peer": true }, "pluralize": { "version": "8.0.0", @@ -24954,6 +23210,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "peer": true, "requires": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -24964,6 +23221,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "peer": true, "requires": { "camelcase-css": "^2.0.1" } @@ -24972,6 +23230,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "peer": true, "requires": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" @@ -24980,7 +23239,8 @@ "lilconfig": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==" + "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "peer": true } } }, @@ -24988,6 +23248,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "peer": true, "requires": { "postcss-selector-parser": "^6.0.11" } @@ -24996,6 +23257,7 @@ "version": "6.0.16", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "peer": true, "requires": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -25004,7 +23266,8 @@ "postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "peer": true }, "postgres-array": { "version": "2.0.0", @@ -25037,7 +23300,8 @@ "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true }, "prettier": { "version": "3.3.3", @@ -25060,22 +23324,6 @@ "dev": true, "requires": {} }, - "prism-react-renderer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.1.0.tgz", - "integrity": "sha512-I5cvXHjA1PVGbGm1MsWCpvBCRrYyxEri0MC7/JbfIfYfcXAxHyO5PaUjs3A8H5GW6kJcLhTHxxMaOZZpRZD2iQ==", - "requires": { - "@types/prismjs": "^1.26.0", - "clsx": "^1.2.1" - }, - "dependencies": { - "clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" - } - } - }, "prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -25178,7 +23426,8 @@ "punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true }, "qs": { "version": "6.11.0", @@ -25218,6 +23467,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "requires": { "safe-buffer": "^5.1.0" } @@ -25250,56 +23500,31 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" } }, "react-email": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-2.1.6.tgz", - "integrity": "sha512-BtR9VI1CMq4953wfiBmzupKlWcRThaWG2dDgl1vWAllK3tNNmJNerwY4VlmASRDQZE3LpLXU3+lf8N/VAKdbZQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-3.0.1.tgz", + "integrity": "sha512-G4Bkx2ULIScy/0Z8nnWywHt0W1iTkaYCdh9rWNuQ3eVZ6B3ttTUDE9uUy3VNQ8dtQbmG0cpt8+XmImw7mMBW6Q==", "requires": { "@babel/core": "7.24.5", "@babel/parser": "7.24.5", - "@radix-ui/colors": "1.0.1", - "@radix-ui/react-collapsible": "1.1.0", - "@radix-ui/react-popover": "1.1.1", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-toggle-group": "1.1.0", - "@radix-ui/react-tooltip": "1.1.1", - "@swc/core": "1.3.101", - "@types/react": "18.2.47", - "@types/react-dom": "^18.2.0", - "@types/webpack": "5.28.5", - "autoprefixer": "10.4.14", "chalk": "4.1.2", - "chokidar": "3.5.3", - "clsx": "2.1.0", + "chokidar": "3.6.0", "commander": "11.1.0", "debounce": "2.0.0", "esbuild": "0.19.11", - "eslint-config-prettier": "9.0.0", - "eslint-config-turbo": "1.10.12", - "framer-motion": "10.17.4", "glob": "10.3.4", "log-symbols": "4.1.0", "mime-types": "2.1.35", - "next": "14.1.4", + "next": "14.2.3", "normalize-path": "3.0.0", "ora": "5.4.1", - "postcss": "8.4.38", - "prism-react-renderer": "2.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "socket.io": "4.7.3", - "socket.io-client": "4.7.3", - "sonner": "1.3.1", - "source-map-js": "1.0.2", - "stacktrace-parser": "0.1.10", - "tailwind-merge": "2.2.0", - "tailwindcss": "3.4.0", - "typescript": "5.1.6" + "socket.io": "4.7.5" }, "dependencies": { "@esbuild/aix-ppc64": { @@ -25440,100 +23665,6 @@ "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", "optional": true }, - "@swc/core": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.101.tgz", - "integrity": "sha512-w5aQ9qYsd/IYmXADAnkXPGDMTqkQalIi+kfFf/MHRKTpaOL7DHjMXwPp/n8hJ0qNjRvchzmPtOqtPBiER50d8A==", - "requires": { - "@swc/core-darwin-arm64": "1.3.101", - "@swc/core-darwin-x64": "1.3.101", - "@swc/core-linux-arm-gnueabihf": "1.3.101", - "@swc/core-linux-arm64-gnu": "1.3.101", - "@swc/core-linux-arm64-musl": "1.3.101", - "@swc/core-linux-x64-gnu": "1.3.101", - "@swc/core-linux-x64-musl": "1.3.101", - "@swc/core-win32-arm64-msvc": "1.3.101", - "@swc/core-win32-ia32-msvc": "1.3.101", - "@swc/core-win32-x64-msvc": "1.3.101", - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - } - }, - "@swc/core-darwin-arm64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.101.tgz", - "integrity": "sha512-mNFK+uHNPRXSnfTOG34zJOeMl2waM4hF4a2NY7dkMXrPqw9CoJn4MwTXJcyMiSz1/BnNjjTCHF3Yhj0jPxmkzQ==", - "optional": true - }, - "@swc/core-darwin-x64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.101.tgz", - "integrity": "sha512-B085j8XOx73Fg15KsHvzYWG262bRweGr3JooO1aW5ec5pYbz5Ew9VS5JKYS03w2UBSxf2maWdbPz2UFAxg0whw==", - "optional": true - }, - "@swc/core-linux-arm-gnueabihf": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.101.tgz", - "integrity": "sha512-9xLKRb6zSzRGPqdz52Hy5GuB1lSjmLqa0lST6MTFads3apmx4Vgs8Y5NuGhx/h2I8QM4jXdLbpqQlifpzTlSSw==", - "optional": true - }, - "@swc/core-linux-arm64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.101.tgz", - "integrity": "sha512-oE+r1lo7g/vs96Weh2R5l971dt+ZLuhaUX+n3BfDdPxNHfObXgKMjO7E+QS5RbGjv/AwiPCxQmbdCp/xN5ICJA==", - "optional": true - }, - "@swc/core-linux-arm64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.101.tgz", - "integrity": "sha512-OGjYG3H4BMOTnJWJyBIovCez6KiHF30zMIu4+lGJTCrxRI2fAjGLml3PEXj8tC3FMcud7U2WUn6TdG0/te2k6g==", - "optional": true - }, - "@swc/core-linux-x64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.101.tgz", - "integrity": "sha512-/kBMcoF12PRO/lwa8Z7w4YyiKDcXQEiLvM+S3G9EvkoKYGgkkz4Q6PSNhF5rwg/E3+Hq5/9D2R+6nrkF287ihg==", - "optional": true - }, - "@swc/core-linux-x64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.101.tgz", - "integrity": "sha512-kDN8lm4Eew0u1p+h1l3JzoeGgZPQ05qDE0czngnjmfpsH2sOZxVj1hdiCwS5lArpy7ktaLu5JdRnx70MkUzhXw==", - "optional": true - }, - "@swc/core-win32-arm64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.101.tgz", - "integrity": "sha512-9Wn8TTLWwJKw63K/S+jjrZb9yoJfJwCE2RV5vPCCWmlMf3U1AXj5XuWOLUX+Rp2sGKau7wZKsvywhheWm+qndQ==", - "optional": true - }, - "@swc/core-win32-ia32-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.101.tgz", - "integrity": "sha512-onO5KvICRVlu2xmr4//V2je9O2XgS1SGKpbX206KmmjcJhXN5EYLSxW9qgg+kgV5mip+sKTHTAu7IkzkAtElYA==", - "optional": true - }, - "@swc/core-win32-x64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.101.tgz", - "integrity": "sha512-T3GeJtNQV00YmiVw/88/nxJ/H43CJvFnpvBHCVn17xbahiVUOPOduh3rc9LgAkKiNt/aV8vU3OJR+6PhfMR7UQ==", - "optional": true - }, - "@types/react": { - "version": "18.2.47", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.47.tgz", - "integrity": "sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==", - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, "brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -25542,21 +23673,6 @@ "balanced-match": "^1.0.0" } }, - "chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - } - }, "commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -25592,12 +23708,6 @@ "@esbuild/win32-x64": "0.19.11" } }, - "eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", - "requires": {} - }, "glob": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.4.tgz", @@ -25617,69 +23727,6 @@ "requires": { "brace-expansion": "^2.0.1" } - }, - "socket.io": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.3.tgz", - "integrity": "sha512-SE+UIQXBQE+GPG2oszWMlsEmWtHVqw/h1VrYJGK5/MC7CH5p58N448HwIrtREcvR4jfdOJAY4ieQfxMr55qbbw==", - "requires": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.5.2", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - } - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" - }, - "tailwindcss": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", - "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", - "requires": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "dependencies": { - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "requires": { - "is-glob": "^4.0.3" - } - } - } - }, - "typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==" } } }, @@ -25698,41 +23745,11 @@ } } }, - "react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "requires": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - } - }, - "react-remove-scroll-bar": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", - "requires": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - } - }, - "react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "requires": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - } - }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "peer": true, "requires": { "pify": "^2.3.0" } @@ -25882,11 +23899,6 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, - "regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -26148,6 +24160,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -26156,6 +24169,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, "requires": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -26166,6 +24180,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -26177,12 +24192,14 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, "requires": {} }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, @@ -26245,6 +24262,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, "requires": { "randombytes": "^2.1.0" } @@ -26313,32 +24331,32 @@ } }, "sharp": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.4.tgz", - "integrity": "sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "requires": { - "@img/sharp-darwin-arm64": "0.33.4", - "@img/sharp-darwin-x64": "0.33.4", - "@img/sharp-libvips-darwin-arm64": "1.0.2", - "@img/sharp-libvips-darwin-x64": "1.0.2", - "@img/sharp-libvips-linux-arm": "1.0.2", - "@img/sharp-libvips-linux-arm64": "1.0.2", - "@img/sharp-libvips-linux-s390x": "1.0.2", - "@img/sharp-libvips-linux-x64": "1.0.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2", - "@img/sharp-libvips-linuxmusl-x64": "1.0.2", - "@img/sharp-linux-arm": "0.33.4", - "@img/sharp-linux-arm64": "0.33.4", - "@img/sharp-linux-s390x": "0.33.4", - "@img/sharp-linux-x64": "0.33.4", - "@img/sharp-linuxmusl-arm64": "0.33.4", - "@img/sharp-linuxmusl-x64": "0.33.4", - "@img/sharp-wasm32": "0.33.4", - "@img/sharp-win32-ia32": "0.33.4", - "@img/sharp-win32-x64": "0.33.4", + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5", "color": "^4.2.3", "detect-libc": "^2.0.3", - "semver": "^7.6.0" + "semver": "^7.6.3" } }, "shebang-command": { @@ -26447,17 +24465,6 @@ } } }, - "socket.io-client": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.3.tgz", - "integrity": "sha512-nU+ywttCyBitXIl9Xe0RSEfek4LneYkJxCeNnKCuhwoH4jGXO1ipIUw/VA/+Vvv2G1MTym11fzFC0SxkrcfXDw==", - "requires": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", - "engine.io-client": "~6.5.2", - "socket.io-parser": "~4.2.4" - } - }, "socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -26467,12 +24474,6 @@ "debug": "~4.3.1" } }, - "sonner": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.3.1.tgz", - "integrity": "sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==", - "requires": {} - }, "source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -26488,6 +24489,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -26496,7 +24498,8 @@ "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true } } }, @@ -26544,9 +24547,9 @@ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" }, "sql-formatter": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.3.2.tgz", - "integrity": "sha512-pNxSMf5DtwhpZ8gUcOGCGZIWtCcyAUx9oLgAtlO4ag7DvlfnETL0BGqXaISc84pNrXvTWmt8Wal1FWKxdTsL3Q==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.0.tgz", + "integrity": "sha512-h3uVulRmOfARvDejuSzs9GMbua/UmGCKiP08zyHT1PnG376zk9CHVsDAcKIc9TcIwIrDH3YULWwI4PrXdmLRVw==", "dev": true, "requires": { "argparse": "^2.0.1", @@ -26582,21 +24585,6 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, - "stacktrace-parser": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz", - "integrity": "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==", - "requires": { - "type-fest": "^0.7.1" - }, - "dependencies": { - "type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==" - } - } - }, "standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -26696,7 +24684,8 @@ "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true }, "styled-jsx": { "version": "5.1.1", @@ -26710,6 +24699,7 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "peer": true, "requires": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", @@ -26759,14 +24749,6 @@ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.22.0.tgz", "integrity": "sha512-oAP80ymt8ssrAzjX8k3frbL7ys6AotqC35oikG6/SG15wBw+tG9nCk4oPaXIhEaAOAZ8XngxUv3ORq2IuR3r4Q==" }, - "tailwind-merge": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.0.tgz", - "integrity": "sha512-SqqhhaL0T06SW59+JVNfAqKdqLs0497esifRrZ7jOaefP3o64fdFNDMrAQWZFMxTLJPiHVjRLUywT8uFz1xNWQ==", - "requires": { - "@babel/runtime": "^7.23.5" - } - }, "tailwindcss": { "version": "3.4.6", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", @@ -26838,7 +24820,8 @@ "tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true }, "tar": { "version": "6.2.0", @@ -26896,6 +24879,7 @@ "version": "5.27.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", + "dev": true, "requires": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -26906,7 +24890,8 @@ "commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true } } }, @@ -26914,6 +24899,7 @@ "version": "5.3.10", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, "requires": { "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", @@ -26926,6 +24912,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, "requires": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -26936,6 +24923,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -27020,7 +25008,8 @@ "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true }, "thenify": { "version": "3.3.1", @@ -27132,7 +25121,8 @@ "ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "peer": true }, "ts-node": { "version": "10.9.2", @@ -27208,6 +25198,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "requires": { "prelude-ls": "^1.2.1" } @@ -27338,9 +25329,9 @@ } }, "undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "universalify": { "version": "2.0.0", @@ -27389,27 +25380,11 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "requires": { "punycode": "^2.1.0" } }, - "use-callback-ref": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", - "requires": { - "tslib": "^2.0.0" - } - }, - "use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "requires": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - } - }, "utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", @@ -27553,6 +25528,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "dev": true, "requires": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -27572,9 +25548,10 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { - "version": "5.92.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", - "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.93.0.tgz", + "integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==", + "dev": true, "requires": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", @@ -27606,6 +25583,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -27614,7 +25592,8 @@ "estraverse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true } } }, @@ -27627,7 +25606,8 @@ "webpack-sources": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==" + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true }, "webpack-virtual-modules": { "version": "0.6.1", @@ -27706,11 +25686,6 @@ "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", "requires": {} }, - "xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" - }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -27767,7 +25742,8 @@ "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true }, "zip-stream": { "version": "6.0.1", diff --git a/server/package.json b/server/package.json index 97ca1ac69ae1d..e3fa9a6081995 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.112.1", + "version": "1.113.0", "description": "", "author": "", "private": true, @@ -50,7 +50,7 @@ "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.52.0", "@opentelemetry/sdk-node": "^0.52.0", - "@react-email/components": "^0.0.22", + "@react-email/components": "^0.0.23", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -71,15 +71,15 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "luxon": "^3.4.2", - "mnemonist": "^0.39.8", "nest-commander": "^3.11.1", "nestjs-cls": "^4.3.0", "nestjs-otel": "^6.0.0", "nodemailer": "^6.9.13", "openid-client": "^5.4.3", "pg": "^8.11.3", - "picomatch": "^4.0.0", - "react-email": "^2.1.2", + "picomatch": "^4.0.2", + "react": "^18.3.1", + "react-email": "^3.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", @@ -109,9 +109,10 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", + "@types/react": "^18.3.4", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.36", @@ -137,6 +138,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 1a8a05fd4d77a..c6cd68a96ff77 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -62,7 +62,7 @@ export class ApiModule implements OnModuleInit, OnModuleDestroy { async onModuleInit() { const items = setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrap', 'api'); + await this.eventRepository.emit('app.bootstrap', 'api'); this.logger.setContext('EventLoader'); const eventMap = _.groupBy(items, 'event'); @@ -74,7 +74,7 @@ export class ApiModule implements OnModuleInit, OnModuleDestroy { } async onModuleDestroy() { - await this.eventRepository.emit('onShutdown'); + await this.eventRepository.emit('app.shutdown'); } } @@ -90,11 +90,11 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { async onModuleInit() { setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrap', 'microservices'); + await this.eventRepository.emit('app.bootstrap', 'microservices'); } async onModuleDestroy() { - await this.eventRepository.emit('onShutdown'); + await this.eventRepository.emit('app.shutdown'); } } diff --git a/server/src/constants.ts b/server/src/constants.ts index f3a6c486ad058..29ed5f6d37c85 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -51,7 +51,7 @@ export const resourcePaths = { }, }; -export const MOBILE_REDIRECT = 'app.immich:/'; +export const MOBILE_REDIRECT = 'app.immich:///oauth-callback'; export const LOGIN_URL = '/auth/login?autoLaunch=0'; export enum AuthType { diff --git a/server/src/controllers/activity.controller.ts b/server/src/controllers/activity.controller.ts index 9b06f82f3a890..b91f2902d5f20 100644 --- a/server/src/controllers/activity.controller.ts +++ b/server/src/controllers/activity.controller.ts @@ -25,12 +25,6 @@ export class ActivityController { return this.service.getAll(auth, dto); } - @Get('statistics') - @Authenticated({ permission: Permission.ACTIVITY_STATISTICS }) - getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise { - return this.service.getStatistics(auth, dto); - } - @Post() @Authenticated({ permission: Permission.ACTIVITY_CREATE }) async createActivity( @@ -45,6 +39,12 @@ export class ActivityController { return value; } + @Get('statistics') + @Authenticated({ permission: Permission.ACTIVITY_STATISTICS }) + getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise { + return this.service.getStatistics(auth, dto); + } + @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) @Authenticated({ permission: Permission.ACTIVITY_DELETE }) diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index 06f2066c29f85..49ec5a82ea44c 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -2,9 +2,9 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@ import { ApiTags } from '@nestjs/swagger'; import { AddUsersDto, - AlbumCountResponseDto, AlbumInfoDto, AlbumResponseDto, + AlbumStatisticsResponseDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto, @@ -22,12 +22,6 @@ import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation'; export class AlbumController { constructor(private service: AlbumService) {} - @Get('count') - @Authenticated({ permission: Permission.ALBUM_STATISTICS }) - getAlbumCount(@Auth() auth: AuthDto): Promise { - return this.service.getCount(auth); - } - @Get() @Authenticated({ permission: Permission.ALBUM_READ }) getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise { @@ -40,6 +34,12 @@ export class AlbumController { return this.service.create(auth, dto); } + @Get('statistics') + @Authenticated({ permission: Permission.ALBUM_STATISTICS }) + getAlbumStatistics(@Auth() auth: AuthDto): Promise { + return this.service.getStatistics(auth); + } + @Authenticated({ permission: Permission.ALBUM_READ, sharedLink: true }) @Get(':id') getAlbumInfo( diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 48fea8b8a6510..fb5ec58f2544c 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -93,8 +93,8 @@ export class AssetMediaController { @Put(':id/original') @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') - @Authenticated({ sharedLink: true }) @EndpointLifecycle({ addedAt: 'v1.106.0' }) + @Authenticated({ sharedLink: true }) async replaceAsset( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 8c70bed1666ac..c6fdac1710edc 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -13,7 +13,6 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; -import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Route } from 'src/middleware/file-upload.interceptor'; import { AssetService } from 'src/services/asset.service'; @@ -52,8 +51,8 @@ export class AssetController { } @Post('jobs') - @HttpCode(HttpStatus.NO_CONTENT) @Authenticated() + @HttpCode(HttpStatus.NO_CONTENT) runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise { return this.service.run(auth, dto); } @@ -72,13 +71,6 @@ export class AssetController { return this.service.deleteAll(auth, dto); } - @Put('stack/parent') - @HttpCode(HttpStatus.OK) - @Authenticated() - updateStackParent(@Auth() auth: AuthDto, @Body() dto: UpdateStackParentDto): Promise { - return this.service.updateStackParent(auth, dto); - } - @Get(':id') @Authenticated({ sharedLink: true }) getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 9675cf6d3be20..ab569d743425a 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -23,6 +23,7 @@ import { ServerInfoController } from 'src/controllers/server-info.controller'; import { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; +import { StackController } from 'src/controllers/stack.controller'; import { SyncController } from 'src/controllers/sync.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller'; import { SystemMetadataController } from 'src/controllers/system-metadata.controller'; @@ -31,6 +32,7 @@ import { TimelineController } from 'src/controllers/timeline.controller'; import { TrashController } from 'src/controllers/trash.controller'; import { UserAdminController } from 'src/controllers/user-admin.controller'; import { UserController } from 'src/controllers/user.controller'; +import { ViewController } from 'src/controllers/view.controller'; export const controllers = [ APIKeyController, @@ -58,6 +60,7 @@ export const controllers = [ ServerInfoController, SessionController, SharedLinkController, + StackController, SyncController, SystemConfigController, SystemMetadataController, @@ -66,4 +69,5 @@ export const controllers = [ TrashController, UserAdminController, UserController, + ViewController, ]; diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index 18ba43c0a61fe..a45617fc2a503 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -31,18 +31,18 @@ export class LibraryController { return this.service.create(dto); } - @Put(':id') - @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) - updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise { - return this.service.update(id, dto); - } - @Get(':id') @Authenticated({ permission: Permission.LIBRARY_READ, admin: true }) getLibrary(@Param() { id }: UUIDParamDto): Promise { return this.service.get(id); } + @Put(':id') + @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) + updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise { + return this.service.update(id, dto); + } + @Post(':id/validate') @HttpCode(200) @Authenticated({ admin: true }) diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts index cc07022a93747..2772e93b5d816 100644 --- a/server/src/controllers/notification.controller.ts +++ b/server/src/controllers/notification.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, HttpCode, Post } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; @@ -11,7 +11,7 @@ export class NotificationController { constructor(private service: NotificationService) {} @Post('test-email') - @HttpCode(200) + @HttpCode(HttpStatus.OK) @Authenticated({ admin: true }) sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto) { return this.service.sendTestEmail(auth.user.id, dto); diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index 764e67d67663a..b733dc612b227 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { AuthType } from 'src/constants'; @@ -58,6 +58,7 @@ export class OAuthController { } @Post('unlink') + @HttpCode(HttpStatus.OK) @Authenticated() unlinkOAuthAccount(@Auth() auth: AuthDto): Promise { return this.service.unlink(auth); diff --git a/server/src/controllers/partner.controller.ts b/server/src/controllers/partner.controller.ts index 0662243d61e5b..6830fdd52f22b 100644 --- a/server/src/controllers/partner.controller.ts +++ b/server/src/controllers/partner.controller.ts @@ -1,9 +1,8 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; -import { ApiQuery, ApiTags } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; import { Permission } from 'src/enum'; -import { PartnerDirection } from 'src/interfaces/partner.interface'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { PartnerService } from 'src/services/partner.service'; import { UUIDParamDto } from 'src/validation'; @@ -14,9 +13,7 @@ export class PartnerController { constructor(private service: PartnerService) {} @Get() - @ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true }) @Authenticated({ permission: Permission.PARTNER_READ }) - // TODO: remove 'direction' and convert to full query dto getPartners(@Auth() auth: AuthDto, @Query() dto: PartnerSearchDto): Promise { return this.service.search(auth, dto); } diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index 812016f4eb83b..245bbbd3475a4 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController, ApiTags } from '@nestjs/swagger'; import { EndpointLifecycle } from 'src/decorators'; import { ServerAboutResponseDto, @@ -16,6 +16,7 @@ import { Authenticated } from 'src/middleware/auth.guard'; import { ServerService } from 'src/services/server.service'; import { VersionService } from 'src/services/version.service'; +@ApiExcludeController() @ApiTags('Server Info') @Controller('server-info') export class ServerInfoController { @@ -68,9 +69,9 @@ export class ServerInfoController { return this.service.getConfig(); } - @Authenticated({ admin: true }) - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) @Get('statistics') + @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) + @Authenticated({ admin: true }) getServerStatistics(): Promise { return this.service.getStatistics(); } diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index 0c615223e2fd5..75becfe341615 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Delete, Get, Put } from '@nestjs/common'; -import { ApiExcludeEndpoint, ApiNotFoundResponse, ApiTags } from '@nestjs/swagger'; +import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -26,57 +26,48 @@ export class ServerController { @Get('about') @Authenticated() - @ApiExcludeEndpoint() getAboutInfo(): Promise { return this.service.getAboutInfo(); } @Get('storage') @Authenticated() - @ApiExcludeEndpoint() getStorage(): Promise { return this.service.getStorage(); } @Get('ping') - @ApiExcludeEndpoint() pingServer(): ServerPingResponse { return this.service.ping(); } @Get('version') - @ApiExcludeEndpoint() getServerVersion(): ServerVersionResponseDto { return this.versionService.getVersion(); } @Get('features') - @ApiExcludeEndpoint() getServerFeatures(): Promise { return this.service.getFeatures(); } @Get('theme') - @ApiExcludeEndpoint() getTheme(): Promise { return this.service.getTheme(); } @Get('config') - @ApiExcludeEndpoint() getServerConfig(): Promise { return this.service.getConfig(); } - @Authenticated({ admin: true }) @Get('statistics') - @ApiExcludeEndpoint() + @Authenticated({ admin: true }) getServerStatistics(): Promise { return this.service.getStatistics(); } @Get('media-types') - @ApiExcludeEndpoint() getSupportedMediaTypes(): ServerMediaTypesResponseDto { return this.service.getSupportedMediaTypes(); } diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index a1fb4779a587c..d526c2e59951f 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -2,6 +2,7 @@ import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/co import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto } from 'src/dtos/session.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { SessionService } from 'src/services/session.service'; import { UUIDParamDto } from 'src/validation'; @@ -12,21 +13,21 @@ export class SessionController { constructor(private service: SessionService) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.SESSION_READ }) getSessions(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Delete() + @Authenticated({ permission: Permission.SESSION_DELETE }) @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() deleteAllSessions(@Auth() auth: AuthDto): Promise { return this.service.deleteAll(auth); } @Delete(':id') + @Authenticated({ permission: Permission.SESSION_DELETE }) @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/stack.controller.ts b/server/src/controllers/stack.controller.ts new file mode 100644 index 0000000000000..188952eba5802 --- /dev/null +++ b/server/src/controllers/stack.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto } from 'src/dtos/stack.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { StackService } from 'src/services/stack.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Stacks') +@Controller('stacks') +export class StackController { + constructor(private service: StackService) {} + + @Get() + @Authenticated({ permission: Permission.STACK_READ }) + searchStacks(@Auth() auth: AuthDto, @Query() query: StackSearchDto): Promise { + return this.service.search(auth, query); + } + + @Post() + @Authenticated({ permission: Permission.STACK_CREATE }) + createStack(@Auth() auth: AuthDto, @Body() dto: StackCreateDto): Promise { + return this.service.create(auth, dto); + } + + @Delete() + @Authenticated({ permission: Permission.STACK_DELETE }) + @HttpCode(HttpStatus.NO_CONTENT) + deleteStacks(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { + return this.service.deleteAll(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.STACK_READ }) + getStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.STACK_UPDATE }) + updateStack( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: StackUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.STACK_DELETE }) + deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts index 8b646400cc960..cf6b8ac695c2c 100644 --- a/server/src/controllers/tag.controller.ts +++ b/server/src/controllers/tag.controller.ts @@ -1,10 +1,15 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; +import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { CreateTagDto, TagResponseDto, UpdateTagDto } from 'src/dtos/tag.dto'; +import { + TagBulkAssetsDto, + TagBulkAssetsResponseDto, + TagCreateDto, + TagResponseDto, + TagUpdateDto, + TagUpsertDto, +} from 'src/dtos/tag.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TagService } from 'src/services/tag.service'; @@ -17,7 +22,7 @@ export class TagController { @Post() @Authenticated({ permission: Permission.TAG_CREATE }) - createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise { + createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise { return this.service.create(auth, dto); } @@ -27,47 +32,54 @@ export class TagController { return this.service.getAll(auth); } + @Put() + @Authenticated({ permission: Permission.TAG_CREATE }) + upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise { + return this.service.upsert(auth, dto); + } + + @Put('assets') + @Authenticated({ permission: Permission.TAG_ASSET }) + bulkTagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise { + return this.service.bulkTagAssets(auth, dto); + } + @Get(':id') @Authenticated({ permission: Permission.TAG_READ }) getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getById(auth, id); + return this.service.get(auth, id); } - @Patch(':id') + @Put(':id') @Authenticated({ permission: Permission.TAG_UPDATE }) - updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise { + updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise { return this.service.update(auth, id, dto); } @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) @Authenticated({ permission: Permission.TAG_DELETE }) deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } - @Get(':id/assets') - @Authenticated() - getTagAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getAssets(auth, id); - } - @Put(':id/assets') - @Authenticated() + @Authenticated({ permission: Permission.TAG_ASSET }) tagAssets( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, - @Body() dto: AssetIdsDto, - ): Promise { + @Body() dto: BulkIdsDto, + ): Promise { return this.service.addAssets(auth, id, dto); } @Delete(':id/assets') - @Authenticated() + @Authenticated({ permission: Permission.TAG_ASSET }) untagAssets( @Auth() auth: AuthDto, - @Body() dto: AssetIdsDto, + @Body() dto: BulkIdsDto, @Param() { id }: UUIDParamDto, - ): Promise { + ): Promise { return this.service.removeAssets(auth, id, dto); } } diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts index 53c62f70edd28..92de84d346e9b 100644 --- a/server/src/controllers/timeline.controller.ts +++ b/server/src/controllers/timeline.controller.ts @@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TimelineService } from 'src/services/timeline.service'; @@ -11,14 +12,14 @@ import { TimelineService } from 'src/services/timeline.service'; export class TimelineController { constructor(private service: TimelineService) {} - @Authenticated({ sharedLink: true }) @Get('buckets') + @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise { return this.service.getTimeBuckets(auth, dto); } - @Authenticated({ sharedLink: true }) @Get('bucket') + @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise { return this.service.getTimeBucket(auth, dto) as Promise; } diff --git a/server/src/controllers/trash.controller.ts b/server/src/controllers/trash.controller.ts index eae49d4ad1f47..20adbb11bbe7f 100644 --- a/server/src/controllers/trash.controller.ts +++ b/server/src/controllers/trash.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TrashService } from 'src/services/trash.service'; @@ -12,21 +13,21 @@ export class TrashController { @Post('empty') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() + @Authenticated({ permission: Permission.ASSET_DELETE }) emptyTrash(@Auth() auth: AuthDto): Promise { return this.service.empty(auth); } @Post('restore') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() + @Authenticated({ permission: Permission.ASSET_DELETE }) restoreTrash(@Auth() auth: AuthDto): Promise { return this.service.restore(auth); } @Post('restore/assets') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() + @Authenticated({ permission: Permission.ASSET_DELETE }) restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { return this.service.restoreAssets(auth, dto); } diff --git a/server/src/controllers/view.controller.ts b/server/src/controllers/view.controller.ts new file mode 100644 index 0000000000000..b5e281e093deb --- /dev/null +++ b/server/src/controllers/view.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { ViewService } from 'src/services/view.service'; + +@ApiTags('View') +@Controller('view') +export class ViewController { + constructor(private service: ViewService) {} + + @Get('folder/unique-paths') + @Authenticated() + getUniqueOriginalPaths(@Auth() auth: AuthDto): Promise { + return this.service.getUniqueOriginalPaths(auth); + } + + @Get('folder') + @Authenticated() + getAssetsByOriginalPath(@Auth() auth: AuthDto, @Query('path') path: string): Promise { + return this.service.getAssetsByOriginalPath(auth, path); + } +} diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts deleted file mode 100644 index b8ba88b59d388..0000000000000 --- a/server/src/cores/access.core.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { BadRequestException, UnauthorizedException } from '@nestjs/common'; -import { AuthDto } from 'src/dtos/auth.dto'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { AlbumUserRole, Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { setDifference, setIsEqual, setUnion } from 'src/utils/set'; - -let instance: AccessCore | null; - -export class AccessCore { - private constructor(private repository: IAccessRepository) {} - - static create(repository: IAccessRepository) { - if (!instance) { - instance = new AccessCore(repository); - } - - return instance; - } - - static reset() { - instance = null; - } - - requireUploadAccess(auth: AuthDto | null): AuthDto { - if (!auth || (auth.sharedLink && !auth.sharedLink.allowUpload)) { - throw new UnauthorizedException(); - } - return auth; - } - - /** - * Check if user has access to all ids, for the given permission. - * Throws error if user does not have access to any of the ids. - */ - async requirePermission(auth: AuthDto, permission: Permission, ids: string[] | string) { - ids = Array.isArray(ids) ? ids : [ids]; - const allowedIds = await this.checkAccess(auth, permission, ids); - if (!setIsEqual(new Set(ids), allowedIds)) { - throw new BadRequestException(`Not found or no ${permission} access`); - } - } - - /** - * Return ids that user has access to, for the given permission. - * Check is done for each id, and only allowed ids are returned. - * - * @returns Set - */ - async checkAccess(auth: AuthDto, permission: Permission, ids: Set | string[]): Promise> { - const idSet = Array.isArray(ids) ? new Set(ids) : ids; - if (idSet.size === 0) { - return new Set(); - } - - if (auth.sharedLink) { - return this.checkAccessSharedLink(auth.sharedLink, permission, idSet); - } - - return this.checkAccessOther(auth, permission, idSet); - } - - private async checkAccessSharedLink( - sharedLink: SharedLinkEntity, - permission: Permission, - ids: Set, - ): Promise> { - const sharedLinkId = sharedLink.id; - - switch (permission) { - case Permission.ASSET_READ: { - return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids); - } - - case Permission.ASSET_VIEW: { - return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids); - } - - case Permission.ASSET_DOWNLOAD: { - return sharedLink.allowDownload - ? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids) - : new Set(); - } - - case Permission.ASSET_UPLOAD: { - return sharedLink.allowUpload ? ids : new Set(); - } - - case Permission.ASSET_SHARE: { - // TODO: fix this to not use sharedLink.userId for access control - return await this.repository.asset.checkOwnerAccess(sharedLink.userId, ids); - } - - case Permission.ALBUM_READ: { - return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids); - } - - case Permission.ALBUM_DOWNLOAD: { - return sharedLink.allowDownload - ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) - : new Set(); - } - - case Permission.ALBUM_ADD_ASSET: { - return sharedLink.allowUpload - ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) - : new Set(); - } - - default: { - return new Set(); - } - } - } - - private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set): Promise> { - switch (permission) { - // uses album id - case Permission.ACTIVITY_CREATE: { - return await this.repository.activity.checkCreateAccess(auth.user.id, ids); - } - - // uses activity id - case Permission.ACTIVITY_DELETE: { - const isOwner = await this.repository.activity.checkOwnerAccess(auth.user.id, ids); - const isAlbumOwner = await this.repository.activity.checkAlbumOwnerAccess( - auth.user.id, - setDifference(ids, isOwner), - ); - return setUnion(isOwner, isAlbumOwner); - } - - case Permission.ASSET_READ: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await this.repository.asset.checkPartnerAccess( - auth.user.id, - setDifference(ids, isOwner, isAlbum), - ); - return setUnion(isOwner, isAlbum, isPartner); - } - - case Permission.ASSET_SHARE: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isPartner = await this.repository.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); - return setUnion(isOwner, isPartner); - } - - case Permission.ASSET_VIEW: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await this.repository.asset.checkPartnerAccess( - auth.user.id, - setDifference(ids, isOwner, isAlbum), - ); - return setUnion(isOwner, isAlbum, isPartner); - } - - case Permission.ASSET_DOWNLOAD: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await this.repository.asset.checkPartnerAccess( - auth.user.id, - setDifference(ids, isOwner, isAlbum), - ); - return setUnion(isOwner, isAlbum, isPartner); - } - - case Permission.ASSET_UPDATE: { - return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ASSET_DELETE: { - return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ASSET_RESTORE: { - return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_READ: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.VIEWER, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ALBUM_ADD_ASSET: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.EDITOR, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ALBUM_UPDATE: { - return await this.repository.album.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_DELETE: { - return await this.repository.album.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_SHARE: { - return await this.repository.album.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_DOWNLOAD: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.VIEWER, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ALBUM_REMOVE_ASSET: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.EDITOR, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ASSET_UPLOAD: { - return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - } - - case Permission.ARCHIVE_READ: { - return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - } - - case Permission.AUTH_DEVICE_DELETE: { - return await this.repository.authDevice.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.TIMELINE_READ: { - const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - const isPartner = await this.repository.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); - return setUnion(isOwner, isPartner); - } - - case Permission.TIMELINE_DOWNLOAD: { - return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - } - - case Permission.MEMORY_READ: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.MEMORY_UPDATE: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.MEMORY_DELETE: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.MEMORY_DELETE: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_READ: { - return await this.repository.person.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_UPDATE: { - return await this.repository.person.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_MERGE: { - return await this.repository.person.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_CREATE: { - return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_REASSIGN: { - return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids); - } - - case Permission.PARTNER_UPDATE: { - return await this.repository.partner.checkUpdateAccess(auth.user.id, ids); - } - - default: { - return new Set(); - } - } - } -} diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 4f386a51ef775..e20a0c658db7f 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -6,6 +6,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, PathType, PersonPathType } from 'src/entities/move.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { AssetFileType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -13,6 +14,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; export enum StorageFolder { ENCODED_VIDEO = 'encoded-video', @@ -130,12 +132,14 @@ export class StorageCore { } async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) { - const { id: entityId, previewPath, thumbnailPath } = asset; + const { id: entityId, files } = asset; + const { thumbnailFile, previewFile } = getAssetFiles(files); + const oldFile = pathType === AssetPathType.PREVIEW ? previewFile : thumbnailFile; return this.moveFile({ entityId, pathType, - oldPath: pathType === AssetPathType.PREVIEW ? previewPath : thumbnailPath, - newPath: StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, format), + oldPath: oldFile?.path || null, + newPath: StorageCore.getImagePath(asset, pathType, format), }); } @@ -285,10 +289,10 @@ export class StorageCore { return this.assetRepository.update({ id, originalPath: newPath }); } case AssetPathType.PREVIEW: { - return this.assetRepository.update({ id, previewPath: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: newPath }); } case AssetPathType.THUMBNAIL: { - return this.assetRepository.update({ id, thumbnailPath: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: newPath }); } case AssetPathType.ENCODED_VIDEO: { return this.assetRepository.update({ id, encodedVideoPath: newPath }); diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 8f5c996caee17..b12847ee62537 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -95,7 +95,7 @@ export class GetAlbumsDto { assetId?: string; } -export class AlbumCountResponseDto { +export class AlbumStatisticsResponseDto { @ApiProperty({ type: 'integer' }) owned!: number; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 4238fd3490310..ed92208182ee5 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -22,7 +22,6 @@ export class SanitizedAssetResponseDto { type!: AssetType; thumbhash!: string | null; originalMimeType?: string; - resized!: boolean; localDateTime!: Date; duration!: string; livePhotoVideoId?: string | null; @@ -52,11 +51,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; - stackParentId?: string | null; - stack?: AssetResponseDto[]; - @ApiProperty({ type: 'integer' }) - stackCount!: number | null; + stack?: AssetStackResponseDto | null; duplicateId?: string | null; + + @PropertyLifecycle({ deprecatedAt: 'v1.113.0' }) + resized?: boolean; +} + +export class AssetStackResponseDto { + id!: string; + + primaryAssetId!: string; + + @ApiProperty({ type: 'integer' }) + assetCount!: number; } export type AssetMapOptions = { @@ -83,6 +91,18 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] return result; }; +const mapStack = (entity: AssetEntity) => { + if (!entity.stack) { + return null; + } + + return { + id: entity.stack.id, + primaryAssetId: entity.stack.primaryAssetId, + assetCount: entity.stack.assetCount ?? entity.stack.assets.length, + }; +}; + export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; @@ -93,7 +113,6 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash?.toString('base64') ?? null, localDateTime: entity.localDateTime, - resized: !!entity.previewPath, duration: entity.duration ?? '0:00:00.00000', livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, @@ -112,7 +131,6 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As originalPath: entity.originalPath, originalFileName: entity.originalFileName, originalMimeType: mimeTypes.lookup(entity.originalFileName), - resized: !!entity.previewPath, thumbhash: entity.thumbhash?.toString('base64') ?? null, fileCreatedAt: entity.fileCreatedAt, fileModifiedAt: entity.fileModifiedAt, @@ -125,20 +143,15 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, - tags: entity.tags?.map(mapTag), + tags: entity.tags?.map((tag) => mapTag(tag)), people: peopleWithFaces(entity.faces), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), checksum: entity.checksum.toString('base64'), - stackParentId: withStack ? entity.stack?.primaryAssetId : undefined, - stack: withStack - ? entity.stack?.assets - ?.filter((a) => a.id !== entity.stack?.primaryAssetId) - ?.map((a) => mapAsset(a, { stripMetadata, auth: options.auth })) - : undefined, - stackCount: entity.stack?.assetCount ?? entity.stack?.assets?.length ?? null, + stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, hasMetadata: true, duplicateId: entity.duplicateId, + resized: true, }; } diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 9bc007543a893..5a2fdb51200d7 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -60,12 +60,6 @@ export class AssetBulkUpdateDto extends UpdateAssetBase { @ValidateUUID({ each: true }) ids!: string[]; - @ValidateUUID({ optional: true }) - stackParentId?: string; - - @ValidateBoolean({ optional: true }) - removeParent?: boolean; - @Optional() duplicateId?: string | null; } diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index 73863fa95da51..09976b3213595 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -1,5 +1,5 @@ import { IsNotEmpty } from 'class-validator'; -import { groupBy } from 'lodash'; +import { groupBy, sortBy } from 'lodash'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { ValidateUUID } from 'src/validation'; @@ -19,7 +19,8 @@ export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateRespo const grouped = groupBy(assets, (a) => a.duplicateId); - for (const [duplicateId, assets] of Object.entries(grouped)) { + for (const [duplicateId, unsortedAssets] of Object.entries(grouped)) { + const assets = sortBy(unsortedAssets, (asset) => asset.localDateTime); result.push({ duplicateId, assets }); } diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index b9578a2c3766b..c2c3ac9d27546 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -48,12 +48,16 @@ export class UpdateLibraryDto { exclusionPatterns?: string[]; } -export class CrawlOptionsDto { - pathsToCrawl!: string[]; - includeHidden? = false; +export interface CrawlOptionsDto { + pathsToCrawl: string[]; + includeHidden?: boolean; exclusionPatterns?: string[]; } +export interface WalkOptionsDto extends CrawlOptionsDto { + take: number; +} + export class ValidateLibraryDto { @Optional() @IsString({ each: true }) diff --git a/server/src/dtos/stack.dto.ts b/server/src/dtos/stack.dto.ts index 3ff04ee5ed7f4..3b867b02fea74 100644 --- a/server/src/dtos/stack.dto.ts +++ b/server/src/dtos/stack.dto.ts @@ -1,9 +1,38 @@ +import { ArrayMinSize } from 'class-validator'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackEntity } from 'src/entities/stack.entity'; import { ValidateUUID } from 'src/validation'; -export class UpdateStackParentDto { - @ValidateUUID() - oldParentId!: string; - - @ValidateUUID() - newParentId!: string; +export class StackCreateDto { + /** first asset becomes the primary */ + @ValidateUUID({ each: true }) + @ArrayMinSize(2) + assetIds!: string[]; } + +export class StackSearchDto { + primaryAssetId?: string; +} + +export class StackUpdateDto { + @ValidateUUID({ optional: true }) + primaryAssetId?: string; +} + +export class StackResponseDto { + id!: string; + primaryAssetId!: string; + assets!: AssetResponseDto[]; +} + +export const mapStack = (stack: StackEntity, { auth }: { auth?: AuthDto }) => { + const primary = stack.assets.filter((asset) => asset.id === stack.primaryAssetId); + const others = stack.assets.filter((asset) => asset.id !== stack.primaryAssetId); + + return { + id: stack.id, + primaryAssetId: stack.primaryAssetId, + assets: [...primary, ...others].map((asset) => mapAsset(asset, { auth })), + }; +}; diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index 1094d70df375a..cff11962d744a 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,38 +1,66 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { TagEntity, TagType } from 'src/entities/tag.entity'; -import { Optional } from 'src/validation'; +import { Transform } from 'class-transformer'; +import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; +import { TagEntity } from 'src/entities/tag.entity'; +import { Optional, ValidateUUID } from 'src/validation'; -export class CreateTagDto { +export class TagCreateDto { @IsString() @IsNotEmpty() name!: string; - @IsEnum(TagType) - @IsNotEmpty() - @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType }) - type!: TagType; + @ValidateUUID({ optional: true, nullable: true }) + parentId?: string | null; + + @IsHexColor() + @Optional({ nullable: true, emptyToNull: true }) + color?: string; } -export class UpdateTagDto { - @IsString() - @Optional() - name?: string; +export class TagUpdateDto { + @Optional({ nullable: true, emptyToNull: true }) + @IsHexColor() + @Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)) + color?: string | null; +} + +export class TagUpsertDto { + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + tags!: string[]; +} + +export class TagBulkAssetsDto { + @ValidateUUID({ each: true }) + tagIds!: string[]; + + @ValidateUUID({ each: true }) + assetIds!: string[]; +} + +export class TagBulkAssetsResponseDto { + @ApiProperty({ type: 'integer' }) + count!: number; } export class TagResponseDto { id!: string; - @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType }) - type!: string; + parentId?: string; name!: string; - userId!: string; + value!: string; + createdAt!: Date; + updatedAt!: Date; + color?: string; } export function mapTag(entity: TagEntity): TagResponseDto { return { id: entity.id, - type: entity.type, - name: entity.name, - userId: entity.userId, + parentId: entity.parentId ?? undefined, + name: entity.value.split('/').at(-1) as string, + value: entity.value, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + color: entity.color ?? undefined, }; } diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 8803f24fc467d..dd7a01df356ef 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -19,6 +19,9 @@ export class TimeBucketDto { @ValidateUUID({ optional: true }) personId?: string; + @ValidateUUID({ optional: true }) + tagId?: string; + @ValidateBoolean({ optional: true }) isArchived?: boolean; diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 7ccf6cd78bbb3..8de7021eaf3c5 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -12,16 +12,40 @@ class AvatarUpdate { color?: UserAvatarColor; } -class MemoryUpdate { +class MemoriesUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; } -class RatingUpdate { +class RatingsUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; } +class FoldersUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + +class PeopleUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + +class TagsUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + class EmailNotificationsUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; @@ -56,19 +80,34 @@ class PurchaseUpdate { export class UserPreferencesUpdateDto { @Optional() @ValidateNested() - @Type(() => RatingUpdate) - rating?: RatingUpdate; + @Type(() => FoldersUpdate) + folders?: FoldersUpdate; + + @Optional() + @ValidateNested() + @Type(() => MemoriesUpdate) + memories?: MemoriesUpdate; + + @Optional() + @ValidateNested() + @Type(() => PeopleUpdate) + people?: PeopleUpdate; + + @Optional() + @ValidateNested() + @Type(() => RatingsUpdate) + ratings?: RatingsUpdate; + + @Optional() + @ValidateNested() + @Type(() => TagsUpdate) + tags?: TagsUpdate; @Optional() @ValidateNested() @Type(() => AvatarUpdate) avatar?: AvatarUpdate; - @Optional() - @ValidateNested() - @Type(() => MemoryUpdate) - memories?: MemoryUpdate; - @Optional() @ValidateNested() @Type(() => EmailNotificationsUpdate) @@ -90,12 +129,27 @@ class AvatarResponse { color!: UserAvatarColor; } -class RatingResponse { +class RatingsResponse { enabled: boolean = false; } -class MemoryResponse { - enabled!: boolean; +class MemoriesResponse { + enabled: boolean = true; +} + +class FoldersResponse { + enabled: boolean = false; + sidebarWeb: boolean = false; +} + +class PeopleResponse { + enabled: boolean = true; + sidebarWeb: boolean = false; +} + +class TagsResponse { + enabled: boolean = true; + sidebarWeb: boolean = true; } class EmailNotificationsResponse { @@ -117,8 +171,11 @@ class PurchaseResponse { } export class UserPreferencesResponseDto implements UserPreferences { - rating!: RatingResponse; - memories!: MemoryResponse; + folders!: FoldersResponse; + memories!: MemoriesResponse; + people!: PeopleResponse; + ratings!: RatingsResponse; + tags!: TagsResponse; avatar!: AvatarResponse; emailNotifications!: EmailNotificationsResponse; download!: DownloadResponse; diff --git a/server/src/dtos/user-profile.dto.ts b/server/src/dtos/user-profile.dto.ts index b14662c844026..9659fa39650a3 100644 --- a/server/src/dtos/user-profile.dto.ts +++ b/server/src/dtos/user-profile.dto.ts @@ -13,7 +13,7 @@ export class CreateProfileImageResponseDto { export function mapCreateProfileImageResponse(userId: string, profileImagePath: string): CreateProfileImageResponseDto { return { - userId: userId, - profileImagePath: profileImagePath, + userId, + profileImagePath, }; } diff --git a/server/src/emails/album-invite.email.tsx b/server/src/emails/album-invite.email.tsx index b804be0898d62..232ef5290d6db 100644 --- a/server/src/emails/album-invite.email.tsx +++ b/server/src/emails/album-invite.email.tsx @@ -1,8 +1,8 @@ import { Img, Link, Section, Text } from '@react-email/components'; import * as React from 'react'; +import { ImmichButton } from 'src/emails/components/button.component'; +import ImmichLayout from 'src/emails/components/immich.layout'; import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface'; -import { ImmichButton } from './components/button.component'; -import ImmichLayout from './components/immich.layout'; export const AlbumInviteEmail = ({ baseUrl, diff --git a/server/src/emails/album-update.email.tsx b/server/src/emails/album-update.email.tsx index d05631a772496..0fb5ad931c9f5 100644 --- a/server/src/emails/album-update.email.tsx +++ b/server/src/emails/album-update.email.tsx @@ -1,8 +1,8 @@ import { Img, Link, Section, Text } from '@react-email/components'; import * as React from 'react'; +import { ImmichButton } from 'src/emails/components/button.component'; +import ImmichLayout from 'src/emails/components/immich.layout'; import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface'; -import { ImmichButton } from './components/button.component'; -import ImmichLayout from './components/immich.layout'; export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, cid }: AlbumUpdateEmailProps) => ( diff --git a/server/src/emails/components/immich.layout.tsx b/server/src/emails/components/immich.layout.tsx index 8e6de2eebc068..bb7a2aab65a3f 100644 --- a/server/src/emails/components/immich.layout.tsx +++ b/server/src/emails/components/immich.layout.tsx @@ -1,6 +1,6 @@ import { Body, Container, Font, Head, Hr, Html, Img, Preview, Section, Tailwind, Text } from '@react-email/components'; import * as React from 'react'; -import { ImmichFooter } from './footer.template'; +import { ImmichFooter } from 'src/emails/components/footer.template'; interface ImmichLayoutProps { children: React.ReactNode; @@ -11,6 +11,7 @@ export const ImmichLayout = ({ children, preview }: ImmichLayoutProps) => ( ( diff --git a/server/src/emails/welcome.email.tsx b/server/src/emails/welcome.email.tsx index d6b3fc13e7ebd..e031ac6b97137 100644 --- a/server/src/emails/welcome.email.tsx +++ b/server/src/emails/welcome.email.tsx @@ -1,8 +1,8 @@ import { Link, Section, Text } from '@react-email/components'; import * as React from 'react'; +import { ImmichButton } from 'src/emails/components/button.component'; +import ImmichLayout from 'src/emails/components/immich.layout'; import { WelcomeEmailProps } from 'src/interfaces/notification.interface'; -import { ImmichButton } from './components/button.component'; -import ImmichLayout from './components/immich.layout'; export const WelcomeEmail = ({ baseUrl, displayName, username, password }: WelcomeEmailProps) => ( diff --git a/server/src/entities/asset-files.entity.ts b/server/src/entities/asset-files.entity.ts new file mode 100644 index 0000000000000..a8a6ddfee1024 --- /dev/null +++ b/server/src/entities/asset-files.entity.ts @@ -0,0 +1,38 @@ +import { AssetEntity } from 'src/entities/asset.entity'; +import { AssetFileType } from 'src/enum'; +import { + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +@Unique('UQ_assetId_type', ['assetId', 'type']) +@Entity('asset_files') +export class AssetFileEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Index('IDX_asset_files_assetId') + @Column() + assetId!: string; + + @ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + asset?: AssetEntity; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column() + type!: AssetFileType; + + @Column() + path!: string; +} diff --git a/server/src/entities/asset-job-status.entity.ts b/server/src/entities/asset-job-status.entity.ts index 44c0a0469619e..353055df437bc 100644 --- a/server/src/entities/asset-job-status.entity.ts +++ b/server/src/entities/asset-job-status.entity.ts @@ -18,4 +18,10 @@ export class AssetJobStatusEntity { @Column({ type: 'timestamptz', nullable: true }) duplicatesDetectedAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + previewAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + thumbnailAt!: Date | null; } diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index f4ea5eafddb9c..9ebf9364d1b2a 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,5 +1,6 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { LibraryEntity } from 'src/entities/library.entity'; @@ -72,11 +73,8 @@ export class AssetEntity { @Column() originalPath!: string; - @Column({ type: 'varchar', nullable: true }) - previewPath!: string | null; - - @Column({ type: 'varchar', nullable: true, default: '' }) - thumbnailPath!: string | null; + @OneToMany(() => AssetFileEntity, (assetFile) => assetFile.asset) + files!: AssetFileEntity[]; @Column({ type: 'bytea', nullable: true }) thumbhash!: Buffer | null; diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 148e2640955d2..0b7ca8c3bd013 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -3,6 +3,7 @@ import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { APIKeyEntity } from 'src/entities/api-key.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { AuditEntity } from 'src/entities/audit.entity'; @@ -32,6 +33,7 @@ export const entities = [ APIKeyEntity, AssetEntity, AssetFaceEntity, + AssetFileEntity, AssetJobStatusEntity, AuditEntity, ExifEntity, diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts index 93edcb0555656..ebcc6853c9bbd 100644 --- a/server/src/entities/tag.entity.ts +++ b/server/src/entities/tag.entity.ts @@ -1,45 +1,53 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { Column, Entity, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + Tree, + TreeChildren, + TreeParent, + Unique, + UpdateDateColumn, +} from 'typeorm'; @Entity('tags') -@Unique('UQ_tag_name_userId', ['name', 'userId']) +@Unique(['userId', 'value']) +@Tree('closure-table') export class TagEntity { @PrimaryGeneratedColumn('uuid') id!: string; @Column() - type!: TagType; + value!: string; - @Column() - name!: string; + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; - @ManyToOne(() => UserEntity, (user) => user.tags) - user!: UserEntity; + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ type: 'varchar', nullable: true, default: null }) + color!: string | null; + + @Column({ nullable: true }) + parentId?: string; + + @TreeParent({ onDelete: 'CASCADE' }) + parent?: TagEntity; + + @TreeChildren() + children?: TagEntity[]; + + @ManyToOne(() => UserEntity, (user) => user.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + user?: UserEntity; @Column() userId!: string; - @Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true }) - renameTagId!: string | null; - - @ManyToMany(() => AssetEntity, (asset) => asset.tags) - assets!: AssetEntity[]; -} - -export enum TagType { - /** - * Tag that is detected by the ML model for object detection will use this type - */ - OBJECT = 'OBJECT', - - /** - * Face that is detected by the ML model for facial detection (TBD/NOT YET IMPLEMENTED) will use this type - */ - FACE = 'FACE', - - /** - * Tag that is created by the user will use this type - */ - CUSTOM = 'CUSTOM', + @ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + assets?: AssetEntity[]; } diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index eadcdeec57eb0..c342cb71f8ae2 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -19,12 +19,24 @@ export class UserMetadataEntity } export interface UserPreferences { - rating: { + folders: { enabled: boolean; + sidebarWeb: boolean; }; memories: { enabled: boolean; }; + people: { + enabled: boolean; + sidebarWeb: boolean; + }; + ratings: { + enabled: boolean; + }; + tags: { + enabled: boolean; + sidebarWeb: boolean; + }; avatar: { color: UserAvatarColor; }; @@ -50,12 +62,24 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences ); return { - rating: { + folders: { enabled: false, + sidebarWeb: false, }, memories: { enabled: true, }, + people: { + enabled: true, + sidebarWeb: false, + }, + ratings: { + enabled: false, + }, + tags: { + enabled: false, + sidebarWeb: false, + }, avatar: { color: values[randomIndex], }, diff --git a/server/src/enum.ts b/server/src/enum.ts index da4b2d76fc580..9cd5c189e8431 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -5,6 +5,11 @@ export enum AssetType { OTHER = 'OTHER', } +export enum AssetFileType { + PREVIEW = 'preview', + THUMBNAIL = 'thumbnail', +} + export enum AlbumUserRole { EDITOR = 'editor', VIEWER = 'viewer', @@ -49,7 +54,6 @@ export enum Permission { ASSET_READ = 'asset.read', ASSET_UPDATE = 'asset.update', ASSET_DELETE = 'asset.delete', - ASSET_RESTORE = 'asset.restore', ASSET_SHARE = 'asset.share', ASSET_VIEW = 'asset.view', ASSET_DOWNLOAD = 'asset.download', @@ -102,11 +106,20 @@ export enum Permission { PERSON_MERGE = 'person.merge', PERSON_REASSIGN = 'person.reassign', + SESSION_READ = 'session.read', + SESSION_UPDATE = 'session.update', + SESSION_DELETE = 'session.delete', + SHARED_LINK_CREATE = 'sharedLink.create', SHARED_LINK_READ = 'sharedLink.read', SHARED_LINK_UPDATE = 'sharedLink.update', SHARED_LINK_DELETE = 'sharedLink.delete', + STACK_CREATE = 'stack.create', + STACK_READ = 'stack.read', + STACK_UPDATE = 'stack.update', + STACK_DELETE = 'stack.delete', + SYSTEM_CONFIG_READ = 'systemConfig.read', SYSTEM_CONFIG_UPDATE = 'systemConfig.update', @@ -117,6 +130,7 @@ export enum Permission { TAG_READ = 'tag.read', TAG_UPDATE = 'tag.update', TAG_DELETE = 'tag.delete', + TAG_ASSET = 'tag.asset', ADMIN_USER_CREATE = 'admin.user.create', ADMIN_USER_READ = 'admin.user.read', diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index cf5ebbd0052ab..d8d7b4e807ab9 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -42,4 +42,12 @@ export interface IAccessRepository { partner: { checkUpdateAccess(userId: string, partnerIds: Set): Promise>; }; + + stack: { + checkOwnerAccess(userId: string, stackIds: Set): Promise>; + }; + + tag: { + checkOwnerAccess(userId: string, tagIds: Set): Promise>; + }; } diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index aca45f3dc7706..9f7213de82b80 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -1,7 +1,7 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetOrder, AssetType } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetType } from 'src/enum'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -16,6 +16,7 @@ export interface AssetStatsOptions { export interface LivePhotoSearchOptions { ownerId: string; + libraryId?: string | null; livePhotoCID: string; otherAssetId: string; type: AssetType; @@ -35,6 +36,7 @@ export enum WithoutProperty { export enum WithProperty { SIDECAR = 'sidecar', + IS_ONLINE = 'isOnline', IS_OFFLINE = 'isOffline', } @@ -49,6 +51,7 @@ export interface AssetBuilderOptions { isTrashed?: boolean; isDuplicate?: boolean; albumId?: string; + tagId?: string; personId?: string; userIds?: string[]; withStacked?: boolean; @@ -144,6 +147,8 @@ export type AssetPathEntity = Pick; + getUniqueOriginalPaths(userId: string): Promise; create(asset: AssetCreate): Promise; getByIds( ids: string[], @@ -164,7 +169,12 @@ export interface IAssetRepository { order?: FindOptionsOrder, ): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; - getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; + getWith( + pagination: PaginationOptions, + property: WithProperty, + libraryId?: string, + withDeleted?: boolean, + ): Paginated; getRandom(userId: string, count: number): Promise; getFirstAssetForAlbumId(albumId: string): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; @@ -191,4 +201,5 @@ export interface IAssetRepository { getDuplicates(options: AssetBuilderOptions): Promise; getAllForUserFullSync(options: AssetFullSyncOptions): Promise; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; + upsertFile(options: { assetId: string; type: AssetFileType; path: string }): Promise; } diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 613a6423a4534..bb2b0d9ab4bc9 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -6,19 +6,23 @@ export const IEventRepository = 'IEventRepository'; type EmitEventMap = { // app events - onBootstrap: ['api' | 'microservices']; - onShutdown: []; + 'app.bootstrap': ['api' | 'microservices']; + 'app.shutdown': []; // config events - onConfigUpdate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; - onConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + 'config.update': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events - onAlbumUpdate: [{ id: string; updatedBy: string }]; - onAlbumInvite: [{ id: string; userId: string }]; + 'album.update': [{ id: string; updatedBy: string }]; + 'album.invite': [{ id: string; userId: string }]; + + // tag events + 'asset.tag': [{ assetId: string }]; + 'asset.untag': [{ assetId: string }]; // user events - onUserSignup: [{ notify: boolean; id: string; tempPassword?: string }]; + 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; }; export type EmitEvent = keyof EmitEventMap; diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 7776d2bd370b5..bc780398eaf05 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -76,6 +76,7 @@ export enum JobName { LIBRARY_SCAN = 'library-refresh', LIBRARY_SCAN_ASSET = 'library-refresh-asset', LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', + LIBRARY_CHECK_OFFLINE = 'library-check-offline', LIBRARY_DELETE = 'library-delete', LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', @@ -110,6 +111,7 @@ export enum JobName { } export const JOBS_ASSET_PAGINATION_SIZE = 1000; +export const JOBS_LIBRARY_PAGINATION_SIZE = 100_000; export interface IBaseJob { force?: boolean; @@ -129,6 +131,11 @@ export interface ILibraryFileJob extends IEntityJob { assetPath: string; } +export interface ILibraryOfflineJob extends IEntityJob { + importPaths: string[]; + exclusionPatterns: string[]; +} + export interface ILibraryRefreshJob extends IEntityJob { refreshModifiedFiles: boolean; refreshAllFiles: boolean; @@ -148,6 +155,7 @@ export interface ISidecarWriteJob extends IEntityJob { latitude?: number; longitude?: number; rating?: number; + tags?: true; } export interface IDeferrableJob extends IEntityJob { @@ -264,6 +272,7 @@ export type JobItem = | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } + | { name: JobName.LIBRARY_CHECK_OFFLINE; data: IEntityJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } // Notification diff --git a/server/src/interfaces/library.interface.ts b/server/src/interfaces/library.interface.ts index 6468977df4b21..d8f1a1303116e 100644 --- a/server/src/interfaces/library.interface.ts +++ b/server/src/interfaces/library.interface.ts @@ -12,5 +12,4 @@ export interface ILibraryRepository { softDelete(id: string): Promise; update(library: Partial): Promise; getStatistics(id: string): Promise; - getAssetIds(id: string, withDeleted?: boolean): Promise; } diff --git a/server/src/interfaces/notification.interface.ts b/server/src/interfaces/notification.interface.ts index c0ba4e209d052..ec0ecc534b6b1 100644 --- a/server/src/interfaces/notification.interface.ts +++ b/server/src/interfaces/notification.interface.ts @@ -90,7 +90,7 @@ export type SendEmailResponse = { }; export interface INotificationRepository { - renderEmail(request: EmailRenderRequest): { html: string; text: string }; + renderEmail(request: EmailRenderRequest): Promise<{ html: string; text: string }>; sendEmail(options: SendEmailOptions): Promise; verifySmtp(options: SmtpOptions): Promise; } diff --git a/server/src/interfaces/stack.interface.ts b/server/src/interfaces/stack.interface.ts index 0e6baf0a34a2e..378f63fd95a6a 100644 --- a/server/src/interfaces/stack.interface.ts +++ b/server/src/interfaces/stack.interface.ts @@ -2,9 +2,16 @@ import { StackEntity } from 'src/entities/stack.entity'; export const IStackRepository = 'IStackRepository'; +export interface StackSearch { + ownerId: string; + primaryAssetId?: string; +} + export interface IStackRepository { - create(stack: Partial & { ownerId: string }): Promise; + search(query: StackSearch): Promise; + create(stack: { ownerId: string; assetIds: string[] }): Promise; update(stack: Pick & Partial): Promise; delete(id: string): Promise; + deleteAll(ids: string[]): Promise; getById(id: string): Promise; } diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts index f27edaccc91bd..fec3d66dd5c03 100644 --- a/server/src/interfaces/storage.interface.ts +++ b/server/src/interfaces/storage.interface.ts @@ -2,7 +2,7 @@ import { WatchOptions } from 'chokidar'; import { Stats } from 'node:fs'; import { FileReadOptions } from 'node:fs/promises'; import { Readable } from 'node:stream'; -import { CrawlOptionsDto } from 'src/dtos/library.dto'; +import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; export interface ImmichReadStream { stream: Readable; @@ -45,8 +45,8 @@ export interface IStorageRepository { checkDiskUsage(folder: string): Promise; readdir(folder: string): Promise; stat(filepath: string): Promise; - crawl(crawlOptions: CrawlOptionsDto): Promise; - walk(crawlOptions: CrawlOptionsDto): AsyncGenerator; + crawl(options: CrawlOptionsDto): Promise; + walk(options: WalkOptionsDto): AsyncGenerator; copyFile(source: string, target: string): Promise; rename(source: string, target: string): Promise; watch(paths: string[], options: WatchOptions, events: Partial): () => Promise; diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts index 8071461dfca07..aca9c223d552b 100644 --- a/server/src/interfaces/tag.interface.ts +++ b/server/src/interfaces/tag.interface.ts @@ -1,17 +1,20 @@ -import { AssetEntity } from 'src/entities/asset.entity'; import { TagEntity } from 'src/entities/tag.entity'; +import { IBulkAsset } from 'src/utils/asset.util'; export const ITagRepository = 'ITagRepository'; -export interface ITagRepository { - getById(userId: string, tagId: string): Promise; +export type AssetTagItem = { assetId: string; tagId: string }; + +export interface ITagRepository extends IBulkAsset { getAll(userId: string): Promise; + getByValue(userId: string, value: string): Promise; + upsertValue(request: { userId: string; value: string; parent?: TagEntity }): Promise; + create(tag: Partial): Promise; - update(tag: Partial): Promise; - remove(tag: TagEntity): Promise; - hasName(userId: string, name: string): Promise; - hasAsset(userId: string, tagId: string, assetId: string): Promise; - getAssets(userId: string, tagId: string): Promise; - addAssets(userId: string, tagId: string, assetIds: string[]): Promise; - removeAssets(userId: string, tagId: string, assetIds: string[]): Promise; + get(id: string): Promise; + update(tag: { id: string } & Partial): Promise; + delete(id: string): Promise; + + upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise; + upsertAssetIds(items: AssetTagItem[]): Promise; } diff --git a/server/src/migrations/1724080823160-AddThumbnailJobStatus.ts b/server/src/migrations/1724080823160-AddThumbnailJobStatus.ts new file mode 100644 index 0000000000000..a71ddfbcf32a8 --- /dev/null +++ b/server/src/migrations/1724080823160-AddThumbnailJobStatus.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddThumbnailJobStatus1724080823160 implements MigrationInterface { + name = 'AddThumbnailJobStatus1724080823160'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_job_status" ADD "previewAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "asset_job_status" ADD "thumbnailAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`UPDATE "asset_job_status" SET "previewAt" = NOW() FROM "assets" WHERE "assetId" = "assets"."id" AND "assets"."previewPath" IS NOT NULL`); + await queryRunner.query(`UPDATE "asset_job_status" SET "thumbnailAt" = NOW() FROM "assets" WHERE "assetId" = "assets"."id" AND "assets"."thumbnailPath" IS NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_job_status" DROP COLUMN "thumbnailAt"`); + await queryRunner.query(`ALTER TABLE "asset_job_status" DROP COLUMN "previewAt"`); + } +} diff --git a/server/src/migrations/1724101822106-AddAssetFilesTable.ts b/server/src/migrations/1724101822106-AddAssetFilesTable.ts new file mode 100644 index 0000000000000..1ed4945749dd8 --- /dev/null +++ b/server/src/migrations/1724101822106-AddAssetFilesTable.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAssetFilesTable1724101822106 implements MigrationInterface { + name = 'AddAssetFilesTable1724101822106' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "asset_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "assetId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "type" character varying NOT NULL, "path" character varying NOT NULL, CONSTRAINT "UQ_assetId_type" UNIQUE ("assetId", "type"), CONSTRAINT "PK_c41dc3e9ef5e1c57ca5a08a0004" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_asset_files_assetId" ON "asset_files" ("assetId") `); + await queryRunner.query(`ALTER TABLE "asset_files" ADD CONSTRAINT "FK_e3e103a5f1d8bc8402999286040" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + + // preview path migration + await queryRunner.query(`INSERT INTO "asset_files" ("assetId", "type", "path") SELECT "id", 'preview', "previewPath" FROM "assets" WHERE "previewPath" IS NOT NULL AND "previewPath" != ''`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "previewPath"`); + + // thumbnail path migration + await queryRunner.query(`INSERT INTO "asset_files" ("assetId", "type", "path") SELECT "id", 'thumbnail', "thumbnailPath" FROM "assets" WHERE "thumbnailPath" IS NOT NULL AND "thumbnailPath" != ''`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "thumbnailPath"`); + } + + public async down(queryRunner: QueryRunner): Promise { + // undo preview path migration + await queryRunner.query(`ALTER TABLE "assets" ADD "previewPath" character varying`); + await queryRunner.query(`UPDATE "assets" SET "previewPath" = "asset_files".path FROM "asset_files" WHERE "assets".id = "asset_files".assetId AND "asset_files".type = 'preview'`); + + // undo thumbnail path migration + await queryRunner.query(`ALTER TABLE "assets" ADD "thumbnailPath" character varying DEFAULT ''`); + await queryRunner.query(`UPDATE "assets" SET "thumbnailPath" = "asset_files".path FROM "asset_files" WHERE "assets".id = "asset_files".assetId AND "asset_files".type = 'thumbnail'`); + + await queryRunner.query(`ALTER TABLE "asset_files" DROP CONSTRAINT "FK_e3e103a5f1d8bc8402999286040"`); + await queryRunner.query(`DROP INDEX "public"."IDX_asset_files_assetId"`); + await queryRunner.query(`DROP TABLE "asset_files"`); + } + +} diff --git a/server/src/migrations/1724790460210-NestedTagTable.ts b/server/src/migrations/1724790460210-NestedTagTable.ts new file mode 100644 index 0000000000000..dfda9a6d7a38e --- /dev/null +++ b/server/src/migrations/1724790460210-NestedTagTable.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class NestedTagTable1724790460210 implements MigrationInterface { + name = 'NestedTagTable1724790460210' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('TRUNCATE TABLE "tags" CASCADE'); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_tag_name_userId"`); + await queryRunner.query(`CREATE TABLE "tags_closure" ("id_ancestor" uuid NOT NULL, "id_descendant" uuid NOT NULL, CONSTRAINT "PK_eab38eb12a3ec6df8376c95477c" PRIMARY KEY ("id_ancestor", "id_descendant"))`); + await queryRunner.query(`CREATE INDEX "IDX_15fbcbc67663c6bfc07b354c22" ON "tags_closure" ("id_ancestor") `); + await queryRunner.query(`CREATE INDEX "IDX_b1a2a7ed45c29179b5ad51548a" ON "tags_closure" ("id_descendant") `); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "renameTagId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "type"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "name"`); + await queryRunner.query(`ALTER TABLE "tags" ADD "value" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451" UNIQUE ("value")`); + await queryRunner.query(`ALTER TABLE "tags" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "tags" ADD "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "tags" ADD "color" character varying`); + await queryRunner.query(`ALTER TABLE "tags" ADD "parentId" uuid`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99" FOREIGN KEY ("parentId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c" FOREIGN KEY ("id_ancestor") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1" FOREIGN KEY ("id_descendant") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1"`); + await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "parentId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "color"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "updatedAt"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "createdAt"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "value"`); + await queryRunner.query(`ALTER TABLE "tags" ADD "name" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD "type" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD "renameTagId" uuid`); + await queryRunner.query(`DROP INDEX "public"."IDX_b1a2a7ed45c29179b5ad51548a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_15fbcbc67663c6bfc07b354c22"`); + await queryRunner.query(`DROP TABLE "tags_closure"`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_tag_name_userId" UNIQUE ("name", "userId")`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} diff --git a/server/src/migrations/1725023079109-FixTagUniqueness.ts b/server/src/migrations/1725023079109-FixTagUniqueness.ts new file mode 100644 index 0000000000000..859712621c1a9 --- /dev/null +++ b/server/src/migrations/1725023079109-FixTagUniqueness.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FixTagUniqueness1725023079109 implements MigrationInterface { + name = 'FixTagUniqueness1725023079109' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451"`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_79d6f16e52bb2c7130375246793" UNIQUE ("userId", "value")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_79d6f16e52bb2c7130375246793"`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451" UNIQUE ("value")`); + } + +} diff --git a/server/src/migrations/1725258039306-UpsertMissingAssetJobStatus.ts b/server/src/migrations/1725258039306-UpsertMissingAssetJobStatus.ts new file mode 100644 index 0000000000000..8eb47db438c29 --- /dev/null +++ b/server/src/migrations/1725258039306-UpsertMissingAssetJobStatus.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpsertMissingAssetJobStatus1725258039306 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO "asset_job_status" ("assetId", "facesRecognizedAt", "metadataExtractedAt", "duplicatesDetectedAt", "previewAt", "thumbnailAt") SELECT "assetId", NULL, NULL, NULL, NULL, NULL FROM "asset_files" f WHERE "f"."path" IS NOT NULL ON CONFLICT DO NOTHING`, + ); + + await queryRunner.query( + `UPDATE "asset_job_status" SET "previewAt" = NOW() FROM "asset_files" f WHERE "previewAt" IS NULL AND "asset_job_status"."assetId" = "f"."assetId" AND "f"."type" = 'preview' AND "f"."path" IS NOT NULL`, + ); + + await queryRunner.query( + `UPDATE "asset_job_status" SET "thumbnailAt" = NOW() FROM "asset_files" f WHERE "thumbnailAt" IS NULL AND "asset_job_status"."assetId" = "f"."assetId" AND "f"."type" = 'thumbnail' AND "f"."path" IS NOT NULL`, + ); + } + + public async down(): Promise { + // do nothing + } +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index ffe4b6413feeb..ad57eac0ad90d 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -248,6 +248,28 @@ WHERE "partner"."sharedById" IN ($1) AND "partner"."sharedWithId" = $2 +-- AccessRepository.stack.checkOwnerAccess +SELECT + "StackEntity"."id" AS "StackEntity_id" +FROM + "asset_stack" "StackEntity" +WHERE + ( + ("StackEntity"."id" IN ($1)) + AND ("StackEntity"."ownerId" = $2) + ) + +-- AccessRepository.tag.checkOwnerAccess +SELECT + "TagEntity"."id" AS "TagEntity_id" +FROM + "tags" "TagEntity" +WHERE + ( + ("TagEntity"."id" IN ($1)) + AND ("TagEntity"."userId" = $2) + ) + -- AccessRepository.timeline.checkPartnerAccess SELECT "partner"."sharedById" AS "partner_sharedById", diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 98fb1d6999d8f..3852439936d83 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -9,8 +9,6 @@ SELECT "entity"."deviceId" AS "entity_deviceId", "entity"."type" AS "entity_type", "entity"."originalPath" AS "entity_originalPath", - "entity"."previewPath" AS "entity_previewPath", - "entity"."thumbnailPath" AS "entity_thumbnailPath", "entity"."thumbhash" AS "entity_thumbhash", "entity"."encodedVideoPath" AS "entity_encodedVideoPath", "entity"."createdAt" AS "entity_createdAt", @@ -59,16 +57,22 @@ SELECT "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", - "exifInfo"."fps" AS "exifInfo_fps" + "exifInfo"."fps" AS "exifInfo_fps", + "files"."id" AS "files_id", + "files"."assetId" AS "files_assetId", + "files"."createdAt" AS "files_createdAt", + "files"."updatedAt" AS "files_updatedAt", + "files"."type" AS "files_type", + "files"."path" AS "files_path" FROM "assets" "entity" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id" WHERE ( "entity"."ownerId" IN ($1) AND "entity"."isVisible" = true AND "entity"."isArchived" = false - AND "entity"."previewPath" IS NOT NULL AND EXTRACT( DAY FROM @@ -93,8 +97,6 @@ SELECT "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -129,8 +131,6 @@ SELECT "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -184,10 +184,12 @@ SELECT "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags", "AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects", "AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id", - "AssetEntity__AssetEntity_tags"."type" AS "AssetEntity__AssetEntity_tags_type", - "AssetEntity__AssetEntity_tags"."name" AS "AssetEntity__AssetEntity_tags_name", + "AssetEntity__AssetEntity_tags"."value" AS "AssetEntity__AssetEntity_tags_value", + "AssetEntity__AssetEntity_tags"."createdAt" AS "AssetEntity__AssetEntity_tags_createdAt", + "AssetEntity__AssetEntity_tags"."updatedAt" AS "AssetEntity__AssetEntity_tags_updatedAt", + "AssetEntity__AssetEntity_tags"."color" AS "AssetEntity__AssetEntity_tags_color", + "AssetEntity__AssetEntity_tags"."parentId" AS "AssetEntity__AssetEntity_tags_parentId", "AssetEntity__AssetEntity_tags"."userId" AS "AssetEntity__AssetEntity_tags_userId", - "AssetEntity__AssetEntity_tags"."renameTagId" AS "AssetEntity__AssetEntity_tags_renameTagId", "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", @@ -216,8 +218,6 @@ SELECT "bd93d5747511a4dad4923546c51365bf1a803774"."deviceId" AS "bd93d5747511a4dad4923546c51365bf1a803774_deviceId", "bd93d5747511a4dad4923546c51365bf1a803774"."type" AS "bd93d5747511a4dad4923546c51365bf1a803774_type", "bd93d5747511a4dad4923546c51365bf1a803774"."originalPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalPath", - "bd93d5747511a4dad4923546c51365bf1a803774"."previewPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_previewPath", - "bd93d5747511a4dad4923546c51365bf1a803774"."thumbnailPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbnailPath", "bd93d5747511a4dad4923546c51365bf1a803774"."thumbhash" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbhash", "bd93d5747511a4dad4923546c51365bf1a803774"."encodedVideoPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_encodedVideoPath", "bd93d5747511a4dad4923546c51365bf1a803774"."createdAt" AS "bd93d5747511a4dad4923546c51365bf1a803774_createdAt", @@ -237,7 +237,13 @@ SELECT "bd93d5747511a4dad4923546c51365bf1a803774"."originalFileName" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalFileName", "bd93d5747511a4dad4923546c51365bf1a803774"."sidecarPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_sidecarPath", "bd93d5747511a4dad4923546c51365bf1a803774"."stackId" AS "bd93d5747511a4dad4923546c51365bf1a803774_stackId", - "bd93d5747511a4dad4923546c51365bf1a803774"."duplicateId" AS "bd93d5747511a4dad4923546c51365bf1a803774_duplicateId" + "bd93d5747511a4dad4923546c51365bf1a803774"."duplicateId" AS "bd93d5747511a4dad4923546c51365bf1a803774_duplicateId", + "AssetEntity__AssetEntity_files"."id" AS "AssetEntity__AssetEntity_files_id", + "AssetEntity__AssetEntity_files"."assetId" AS "AssetEntity__AssetEntity_files_assetId", + "AssetEntity__AssetEntity_files"."createdAt" AS "AssetEntity__AssetEntity_files_createdAt", + "AssetEntity__AssetEntity_files"."updatedAt" AS "AssetEntity__AssetEntity_files_updatedAt", + "AssetEntity__AssetEntity_files"."type" AS "AssetEntity__AssetEntity_files_type", + "AssetEntity__AssetEntity_files"."path" AS "AssetEntity__AssetEntity_files_path" FROM "assets" "AssetEntity" LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id" @@ -248,6 +254,7 @@ FROM LEFT JOIN "person" "8258e303a73a72cf6abb13d73fb592dde0d68280" ON "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" = "AssetEntity__AssetEntity_faces"."personId" LEFT JOIN "asset_stack" "AssetEntity__AssetEntity_stack" ON "AssetEntity__AssetEntity_stack"."id" = "AssetEntity"."stackId" LEFT JOIN "assets" "bd93d5747511a4dad4923546c51365bf1a803774" ON "bd93d5747511a4dad4923546c51365bf1a803774"."stackId" = "AssetEntity__AssetEntity_stack"."id" + LEFT JOIN "asset_files" "AssetEntity__AssetEntity_files" ON "AssetEntity__AssetEntity_files"."assetId" = "AssetEntity"."id" WHERE (("AssetEntity"."id" IN ($1))) @@ -298,8 +305,6 @@ FROM "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -397,8 +402,6 @@ SELECT "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -452,8 +455,6 @@ SELECT "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -525,8 +526,6 @@ SELECT "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -581,8 +580,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -642,8 +639,6 @@ SELECT "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -723,8 +718,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -784,8 +777,6 @@ SELECT "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -841,8 +832,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -902,8 +891,6 @@ SELECT "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -1009,8 +996,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -1086,8 +1071,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -1148,3 +1131,130 @@ WHERE "asset"."isVisible" = true AND "asset"."ownerId" IN ($1) AND "asset"."updatedAt" > $2 + +-- AssetRepository.getAssetsByOriginalPath +SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "exifInfo"."assetId" AS "exifInfo_assetId", + "exifInfo"."description" AS "exifInfo_description", + "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", + "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight", + "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte", + "exifInfo"."orientation" AS "exifInfo_orientation", + "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal", + "exifInfo"."modifyDate" AS "exifInfo_modifyDate", + "exifInfo"."timeZone" AS "exifInfo_timeZone", + "exifInfo"."latitude" AS "exifInfo_latitude", + "exifInfo"."longitude" AS "exifInfo_longitude", + "exifInfo"."projectionType" AS "exifInfo_projectionType", + "exifInfo"."city" AS "exifInfo_city", + "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID", + "exifInfo"."autoStackId" AS "exifInfo_autoStackId", + "exifInfo"."state" AS "exifInfo_state", + "exifInfo"."country" AS "exifInfo_country", + "exifInfo"."make" AS "exifInfo_make", + "exifInfo"."model" AS "exifInfo_model", + "exifInfo"."lensModel" AS "exifInfo_lensModel", + "exifInfo"."fNumber" AS "exifInfo_fNumber", + "exifInfo"."focalLength" AS "exifInfo_focalLength", + "exifInfo"."iso" AS "exifInfo_iso", + "exifInfo"."exposureTime" AS "exifInfo_exposureTime", + "exifInfo"."profileDescription" AS "exifInfo_profileDescription", + "exifInfo"."colorspace" AS "exifInfo_colorspace", + "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", + "exifInfo"."fps" AS "exifInfo_fps", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" +FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" = $1 + AND ( + "asset"."originalPath" LIKE $2 + AND "asset"."originalPath" NOT LIKE $3 + ) +ORDER BY + regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC + +-- AssetRepository.upsertFile +INSERT INTO + "asset_files" ( + "id", + "assetId", + "createdAt", + "updatedAt", + "type", + "path" + ) +VALUES + (DEFAULT, $1, DEFAULT, DEFAULT, $2, $3) +ON CONFLICT ("assetId", "type") DO +UPDATE +SET + "assetId" = EXCLUDED."assetId", + "type" = EXCLUDED."type", + "path" = EXCLUDED."path", + "updatedAt" = DEFAULT +RETURNING + "id", + "createdAt", + "updatedAt" diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index bc20bf4bd3af9..5dd32ce365d9e 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -145,14 +145,3 @@ WHERE AND ("libraries"."deletedAt" IS NULL) GROUP BY "libraries"."id" - --- LibraryRepository.getAssetIds -SELECT - "assets"."id" AS "assets_id" -FROM - "libraries" "library" - INNER JOIN "assets" "assets" ON "assets"."libraryId" = "library"."id" - AND ("assets"."deletedAt" IS NULL) -WHERE - ("library"."id" = $1) - AND ("library"."deletedAt" IS NULL) diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 9b20b964d8eb3..9c94232d20857 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -157,8 +157,6 @@ FROM "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", - "AssetFaceEntity__AssetFaceEntity_asset"."previewPath" AS "AssetFaceEntity__AssetFaceEntity_asset_previewPath", - "AssetFaceEntity__AssetFaceEntity_asset"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbnailPath", "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt", @@ -255,8 +253,6 @@ FROM "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -386,8 +382,6 @@ SELECT "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", - "AssetFaceEntity__AssetFaceEntity_asset"."previewPath" AS "AssetFaceEntity__AssetFaceEntity_asset_previewPath", - "AssetFaceEntity__AssetFaceEntity_asset"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbnailPath", "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt", diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 390aedaf35017..e9e94400ad454 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -14,8 +14,6 @@ FROM "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -46,8 +44,6 @@ FROM "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -111,8 +107,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -143,8 +137,6 @@ SELECT "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -353,8 +345,6 @@ SELECT "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 2880e6896f506..10af8d17dbddb 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -28,8 +28,6 @@ FROM "SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId", "SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type", "SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath", - "SharedLinkEntity__SharedLinkEntity_assets"."previewPath" AS "SharedLinkEntity__SharedLinkEntity_assets_previewPath", - "SharedLinkEntity__SharedLinkEntity_assets"."thumbnailPath" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbnailPath", "SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash", "SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath", "SharedLinkEntity__SharedLinkEntity_assets"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_assets_createdAt", @@ -96,8 +94,6 @@ FROM "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."type" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_type", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalPath", - "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."previewPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_previewPath", - "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."thumbnailPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_thumbnailPath", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."thumbhash" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_thumbhash", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."encodedVideoPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_encodedVideoPath", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."createdAt" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_createdAt", @@ -218,8 +214,6 @@ SELECT "SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId", "SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type", "SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath", - "SharedLinkEntity__SharedLinkEntity_assets"."previewPath" AS "SharedLinkEntity__SharedLinkEntity_assets_previewPath", - "SharedLinkEntity__SharedLinkEntity_assets"."thumbnailPath" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbnailPath", "SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash", "SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath", "SharedLinkEntity__SharedLinkEntity_assets"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_assets_createdAt", diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql new file mode 100644 index 0000000000000..ba1aac82b356c --- /dev/null +++ b/server/src/queries/tag.repository.sql @@ -0,0 +1,30 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- TagRepository.getAssetIds +SELECT + "tag_asset"."assetsId" AS "assetId" +FROM + "tag_asset" "tag_asset" +WHERE + "tag_asset"."tagsId" = $1 + AND "tag_asset"."assetsId" IN ($2) + +-- TagRepository.addAssetIds +INSERT INTO + "tag_asset" ("assetsId", "tagsId") +VALUES + ($1, $2) + +-- TagRepository.removeAssetIds +DELETE FROM "tag_asset" +WHERE + ( + "tagsId" = $1 + AND "assetsId" IN ($2) + ) + +-- TagRepository.upsertAssetIds +INSERT INTO + "tag_asset" ("assetsId", "tagsId") +VALUES + ($1, $2) diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 438424ab78e60..f6921ffe27423 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -11,6 +11,8 @@ import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { StackEntity } from 'src/entities/stack.entity'; +import { TagEntity } from 'src/entities/tag.entity'; import { AlbumUserRole } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -20,10 +22,12 @@ type IActivityAccess = IAccessRepository['activity']; type IAlbumAccess = IAccessRepository['album']; type IAssetAccess = IAccessRepository['asset']; type IAuthDeviceAccess = IAccessRepository['authDevice']; -type ITimelineAccess = IAccessRepository['timeline']; type IMemoryAccess = IAccessRepository['memory']; type IPersonAccess = IAccessRepository['person']; type IPartnerAccess = IAccessRepository['partner']; +type IStackAccess = IAccessRepository['stack']; +type ITagAccess = IAccessRepository['tag']; +type ITimelineAccess = IAccessRepository['timeline']; @Instrumentation() @Injectable() @@ -313,6 +317,28 @@ class AuthDeviceAccess implements IAuthDeviceAccess { } } +class StackAccess implements IStackAccess { + constructor(private stackRepository: Repository) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, stackIds: Set): Promise> { + if (stackIds.size === 0) { + return new Set(); + } + + return this.stackRepository + .find({ + select: { id: true }, + where: { + id: In([...stackIds]), + ownerId: userId, + }, + }) + .then((stacks) => new Set(stacks.map((stack) => stack.id))); + } +} + class TimelineAccess implements ITimelineAccess { constructor(private partnerRepository: Repository) {} @@ -420,6 +446,28 @@ class PartnerAccess implements IPartnerAccess { } } +class TagAccess implements ITagAccess { + constructor(private tagRepository: Repository) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, tagIds: Set): Promise> { + if (tagIds.size === 0) { + return new Set(); + } + + return this.tagRepository + .find({ + select: { id: true }, + where: { + id: In([...tagIds]), + userId, + }, + }) + .then((tags) => new Set(tags.map((tag) => tag.id))); + } +} + export class AccessRepository implements IAccessRepository { activity: IActivityAccess; album: IAlbumAccess; @@ -428,6 +476,8 @@ export class AccessRepository implements IAccessRepository { memory: IMemoryAccess; person: IPersonAccess; partner: IPartnerAccess; + stack: IStackAccess; + tag: ITagAccess; timeline: ITimelineAccess; constructor( @@ -441,6 +491,8 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository, @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository, @InjectRepository(SessionEntity) sessionRepository: Repository, + @InjectRepository(StackEntity) stackRepository: Repository, + @InjectRepository(TagEntity) tagRepository: Repository, ) { this.activity = new ActivityAccess(activityRepository, albumRepository); this.album = new AlbumAccess(albumRepository, sharedLinkRepository); @@ -449,6 +501,8 @@ export class AccessRepository implements IAccessRepository { this.memory = new MemoryAccess(memoryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); this.partner = new PartnerAccess(partnerRepository); + this.stack = new StackAccess(stackRepository); + this.tag = new TagAccess(tagRepository); this.timeline = new TimelineAccess(partnerRepository); } } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 1029b8d8da81a..3763cccd53c5d 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,11 +1,12 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; -import { AssetOrder, AssetType } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetType } from 'src/enum'; import { AssetBuilderOptions, AssetCreate, @@ -59,6 +60,7 @@ const dateTrunc = (options: TimeBucketOptions) => export class AssetRepository implements IAssetRepository { constructor( @InjectRepository(AssetEntity) private repository: Repository, + @InjectRepository(AssetFileEntity) private fileRepository: Repository, @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, @InjectRepository(SmartInfoEntity) private smartInfoRepository: Repository, @@ -84,7 +86,6 @@ export class AssetRepository implements IAssetRepository { `entity.ownerId IN (:...ownerIds) AND entity.isVisible = true AND entity.isArchived = false - AND entity.previewPath IS NOT NULL AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`, { @@ -94,6 +95,7 @@ export class AssetRepository implements IAssetRepository { }, ) .leftJoinAndSelect('entity.exifInfo', 'exifInfo') + .leftJoinAndSelect('entity.files', 'files') .orderBy('entity.localDateTime', 'ASC') .getMany(); } @@ -128,6 +130,7 @@ export class AssetRepository implements IAssetRepository { stack: { assets: true, }, + files: true, }, withDeleted: true, }); @@ -214,7 +217,7 @@ export class AssetRepository implements IAssetRepository { } getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { - let builder = this.repository.createQueryBuilder('asset'); + let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files'); builder = searchAssetBuilder(builder, options); builder.orderBy('asset.createdAt', options.orderDirection ?? 'ASC'); return paginatedBuilder(builder, { @@ -360,12 +363,13 @@ export class AssetRepository implements IAssetRepository { } findLivePhotoMatch(options: LivePhotoSearchOptions): Promise { - const { ownerId, otherAssetId, livePhotoCID, type } = options; + const { ownerId, libraryId, otherAssetId, livePhotoCID, type } = options; return this.repository.findOne({ where: { id: Not(otherAssetId), ownerId, + libraryId: libraryId || IsNull(), type, exifInfo: { livePhotoCID, @@ -379,7 +383,7 @@ export class AssetRepository implements IAssetRepository { @GenerateSql( ...Object.values(WithProperty) - .filter((property) => property !== WithProperty.IS_OFFLINE) + .filter((property) => property !== WithProperty.IS_OFFLINE && property !== WithProperty.IS_ONLINE) .map((property) => ({ name: property, params: [DummyValue.PAGINATION, property], @@ -391,11 +395,10 @@ export class AssetRepository implements IAssetRepository { switch (property) { case WithoutProperty.THUMBNAIL: { + relations = { jobStatus: true, files: true }; where = [ - { previewPath: IsNull(), isVisible: true }, - { previewPath: '', isVisible: true }, - { thumbnailPath: IsNull(), isVisible: true }, - { thumbnailPath: '', isVisible: true }, + { jobStatus: { previewAt: IsNull() }, isVisible: true }, + { jobStatus: { thumbnailAt: IsNull() }, isVisible: true }, { thumbhash: IsNull(), isVisible: true }, ]; break; @@ -429,7 +432,7 @@ export class AssetRepository implements IAssetRepository { }; where = { isVisible: true, - previewPath: Not(IsNull()), + jobStatus: { previewAt: Not(IsNull()) }, smartSearch: { embedding: IsNull(), }, @@ -439,10 +442,10 @@ export class AssetRepository implements IAssetRepository { case WithoutProperty.DUPLICATE: { where = { - previewPath: Not(IsNull()), isVisible: true, smartSearch: true, jobStatus: { + previewAt: Not(IsNull()), duplicatesDetectedAt: IsNull(), }, }; @@ -454,7 +457,9 @@ export class AssetRepository implements IAssetRepository { smartInfo: true, }; where = { - previewPath: Not(IsNull()), + jobStatus: { + previewAt: Not(IsNull()), + }, isVisible: true, smartInfo: { tags: IsNull(), @@ -469,13 +474,13 @@ export class AssetRepository implements IAssetRepository { jobStatus: true, }; where = { - previewPath: Not(IsNull()), isVisible: true, faces: { assetId: IsNull(), personId: IsNull(), }, jobStatus: { + previewAt: Not(IsNull()), facesRecognizedAt: IsNull(), }, }; @@ -487,7 +492,9 @@ export class AssetRepository implements IAssetRepository { faces: true, }; where = { - previewPath: Not(IsNull()), + jobStatus: { + previewAt: Not(IsNull()), + }, isVisible: true, faces: { assetId: Not(IsNull()), @@ -520,7 +527,12 @@ export class AssetRepository implements IAssetRepository { }); } - getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated { + getWith( + pagination: PaginationOptions, + property: WithProperty, + libraryId?: string, + withDeleted = false, + ): Paginated { let where: FindOptionsWhere | FindOptionsWhere[] = {}; switch (property) { @@ -532,7 +544,14 @@ export class AssetRepository implements IAssetRepository { if (!libraryId) { throw new Error('Library id is required when finding offline assets'); } - where = [{ isOffline: true, libraryId: libraryId }]; + where = [{ isOffline: true, libraryId }]; + break; + } + case WithProperty.IS_ONLINE: { + if (!libraryId) { + throw new Error('Library id is required when finding online assets'); + } + where = [{ isOffline: false, libraryId }]; break; } @@ -543,6 +562,7 @@ export class AssetRepository implements IAssetRepository { return paginate(this.repository, pagination, { where, + withDeleted, order: { // Ensures correct order when paginating createdAt: 'ASC', @@ -704,10 +724,20 @@ export class AssetRepository implements IAssetRepository { private getBuilder(options: AssetBuilderOptions) { const builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true'); + if (options.assetType !== undefined) { builder.andWhere('asset.type = :assetType', { assetType: options.assetType }); } + if (options.tagId) { + builder.innerJoin( + 'asset.tags', + 'asset_tags', + 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)', + { tagId: options.tagId }, + ); + } + let stackJoined = false; if (options.exifInfo !== false) { @@ -809,4 +839,53 @@ export class AssetRepository implements IAssetRepository { .withDeleted(); return builder.getMany(); } + + async getUniqueOriginalPaths(userId: string): Promise { + const builder = this.getBuilder({ + userIds: [userId], + exifInfo: false, + withStacked: false, + isArchived: false, + isTrashed: false, + }); + + const results = await builder + .select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath') + .getRawMany(); + + return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + async getAssetsByOriginalPath(userId: string, partialPath: string): Promise { + const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, ''); + + const builder = this.getBuilder({ + userIds: [userId], + exifInfo: true, + withStacked: false, + isArchived: false, + isTrashed: false, + }); + + const assets = await builder + .where('asset.ownerId = :userId', { userId }) + .andWhere( + new Brackets((qb) => { + qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere( + 'asset.originalPath NOT LIKE :notLikePath', + { notLikePath: `%${normalizedPath}/%/%` }, + ); + }), + ) + .orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC') + .getMany(); + + return assets; + } + + @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) + async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise { + await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] }); + } } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 88834afc00273..f64e5175e5127 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -79,6 +79,7 @@ export const JOBS_TO_QUEUE: Record = { [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, + [JobName.LIBRARY_CHECK_OFFLINE]: QueueName.LIBRARY, [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY, diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index 963b0aaf73dfc..36fb4b921751b 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -94,30 +94,6 @@ export class LibraryRepository implements ILibraryRepository { }; } - @GenerateSql({ params: [DummyValue.UUID] }) - async getAssetIds(libraryId: string, withDeleted = false): Promise { - const builder = this.repository - .createQueryBuilder('library') - .innerJoinAndSelect('library.assets', 'assets') - .where('library.id = :id', { id: libraryId }) - .select('assets.id'); - - if (withDeleted) { - builder.withDeleted(); - } - - // Return all asset paths for a given library - const rawResults = await builder.getRawMany(); - - const results: string[] = []; - - for (const rawPath of rawResults) { - results.push(rawPath.assets_id); - } - - return results; - } - private async save(library: Partial) { const { id } = await this.repository.save(library); return this.repository.findOneByOrFail({ id }); diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 555f1042bbc0b..da4e30d47cbf8 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -317,7 +317,7 @@ export class MapRepository implements IMapRepository { } const input = createReadStream(filePath); - const lineReader = readLine.createInterface({ input: input }); + const lineReader = readLine.createInterface({ input }); const adminMap = new Map(); for await (const line of lineReader) { diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index ef6c8c2f39603..9814a7bd5e72f 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -33,10 +33,10 @@ export class NotificationRepository implements INotificationRepository { } } - renderEmail(request: EmailRenderRequest): { html: string; text: string } { + async renderEmail(request: EmailRenderRequest): Promise<{ html: string; text: string }> { const component = this.render(request); - const html = render(component, { pretty: true }); - const text = render(component, { plainText: true }); + const html = await render(component, { pretty: true }); + const text = await render(component, { plainText: true }); return { html, text }; } diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index 46cc14e713d1f..f23a1c9a9c10f 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -1,21 +1,120 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { AssetEntity } from 'src/entities/asset.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { IStackRepository } from 'src/interfaces/stack.interface'; +import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; +import { DataSource, In, Repository } from 'typeorm'; @Instrumentation() @Injectable() export class StackRepository implements IStackRepository { - constructor(@InjectRepository(StackEntity) private repository: Repository) {} + constructor( + @InjectDataSource() private dataSource: DataSource, + @InjectRepository(StackEntity) private repository: Repository, + ) {} - create(entity: Partial) { - return this.save(entity); + search(query: StackSearch): Promise { + return this.repository.find({ + where: { + ownerId: query.ownerId, + primaryAssetId: query.primaryAssetId, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + } + + async create(entity: { ownerId: string; assetIds: string[] }): Promise { + return this.dataSource.manager.transaction(async (manager) => { + const stackRepository = manager.getRepository(StackEntity); + + const stacks = await stackRepository.find({ + where: { + ownerId: entity.ownerId, + primaryAssetId: In(entity.assetIds), + }, + select: { + id: true, + assets: { + id: true, + }, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + + const assetIds = new Set(entity.assetIds); + + // children + for (const stack of stacks) { + for (const asset of stack.assets) { + assetIds.add(asset.id); + } + } + + if (stacks.length > 0) { + await stackRepository.delete({ id: In(stacks.map((stack) => stack.id)) }); + } + + const { id } = await stackRepository.save({ + ownerId: entity.ownerId, + primaryAssetId: entity.assetIds[0], + assets: [...assetIds].map((id) => ({ id }) as AssetEntity), + }); + + return stackRepository.findOneOrFail({ + where: { + id, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + }); } async delete(id: string): Promise { + const stack = await this.getById(id); + if (!stack) { + return; + } + + const assetIds = stack.assets.map(({ id }) => id); + await this.repository.delete(id); + + // Update assets updatedAt + await this.dataSource.manager.update(AssetEntity, assetIds, { + updatedAt: new Date(), + }); + } + + async deleteAll(ids: string[]): Promise { + const assetIds = []; + for (const id of ids) { + const stack = await this.getById(id); + if (!stack) { + continue; + } + + assetIds.push(...stack.assets.map(({ id }) => id)); + } + + await this.repository.delete(ids); + + // Update assets updatedAt + await this.dataSource.manager.update(AssetEntity, assetIds, { + updatedAt: new Date(), + }); } update(entity: Partial) { @@ -28,8 +127,14 @@ export class StackRepository implements IStackRepository { id, }, relations: { - primaryAsset: true, - assets: true, + assets: { + exifInfo: true, + }, + }, + order: { + assets: { + fileCreatedAt: 'ASC', + }, }, }); } @@ -41,8 +146,14 @@ export class StackRepository implements IStackRepository { id, }, relations: { - primaryAsset: true, - assets: true, + assets: { + exifInfo: true, + }, + }, + order: { + assets: { + fileCreatedAt: 'ASC', + }, }, }); } diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index b310f2e1100aa..c699047ce1575 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -5,7 +5,7 @@ import { escapePath, glob, globStream } from 'fast-glob'; import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { CrawlOptionsDto } from 'src/dtos/library.dto'; +import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DiskUsage, @@ -157,8 +157,8 @@ export class StorageRepository implements IStorageRepository { }); } - async *walk(crawlOptions: CrawlOptionsDto): AsyncGenerator { - const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions; + async *walk(walkOptions: WalkOptionsDto): AsyncGenerator { + const { pathsToCrawl, exclusionPatterns, includeHidden } = walkOptions; if (pathsToCrawl.length === 0) { async function* emptyGenerator() {} return emptyGenerator(); @@ -172,8 +172,17 @@ export class StorageRepository implements IStorageRepository { ignore: exclusionPatterns, }); + let batch: string[] = []; for await (const value of stream) { - yield value as string; + batch.push(value.toString()); + if (batch.length === walkOptions.take) { + yield batch; + batch = []; + } + } + + if (batch.length > 0) { + yield batch; } } diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 788b9763578e2..9389aeb13b4e3 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -1,33 +1,78 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { AssetEntity } from 'src/entities/asset.entity'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { TagEntity } from 'src/entities/tag.entity'; -import { ITagRepository } from 'src/interfaces/tag.interface'; +import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; +import { DataSource, In, Repository } from 'typeorm'; @Instrumentation() @Injectable() export class TagRepository implements ITagRepository { constructor( - @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectDataSource() private dataSource: DataSource, @InjectRepository(TagEntity) private repository: Repository, ) {} - getById(userId: string, id: string): Promise { - return this.repository.findOne({ - where: { - id, - userId, - }, - relations: { - user: true, - }, + get(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + getByValue(userId: string, value: string): Promise { + return this.repository.findOne({ where: { userId, value } }); + } + + async upsertValue({ + userId, + value, + parent, + }: { + userId: string; + value: string; + parent?: TagEntity; + }): Promise { + return this.dataSource.transaction(async (manager) => { + // upsert tag + const { identifiers } = await manager.upsert( + TagEntity, + { userId, value, parentId: parent?.id }, + { conflictPaths: { userId: true, value: true } }, + ); + const id = identifiers[0]?.id; + if (!id) { + throw new Error('Failed to upsert tag'); + } + + // update closure table + await manager.query( + `INSERT INTO tags_closure (id_ancestor, id_descendant) + VALUES ($1, $1) + ON CONFLICT DO NOTHING;`, + [id], + ); + + if (parent) { + await manager.query( + `INSERT INTO tags_closure (id_ancestor, id_descendant) + SELECT id_ancestor, '${id}' as id_descendant FROM tags_closure WHERE id_descendant = $1 + ON CONFLICT DO NOTHING`, + [parent.id], + ); + } + + return manager.findOneOrFail(TagEntity, { where: { id } }); }); } - getAll(userId: string): Promise { - return this.repository.find({ where: { userId } }); + async getAll(userId: string): Promise { + const tags = await this.repository.find({ + where: { userId }, + order: { + value: 'ASC', + }, + }); + + return tags; } create(tag: Partial): Promise { @@ -38,89 +83,99 @@ export class TagRepository implements ITagRepository { return this.save(tag); } - async remove(tag: TagEntity): Promise { - await this.repository.remove(tag); + async delete(id: string): Promise { + await this.repository.delete(id); } - async getAssets(userId: string, tagId: string): Promise { - return this.assetRepository.find({ - where: { - tags: { - userId, - id: tagId, - }, - }, - relations: { - exifInfo: true, - tags: true, - faces: { - person: true, - }, - }, - order: { - createdAt: 'ASC', - }, - }); - } - - async addAssets(userId: string, id: string, assetIds: string[]): Promise { - for (const assetId of assetIds) { - const asset = await this.assetRepository.findOneOrFail({ - where: { - ownerId: userId, - id: assetId, - }, - relations: { - tags: true, - }, - }); - asset.tags.push({ id } as TagEntity); - await this.assetRepository.save(asset); + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @ChunkedSet({ paramIndex: 1 }) + async getAssetIds(tagId: string, assetIds: string[]): Promise> { + if (assetIds.length === 0) { + return new Set(); } + + const results = await this.dataSource + .createQueryBuilder() + .select('tag_asset.assetsId', 'assetId') + .from('tag_asset', 'tag_asset') + .where('"tag_asset"."tagsId" = :tagId', { tagId }) + .andWhere('"tag_asset"."assetsId" IN (:...assetIds)', { assetIds }) + .getRawMany<{ assetId: string }>(); + + return new Set(results.map(({ assetId }) => assetId)); } - async removeAssets(userId: string, id: string, assetIds: string[]): Promise { - for (const assetId of assetIds) { - const asset = await this.assetRepository.findOneOrFail({ - where: { - ownerId: userId, - id: assetId, - }, - relations: { - tags: true, - }, - }); - asset.tags = asset.tags.filter((tag) => tag.id !== id); - await this.assetRepository.save(asset); + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + async addAssetIds(tagId: string, assetIds: string[]): Promise { + if (assetIds.length === 0) { + return; } + + await this.dataSource.manager + .createQueryBuilder() + .insert() + .into('tag_asset', ['tagsId', 'assetsId']) + .values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId }))) + .execute(); } - hasAsset(userId: string, tagId: string, assetId: string): Promise { - return this.repository.exists({ - where: { - id: tagId, - userId, - assets: { - id: assetId, - }, - }, - relations: { - assets: true, - }, + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @Chunked({ paramIndex: 1 }) + async removeAssetIds(tagId: string, assetIds: string[]): Promise { + if (assetIds.length === 0) { + return; + } + + await this.dataSource + .createQueryBuilder() + .delete() + .from('tag_asset') + .where({ + tagsId: tagId, + assetsId: In(assetIds), + }) + .execute(); + } + + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagId: DummyValue.UUID }]] }) + @Chunked() + async upsertAssetIds(items: AssetTagItem[]): Promise { + if (items.length === 0) { + return []; + } + + const { identifiers } = await this.dataSource + .createQueryBuilder() + .insert() + .into('tag_asset', ['assetsId', 'tagsId']) + .values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId }))) + .execute(); + + return (identifiers as Array<{ assetsId: string; tagsId: string }>).map(({ assetsId, tagsId }) => ({ + assetId: assetsId, + tagId: tagsId, + })); + } + + async upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }) { + await this.dataSource.transaction(async (manager) => { + await manager.createQueryBuilder().delete().from('tag_asset').where({ assetsId: assetId }).execute(); + + if (tagIds.length === 0) { + return; + } + + await manager + .createQueryBuilder() + .insert() + .into('tag_asset', ['tagsId', 'assetsId']) + .values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId }))) + .execute(); }); } - hasName(userId: string, name: string): Promise { - return this.repository.exists({ - where: { - name, - userId, - }, - }); - } - - private async save(tag: Partial): Promise { - const { id } = await this.repository.save(tag); - return this.repository.findOneOrFail({ where: { id }, relations: { user: true } }); + private async save(partial: Partial): Promise { + const { id } = await this.repository.save(partial); + return this.repository.findOneOrFail({ where: { id } }); } } diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index c1b2e1b4d0e51..1e4034de936fa 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { ActivityCreateDto, ActivityDto, @@ -16,20 +15,17 @@ import { ActivityEntity } from 'src/entities/activity.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IActivityRepository } from 'src/interfaces/activity.interface'; +import { requireAccess } from 'src/utils/access'; @Injectable() export class ActivityService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IActivityRepository) private repository: IActivityRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_READ, dto.albumId); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); const activities = await this.repository.search({ userId: dto.userId, albumId: dto.albumId, @@ -41,12 +37,12 @@ export class ActivityService { } async getStatistics(auth: AuthDto, dto: ActivityDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_READ, dto.albumId); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) }; } async create(auth: AuthDto, dto: ActivityCreateDto): Promise> { - await this.access.requirePermission(auth, Permission.ACTIVITY_CREATE, dto.albumId); + await requireAccess(this.access, { auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] }); const common = { userId: auth.user.id, @@ -80,7 +76,7 @@ export class ActivityService { } async delete(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ACTIVITY_DELETE, id); + await requireAccess(this.access, { auth, permission: Permission.ACTIVITY_DELETE, ids: [id] }); await this.repository.delete(id); } } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 6db39328df53e..164e823336878 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -43,12 +43,12 @@ describe(AlbumService.name, () => { expect(sut).toBeDefined(); }); - describe('getCount', () => { + describe('getStatistics', () => { it('should get the album count', async () => { albumMock.getOwned.mockResolvedValue([]); albumMock.getShared.mockResolvedValue([]); albumMock.getNotShared.mockResolvedValue([]); - await expect(sut.getCount(authStub.admin)).resolves.toEqual({ + await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({ owned: 0, shared: 0, notShared: 0, @@ -205,6 +205,10 @@ describe(AlbumService.name, () => { expect(userMock.get).toHaveBeenCalledWith('user-id', {}); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); + expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { + id: albumStub.empty.id, + userId: 'user-id', + }); }); it('should require valid userIds', async () => { @@ -380,7 +384,7 @@ describe(AlbumService.name, () => { userId: authStub.user2.user.id, albumId: albumStub.sharedWithAdmin.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInvite', { + expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.sharedWithAdmin.id, userId: userStub.user2.id, }); @@ -568,7 +572,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', { + expect(eventMock.emit).toHaveBeenCalledWith('album.update', { id: 'album-123', updatedBy: authStub.admin.user.id, }); @@ -612,7 +616,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', { + expect(eventMock.emit).toHaveBeenCalledWith('album.update', { id: 'album-123', updatedBy: authStub.user1.user.id, }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 71594d20b6178..b59364af9fb6e 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -1,10 +1,9 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { AddUsersDto, - AlbumCountResponseDto, AlbumInfoDto, AlbumResponseDto, + AlbumStatisticsResponseDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto, @@ -24,23 +23,21 @@ import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfa import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() export class AlbumService { - private access: AccessCore; constructor( - @Inject(IAccessRepository) private accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} - async getCount(auth: AuthDto): Promise { + async getStatistics(auth: AuthDto): Promise { const [owned, shared, notShared] = await Promise.all([ this.albumRepository.getOwned(auth.user.id), this.albumRepository.getShared(auth.user.id), @@ -102,7 +99,7 @@ export class AlbumService { } async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_READ, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [id] }); await this.albumRepository.updateThumbnails(); const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets; const album = await this.findOrFail(id, { withAssets }); @@ -126,7 +123,11 @@ export class AlbumService { } } - const allowedAssetIdsSet = await this.access.checkAccess(auth, Permission.ASSET_SHARE, new Set(dto.assetIds)); + const allowedAssetIdsSet = await checkAccess(this.access, { + auth, + permission: Permission.ASSET_SHARE, + ids: dto.assetIds || [], + }); const assets = [...allowedAssetIdsSet].map((id) => ({ id }) as AssetEntity); const album = await this.albumRepository.create({ @@ -138,11 +139,15 @@ export class AlbumService { albumThumbnailAssetId: assets[0]?.id || null, }); + for (const { userId } of albumUsers) { + await this.eventRepository.emit('album.invite', { id: album.id, userId }); + } + return mapAlbumWithAssets(album); } async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_UPDATE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: true }); @@ -165,17 +170,17 @@ export class AlbumService { } async delete(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_DELETE, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_DELETE, ids: [id] }); await this.albumRepository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { const album = await this.findOrFail(id, { withAssets: false }); - await this.access.requirePermission(auth, Permission.ALBUM_ADD_ASSET, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] }); const results = await addAssets( auth, - { accessRepository: this.accessRepository, repository: this.albumRepository }, + { access: this.access, bulk: this.albumRepository }, { parentId: id, assetIds: dto.ids }, ); @@ -187,19 +192,19 @@ export class AlbumService { albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, }); - await this.eventRepository.emit('onAlbumUpdate', { id, updatedBy: auth.user.id }); + await this.eventRepository.emit('album.update', { id, updatedBy: auth.user.id }); } return results; } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_REMOVE_ASSET, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); const results = await removeAssets( auth, - { accessRepository: this.accessRepository, repository: this.albumRepository }, + { access: this.access, bulk: this.albumRepository }, { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.ALBUM_DELETE }, ); @@ -215,7 +220,7 @@ export class AlbumService { } async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); @@ -234,8 +239,8 @@ export class AlbumService { throw new BadRequestException('User not found'); } - await this.albumUserRepository.create({ userId: userId, albumId: id, role }); - await this.eventRepository.emit('onAlbumInvite', { id, userId }); + await this.albumUserRepository.create({ userId, albumId: id, role }); + await this.eventRepository.emit('album.invite', { id, userId }); } return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets); @@ -259,15 +264,14 @@ export class AlbumService { // non-admin can remove themselves if (auth.user.id !== userId) { - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); } await this.albumUserRepository.delete({ albumId: id, userId }); } async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); - + await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); } diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 978f98cf10f8b..2f5192d84fcf6 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException, NotFoundException, UnauthorizedException } from '@ import { Stats } from 'node:fs'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { AssetType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; @@ -150,15 +151,14 @@ const assetEntity = Object.freeze({ deviceId: 'device_id_1', type: AssetType.VIDEO, originalPath: 'fake_path/asset_1.jpeg', - previewPath: '', fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, isArchived: false, - thumbnailPath: '', encodedVideoPath: '', duration: '0:00:00.000000', + files: [] as AssetFileEntity[], exifInfo: { latitude: 49.533_547, longitude: 10.703_075, @@ -418,7 +418,7 @@ describe(AssetMediaService.name, () => { await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); - expect(assetMock.getById).toHaveBeenCalledWith('asset-1'); + expect(assetMock.getById).toHaveBeenCalledWith('asset-1', { files: true }); }); it('should download a file', async () => { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index b8a43b34ec224..9ce2e58d28fad 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -7,7 +7,6 @@ import { } from '@nestjs/common'; import { extname } from 'node:path'; import sanitize from 'sanitize-filename'; -import { AccessCore } from 'src/cores/access.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { AssetBulkUploadCheckResponseDto, @@ -36,6 +35,8 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { requireAccess, requireUploadAccess } from 'src/utils/access'; +import { getAssetFiles } from 'src/utils/asset.util'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; @@ -56,10 +57,8 @@ export interface UploadFile { @Injectable() export class AssetMediaService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @@ -68,7 +67,6 @@ export class AssetMediaService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(AssetMediaService.name); - this.access = AccessCore.create(accessRepository); } async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { @@ -85,7 +83,7 @@ export class AssetMediaService { } canUploadFile({ auth, fieldName, file }: UploadRequest): true { - this.access.requireUploadAccess(auth); + requireUploadAccess(auth); const filename = file.originalName; @@ -117,7 +115,7 @@ export class AssetMediaService { } getUploadFilename({ auth, fieldName, file }: UploadRequest): string { - this.access.requireUploadAccess(auth); + requireUploadAccess(auth); const originalExtension = extname(file.originalName); @@ -131,7 +129,7 @@ export class AssetMediaService { } getUploadFolder({ auth, fieldName, file }: UploadRequest): string { - auth = this.access.requireUploadAccess(auth); + auth = requireUploadAccess(auth); let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid); if (fieldName === UploadFieldName.PROFILE_DATA) { @@ -150,12 +148,12 @@ export class AssetMediaService { sidecarFile?: UploadFile, ): Promise { try { - await this.access.requirePermission( + await requireAccess(this.access, { auth, - Permission.ASSET_UPLOAD, + permission: Permission.ASSET_UPLOAD, // do not need an id here, but the interface requires it - auth.user.id, - ); + ids: [auth.user.id], + }); this.requireQuota(auth, file.size); @@ -194,7 +192,7 @@ export class AssetMediaService { sidecarFile?: UploadFile, ): Promise { try { - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const asset = (await this.assetRepository.getById(id)) as AssetEntity; this.requireQuota(auth, file.size); @@ -218,7 +216,7 @@ export class AssetMediaService { } async downloadOriginal(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); const asset = await this.findOrFail(id); if (!asset) { @@ -233,14 +231,15 @@ export class AssetMediaService { } async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_VIEW, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; - let filepath = asset.previewPath; - if (size === AssetMediaSize.THUMBNAIL && asset.thumbnailPath) { - filepath = asset.thumbnailPath; + const { thumbnailFile, previewFile } = getAssetFiles(asset.files); + let filepath = previewFile?.path; + if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) { + filepath = thumbnailFile.path; } if (!filepath) { @@ -255,7 +254,7 @@ export class AssetMediaService { } async playbackVideo(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_VIEW, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); if (!asset) { @@ -460,7 +459,7 @@ export class AssetMediaService { } private async findOrFail(id: string): Promise { - const asset = await this.assetRepository.getById(id); + const asset = await this.assetRepository.getById(id, { files: true }); if (!asset) { throw new NotFoundException('Asset not found'); } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 95a80ab4da622..3ac7aa1c718f7 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -4,7 +4,7 @@ import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetType } from 'src/enum'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; @@ -12,7 +12,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AssetService } from 'src/services/asset.service'; -import { assetStub, stackStub } from 'test/fixtures/asset.stub'; +import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; @@ -253,134 +253,6 @@ describe(AssetService.name, () => { await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); - - /// Stack related - - it('should require asset update access for parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - await expect( - sut.updateAll(authStub.user1, { - ids: ['asset-1'], - stackParentId: 'parent', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should update parent asset updatedAt when children are added', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['parent'])); - mockGetById([{ ...assetStub.image, id: 'parent' }]); - await sut.updateAll(authStub.user1, { - ids: [], - stackParentId: 'parent', - }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { updatedAt: expect.any(Date) }); - }); - - it('should update parent asset when children are removed', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1'])); - assetMock.getByIds.mockResolvedValue([ - { - id: 'child-1', - stackId: 'stack-1', - stack: stackStub('stack-1', [{ id: 'parent' } as AssetEntity, { id: 'child-1' } as AssetEntity]), - } as AssetEntity, - ]); - stackMock.getById.mockResolvedValue(stackStub('stack-1', [{ id: 'parent' } as AssetEntity])); - - await sut.updateAll(authStub.user1, { - ids: ['child-1'], - removeParent: true, - }); - expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['child-1']), { stack: null }); - expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { - updatedAt: expect.any(Date), - }); - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); - }); - - it('update parentId for new children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1', 'child-2'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - const stack = stackStub('stack-1', [ - { id: 'parent' } as AssetEntity, - { id: 'child-1' } as AssetEntity, - { id: 'child-2' } as AssetEntity, - ]); - assetMock.getById.mockResolvedValue({ - id: 'child-1', - stack, - } as AssetEntity); - - await sut.updateAll(authStub.user1, { - stackParentId: 'parent', - ids: ['child-1', 'child-2'], - }); - - expect(stackMock.update).toHaveBeenCalledWith({ - ...stackStub('stack-1', [ - { id: 'child-1' } as AssetEntity, - { id: 'child-2' } as AssetEntity, - { id: 'parent' } as AssetEntity, - ]), - primaryAsset: undefined, - }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2', 'parent'], { updatedAt: expect.any(Date) }); - }); - - it('remove stack for removed children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1', 'child-2'])); - await sut.updateAll(authStub.user1, { - removeParent: true, - ids: ['child-1', 'child-2'], - }); - - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stack: null }); - }); - - it('merge stacks if new child has children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, id: 'parent' }); - assetMock.getByIds.mockResolvedValue([ - { - id: 'child-1', - stackId: 'stack-1', - stack: stackStub('stack-1', [{ id: 'child-1' } as AssetEntity, { id: 'child-2' } as AssetEntity]), - } as AssetEntity, - ]); - stackMock.getById.mockResolvedValue(stackStub('stack-1', [{ id: 'parent' } as AssetEntity])); - - await sut.updateAll(authStub.user1, { - ids: ['child-1'], - stackParentId: 'parent', - }); - - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); - expect(stackMock.create).toHaveBeenCalledWith({ - assets: [{ id: 'child-1' }, { id: 'parent' }, { id: 'child-1' }, { id: 'child-2' }], - ownerId: 'user-id', - primaryAssetId: 'parent', - }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'parent', 'child-1', 'child-2'], { - updatedAt: expect.any(Date), - }); - }); - - it('should send ws asset update event', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - assetMock.getById.mockResolvedValue(assetStub.image); - - await sut.updateAll(authStub.user1, { - ids: ['asset-1'], - stackParentId: 'parent', - }); - - expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_STACK_UPDATE, authStub.user1.user.id, [ - 'asset-1', - 'parent', - ]); - }); }); describe('deleteAll', () => { @@ -427,8 +299,8 @@ describe(AssetService.name, () => { name: JobName.DELETE_FILES, data: { files: [ - assetWithFace.thumbnailPath, - assetWithFace.previewPath, + '/uploads/user-id/webp/path.ext', + '/uploads/user-id/thumbs/path.jpg', assetWithFace.encodedVideoPath, assetWithFace.sidecarPath, assetWithFace.originalPath, @@ -530,53 +402,17 @@ describe(AssetService.name, () => { }); }); - describe('updateStackParent', () => { - it('should require asset update access for new parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['old'])); - await expect( - sut.updateStackParent(authStub.user1, { - oldParentId: 'old', - newParentId: 'new', - }), - ).rejects.toBeInstanceOf(BadRequestException); + describe('getUserAssetsByDeviceId', () => { + it('get assets by device id', async () => { + const assets = [assetStub.image, assetStub.image1]; + + assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); + + const deviceId = 'device-id'; + const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); + + expect(result.length).toEqual(2); + expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); }); - - it('should require asset read access for old parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['new'])); - await expect( - sut.updateStackParent(authStub.user1, { - oldParentId: 'old', - newParentId: 'new', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('make old parent the child of new parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.image.id])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new'])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, stackId: 'stack-1' }); - - await sut.updateStackParent(authStub.user1, { - oldParentId: assetStub.image.id, - newParentId: 'new', - }); - - expect(stackMock.update).toBeCalledWith({ id: 'stack-1', primaryAssetId: 'new' }); - expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id, 'new', assetStub.image.id], { - updatedAt: expect.any(Date), - }); - }); - }); - - it('get assets by device id', async () => { - const assets = [assetStub.image, assetStub.image1]; - - assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); - - const deviceId = 'device-id'; - const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); - - expect(result.length).toEqual(2); - expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); }); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index bbbc2bb40758d..bfd3a0c4d26b5 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Inject } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; -import { AccessCore } from 'src/cores/access.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, @@ -20,7 +19,6 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; -import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; @@ -40,15 +38,15 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { getMyPartnerIds } from 'src/utils/asset.util'; +import { requireAccess } from 'src/utils/access'; +import { getAssetFiles, getMyPartnerIds } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; export class AssetService { - private access: AccessCore; private configCore: SystemConfigCore; constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @@ -59,7 +57,6 @@ export class AssetService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(AssetService.name); - this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @@ -72,9 +69,10 @@ export class AssetService { const userIds = [auth.user.id, ...partnerIds]; const assets = await this.assetRepository.getByDayOfYear(userIds, dto); + const assetsWithThumbnails = assets.filter(({ files }) => !!getAssetFiles(files).thumbnailFile); const groups: Record = {}; const currentYear = new Date().getFullYear(); - for (const asset of assets) { + for (const asset of assetsWithThumbnails) { const yearsAgo = currentYear - asset.localDateTime.getFullYear(); if (!groups[yearsAgo]) { groups[yearsAgo] = []; @@ -109,7 +107,7 @@ export class AssetService { } async get(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_READ, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [id] }); const asset = await this.assetRepository.getById( id, @@ -127,6 +125,7 @@ export class AssetService { exifInfo: true, }, }, + files: true, }, { faces: { @@ -157,7 +156,7 @@ export class AssetService { } async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); @@ -171,6 +170,7 @@ export class AssetService { faces: { person: true, }, + files: true, }); if (!asset) { throw new BadRequestException('Asset not found'); @@ -179,68 +179,14 @@ export class AssetService { } async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { - const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto; - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, ids); - - // TODO: refactor this logic into separate API calls POST /stack, PUT /stack, etc. - const stackIdsToCheckForDelete: string[] = []; - if (removeParent) { - (options as Partial).stack = null; - const assets = await this.assetRepository.getByIds(ids, { stack: true }); - stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!))); - // This updates the updatedAt column of the parents to indicate that one of its children is removed - // All the unique parent's -> parent is set to null - await this.assetRepository.updateAll( - assets.filter((a) => !!a.stack?.primaryAssetId).map((a) => a.stack!.primaryAssetId!), - { updatedAt: new Date() }, - ); - } else if (options.stackParentId) { - //Creating new stack if parent doesn't have one already. If it does, then we add to the existing stack - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, options.stackParentId); - const primaryAsset = await this.assetRepository.getById(options.stackParentId, { stack: { assets: true } }); - if (!primaryAsset) { - throw new BadRequestException('Asset not found for given stackParentId'); - } - let stack = primaryAsset.stack; - - ids.push(options.stackParentId); - const assets = await this.assetRepository.getByIds(ids, { stack: { assets: true } }); - stackIdsToCheckForDelete.push( - ...new Set(assets.filter((a) => !!a.stackId && stack?.id !== a.stackId).map((a) => a.stackId!)), - ); - const assetsWithChildren = assets.filter((a) => a.stack && a.stack.assets.length > 0); - ids.push(...assetsWithChildren.flatMap((child) => child.stack!.assets.map((gChild) => gChild.id))); - - if (stack) { - await this.stackRepository.update({ - id: stack.id, - primaryAssetId: primaryAsset.id, - assets: ids.map((id) => ({ id }) as AssetEntity), - }); - } else { - stack = await this.stackRepository.create({ - primaryAssetId: primaryAsset.id, - ownerId: primaryAsset.ownerId, - assets: ids.map((id) => ({ id }) as AssetEntity), - }); - } - - // Merge stacks - options.stackParentId = undefined; - (options as Partial).updatedAt = new Date(); - } + const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; + await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids }); for (const id of ids) { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); } await this.assetRepository.updateAll(ids, options); - const stackIdsToDelete = await Promise.all(stackIdsToCheckForDelete.map((id) => this.stackRepository.getById(id))); - const stacksToDelete = stackIdsToDelete - .flatMap((stack) => (stack ? [stack] : [])) - .filter((stack) => stack.assets.length < 2); - await Promise.all(stacksToDelete.map((as) => this.stackRepository.delete(as.id))); - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, ids); } async handleAssetDeletionCheck(): Promise { @@ -278,6 +224,7 @@ export class AssetService { library: true, stack: { assets: true }, exifInfo: true, + files: true, }); if (!asset) { @@ -315,7 +262,8 @@ export class AssetService { } } - const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath]; + const { thumbnailFile, previewFile } = getAssetFiles(asset.files); + const files = [thumbnailFile?.path, previewFile?.path, asset.encodedVideoPath]; if (deleteOnDisk) { files.push(asset.sidecarPath, asset.originalPath); } @@ -328,7 +276,7 @@ export class AssetService { async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise { const { ids, force } = dto; - await this.access.requirePermission(auth, Permission.ASSET_DELETE, ids); + await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids }); if (force) { await this.jobRepository.queueAll( @@ -343,43 +291,8 @@ export class AssetService { } } - async updateStackParent(auth: AuthDto, dto: UpdateStackParentDto): Promise { - const { oldParentId, newParentId } = dto; - await this.access.requirePermission(auth, Permission.ASSET_READ, oldParentId); - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, newParentId); - - const childIds: string[] = []; - const oldParent = await this.assetRepository.getById(oldParentId, { - faces: { - person: true, - }, - library: true, - stack: { - assets: true, - }, - }); - if (!oldParent?.stackId) { - throw new Error('Asset not found or not in a stack'); - } - if (oldParent != null) { - // Get all children of old parent - childIds.push(oldParent.id, ...(oldParent.stack?.assets.map((a) => a.id) ?? [])); - } - await this.stackRepository.update({ - id: oldParent.stackId, - primaryAssetId: newParentId, - }); - - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, [ - ...childIds, - newParentId, - oldParentId, - ]); - await this.assetRepository.updateAll([oldParentId, newParentId, ...childIds], { updatedAt: new Date() }); - } - async run(auth: AuthDto, dto: AssetJobsDto) { - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds); + await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); const jobs: JobItem[] = []; diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 225bd1106176a..72db2b6eb56ce 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { resolve } from 'node:path'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { AccessCore } from 'src/cores/access.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { AuditDeletesDto, @@ -14,7 +13,7 @@ import { } from 'src/dtos/audit.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetPathType, PersonPathType, UserPathType } from 'src/entities/move.entity'; -import { DatabaseAction, Permission } from 'src/enum'; +import { AssetFileType, DatabaseAction, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; @@ -24,14 +23,14 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { requireAccess } from 'src/utils/access'; +import { getAssetFiles } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; @Injectable() export class AuditService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, @@ -40,7 +39,6 @@ export class AuditService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { - this.access = AccessCore.create(accessRepository); this.logger.setContext(AuditService.name); } @@ -51,7 +49,7 @@ export class AuditService { async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise { const userId = dto.userId || auth.user.id; - await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); + await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); const audits = await this.repository.getAfter(dto.after, { userIds: [userId], @@ -97,12 +95,12 @@ export class AuditService { } case AssetPathType.PREVIEW: { - await this.assetRepository.update({ id, previewPath: pathValue }); + await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: pathValue }); break; } case AssetPathType.THUMBNAIL: { - await this.assetRepository.update({ id, thumbnailPath: pathValue }); + await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: pathValue }); break; } @@ -155,7 +153,7 @@ export class AuditService { } } - const track = (filename: string | null) => { + const track = (filename: string | null | undefined) => { if (!filename) { return; } @@ -175,8 +173,9 @@ export class AuditService { const orphans: FileReportItemDto[] = []; for await (const assets of pagination) { assetCount += assets.length; - for (const { id, originalPath, previewPath, encodedVideoPath, thumbnailPath, isExternal, checksum } of assets) { - for (const file of [originalPath, previewPath, encodedVideoPath, thumbnailPath]) { + for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) { + const { previewFile, thumbnailFile } = getAssetFiles(files); + for (const file of [originalPath, previewFile?.path, encodedVideoPath, thumbnailFile?.path]) { track(file); } @@ -192,11 +191,11 @@ export class AuditService { ) { orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath }); } - if (previewPath && !hasFile(thumbFiles, previewPath)) { - orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewPath }); + if (previewFile && !hasFile(thumbFiles, previewFile.path)) { + orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewFile.path }); } - if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) { - orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailPath }); + if (thumbnailFile && !hasFile(thumbFiles, thumbnailFile.path)) { + orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailFile.path }); } if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) { orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: encodedVideoPath }); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index ed73c5aa00256..f2fa0c520a30f 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -46,7 +46,7 @@ const fixtures = { }; const oauthUserWithDefaultQuota = { - email: email, + email, name: ' ', oauthId: sub, quotaSizeInBytes: 1_073_741_824, @@ -423,11 +423,13 @@ describe('AuthService', () => { describe('getMobileRedirect', () => { it('should pass along the query params', () => { - expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456'); + expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual( + 'app.immich:///oauth-callback?code=123&state=456', + ); }); it('should work if called without query params', () => { - expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?'); + expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:///oauth-callback?'); }); }); @@ -488,25 +490,23 @@ describe('AuthService', () => { expect(userMock.create).toHaveBeenCalledTimes(1); }); - it('should use the mobile redirect override', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); - userMock.getByOAuthId.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + for (const url of [ + 'app.immich:/', + 'app.immich://', + 'app.immich:///', + 'app.immich:/oauth-callback?code=abc123', + 'app.immich://oauth-callback?code=abc123', + 'app.immich:///oauth-callback?code=abc123', + ]) { + it(`should use the mobile redirect override for a url of ${url}`, async () => { + systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); + userMock.getByOAuthId.mockResolvedValue(userStub.user1); + sessionMock.create.mockResolvedValue(sessionStub.valid); - await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails); - - expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); - }); - - it('should use the mobile redirect override for ios urls with multiple slashes', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); - userMock.getByOAuthId.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); - - await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails); - - expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); - }); + await sut.callback({ url }, loginDetails); + expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); + }); + } it('should use the default quota', async () => { systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); @@ -561,7 +561,7 @@ describe('AuthService', () => { ); expect(userMock.create).toHaveBeenCalledWith({ - email: email, + email, name: ' ', oauthId: sub, quotaSizeInBytes: null, @@ -581,7 +581,7 @@ describe('AuthService', () => { ); expect(userMock.create).toHaveBeenCalledWith({ - email: email, + email, name: ' ', oauthId: sub, quotaSizeInBytes: 5_368_709_120, diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 18b4268292dba..2b25decc07035 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -356,7 +356,7 @@ export class AuthService { } private normalize(config: SystemConfig, redirectUri: string) { - const isMobile = redirectUri.startsWith(MOBILE_REDIRECT); + const isMobile = redirectUri.startsWith('app.immich:/'); const { mobileRedirectUri, mobileOverrideEnabled } = config.oauth; if (isMobile && mobileOverrideEnabled && mobileRedirectUri) { return mobileRedirectUri; @@ -421,7 +421,7 @@ export class AuthService { await this.sessionRepository.update({ id: session.id, updatedAt: new Date() }); } - return { user: session.user, session: session }; + return { user: session.user, session }; } throw new UnauthorizedException('Invalid user token'); diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index b6d61c578d79c..d2a2813a0550c 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -68,7 +68,7 @@ export class DatabaseService { this.logger.setContext(DatabaseService.name); } - @OnEmit({ event: 'onBootstrap', priority: -200 }) + @OnEmit({ event: 'app.bootstrap', priority: -200 }) async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 1ff9e51576ba0..988b859ff882f 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; -import { AccessCore } from 'src/cores/access.core'; import { StorageCore } from 'src/cores/storage.core'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -11,21 +10,19 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface'; +import { requireAccess } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; import { usePagination } from 'src/utils/pagination'; import { getPreferences } from 'src/utils/preferences'; @Injectable() export class DownloadService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { - this.access = AccessCore.create(accessRepository); this.logger.setContext(DownloadService.name); } @@ -76,7 +73,7 @@ export class DownloadService { } async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, dto.assetIds); + await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); const zip = this.storageRepository.createZipStream(); const assets = await this.assetRepository.getByIds(dto.assetIds); @@ -119,20 +116,20 @@ export class DownloadService { if (dto.assetIds) { const assetIds = dto.assetIds; - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, assetIds); + await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true }); return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets })); } if (dto.albumId) { const albumId = dto.albumId; - await this.access.requirePermission(auth, Permission.ALBUM_DOWNLOAD, albumId); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] }); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); } if (dto.userId) { const userId = dto.userId; - await this.access.requirePermission(auth, Permission.TIMELINE_DOWNLOAD, userId); + await requireAccess(this.access, { auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), ); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index ae9d101c58aeb..35a1a7325bb2f 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -17,6 +17,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetDuplicateResult, ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -39,7 +40,7 @@ export class DuplicateService { async getDuplicates(auth: AuthDto): Promise { const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); - return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth }))); + return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true }))); } async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { @@ -69,7 +70,7 @@ export class DuplicateService { return JobStatus.SKIPPED; } - const asset = await this.assetRepository.getById(id, { smartSearch: true }); + const asset = await this.assetRepository.getById(id, { files: true, smartSearch: true }); if (!asset) { this.logger.error(`Asset ${id} not found`); return JobStatus.FAILED; @@ -80,7 +81,8 @@ export class DuplicateService { return JobStatus.SKIPPED; } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { this.logger.warn(`Asset ${id} is missing preview image`); return JobStatus.FAILED; } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index ab680f15e3111..2cfbdb40c21c1 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -25,6 +25,7 @@ import { ServerService } from 'src/services/server.service'; import { SessionService } from 'src/services/session.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { SmartInfoService } from 'src/services/smart-info.service'; +import { StackService } from 'src/services/stack.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SyncService } from 'src/services/sync.service'; @@ -36,6 +37,7 @@ import { TrashService } from 'src/services/trash.service'; import { UserAdminService } from 'src/services/user-admin.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; +import { ViewService } from 'src/services/view.service'; export const services = [ APIKeyService, @@ -65,6 +67,7 @@ export const services = [ SessionService, SharedLinkService, SmartInfoService, + StackService, StorageService, StorageTemplateService, SyncService, @@ -76,4 +79,5 @@ export const services = [ UserAdminService, UserService, VersionService, + ViewService, ]; diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 8a74ec918996c..2d4e1d5776bfe 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -8,7 +8,15 @@ import { AssetType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IJobRepository, ILibraryFileJob, ILibraryRefreshJob, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { + IJobRepository, + ILibraryFileJob, + ILibraryOfflineJob, + ILibraryRefreshJob, + JobName, + JOBS_LIBRARY_PAGINATION_SIZE, + JobStatus, +} from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -154,17 +162,19 @@ describe(LibraryService.name, () => { }); describe('handleQueueAssetRefresh', () => { - it('should queue new assets', async () => { + it('should queue refresh of a new asset', async () => { const mockLibraryJob: ILibraryRefreshJob = { id: libraryStub.externalLibrary1.id, refreshModifiedFiles: false, refreshAllFiles: false, }; + assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); // eslint-disable-next-line @typescript-eslint/require-await storageMock.walk.mockImplementation(async function* generator() { - yield '/data/user1/photo.jpg'; + yield ['/data/user1/photo.jpg']; }); assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); @@ -183,6 +193,44 @@ describe(LibraryService.name, () => { ]); }); + it('should queue offline check of existing online assets', async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.walk.mockImplementation(async function* generator() {}); + assetMock.getWith.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); + + await sut.handleQueueAssetRefresh(mockLibraryJob); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.LIBRARY_CHECK_OFFLINE, + data: { + id: assetStub.external.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: [], + }, + }, + ]); + }); + + it("should fail when library can't be found", async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(null); + + await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); + }); + it('should force queue new assets', async () => { const mockLibraryJob: ILibraryRefreshJob = { id: libraryStub.externalLibrary1.id, @@ -190,10 +238,11 @@ describe(LibraryService.name, () => { refreshAllFiles: true, }; + assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); // eslint-disable-next-line @typescript-eslint/require-await storageMock.walk.mockImplementation(async function* generator() { - yield '/data/user1/photo.jpg'; + yield ['/data/user1/photo.jpg']; }); assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); @@ -225,6 +274,8 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(true); + assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); + const mockLibraryJob: ILibraryRefreshJob = { id: libraryStub.externalLibraryWithImportPaths1.id, refreshModifiedFiles: false, @@ -239,51 +290,97 @@ describe(LibraryService.name, () => { expect(storageMock.walk).toHaveBeenCalledWith({ pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], exclusionPatterns: [], + includeHidden: false, + take: JOBS_LIBRARY_PAGINATION_SIZE, }); }); + }); - it('should set missing assets offline', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, + describe('handleOfflineCheck', () => { + it('should skip missing assets', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], }; - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ - items: [assetStub.external], - hasNextPage: false, - }); + assetMock.getById.mockResolvedValue(null); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { isOffline: true }); - expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: false }); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); }); - it('should set crawled assets that were previously offline back online', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, + it('should do nothing with already-offline assets', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], }; - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield assetStub.externalOffline.originalPath; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ - items: [assetStub.externalOffline], - hasNextPage: false, - }); + assetMock.getById.mockResolvedValue(assetStub.offline); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.externalOffline.id], { isOffline: false }); - expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: true }); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalled(); + }); + + it('should offline assets no longer on disk', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + assetMock.getById.mockResolvedValue(assetStub.external); + + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + }); + + it('should offline assets matching an exclusion pattern', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: ['**/user1/**'], + }; + + assetMock.getById.mockResolvedValue(assetStub.external); + + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + }); + + it('should set assets outside of import paths as offline', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/data/user2'], + exclusionPatterns: [], + }; + + assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.checkFileExists.mockResolvedValue(true); + + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + }); + + it('should do nothing with online assets', async () => { + const mockAssetJob: ILibraryOfflineJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.checkFileExists.mockResolvedValue(true); + + await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.update).not.toHaveBeenCalled(); }); }); @@ -1115,18 +1212,9 @@ describe(LibraryService.name, () => { }); describe('handleDeleteLibrary', () => { - it('should not delete a nonexistent library', async () => { - libraryMock.get.mockResolvedValue(null); - - libraryMock.getAssetIds.mockResolvedValue([]); - libraryMock.delete.mockImplementation(async () => {}); - - await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.FAILED); - }); - it('should delete an empty library', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.getAssetIds.mockResolvedValue([]); + assetMock.getAll.mockResolvedValue({ items: [], hasNextPage: false }); libraryMock.delete.mockImplementation(async () => {}); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); @@ -1134,7 +1222,7 @@ describe(LibraryService.name, () => { it('should delete a library with assets', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.getAssetIds.mockResolvedValue([assetStub.image1.id]); + assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); libraryMock.delete.mockImplementation(async () => {}); assetMock.getById.mockResolvedValue(assetStub.image1); @@ -1273,7 +1361,7 @@ describe(LibraryService.name, () => { assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); assetMock.getById.mockResolvedValue(assetStub.image1); - await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleRemoveOffline({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id, deleteOnDisk: false } }, diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 1bee2d32c3a41..2aa0df402a373 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { Trie } from 'mnemonist'; import { R_OK } from 'node:constants'; import { Stats } from 'node:fs'; import path, { basename, parse } from 'node:path'; @@ -18,7 +17,6 @@ import { ValidateLibraryResponseDto, mapLibrary, } from 'src/dtos/library.dto'; -import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType } from 'src/enum'; import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -29,8 +27,9 @@ import { IEntityJob, IJobRepository, ILibraryFileJob, + ILibraryOfflineJob, ILibraryRefreshJob, - JOBS_ASSET_PAGINATION_SIZE, + JOBS_LIBRARY_PAGINATION_SIZE, JobName, JobStatus, } from 'src/interfaces/job.interface'; @@ -43,8 +42,6 @@ import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { validateCronExpression } from 'src/validation'; -const LIBRARY_SCAN_BATCH_SIZE = 5000; - @Injectable() export class LibraryService { private configCore: SystemConfigCore; @@ -66,7 +63,7 @@ export class LibraryService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'onBootstrap' }) + @OnEmit({ event: 'app.bootstrap' }) async onBootstrap() { const config = await this.configCore.getConfig({ withCache: false }); @@ -104,7 +101,8 @@ export class LibraryService { }); } - onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { + @OnEmit({ event: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { throw new Error(`Invalid cron expression ${scan.cronExpression}`); @@ -189,7 +187,7 @@ export class LibraryService { } } - @OnEmit({ event: 'onShutdown' }) + @OnEmit({ event: 'app.shutdown' }) async onShutdown() { await this.unwatchAll(); } @@ -253,26 +251,17 @@ export class LibraryService { } private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) { - this.logger.verbose(`Queuing refresh of ${assetPaths.length} asset(s)`); - - // We perform this in batches to save on memory when performing large refreshes (greater than 1M assets) - const batchSize = 5000; - for (let i = 0; i < assetPaths.length; i += batchSize) { - const batch = assetPaths.slice(i, i + batchSize); - await this.jobRepository.queueAll( - batch.map((assetPath) => ({ - name: JobName.LIBRARY_SCAN_ASSET, - data: { - id: libraryId, - assetPath: assetPath, - ownerId, - force, - }, - })), - ); - } - - this.logger.debug('Asset refresh queue completed'); + await this.jobRepository.queueAll( + assetPaths.map((assetPath) => ({ + name: JobName.LIBRARY_SCAN_ASSET, + data: { + id: libraryId, + assetPath, + ownerId, + force, + }, + })), + ); } private async validateImportPath(importPath: string): Promise { @@ -347,27 +336,32 @@ export class LibraryService { } async handleDeleteLibrary(job: IEntityJob): Promise { - const library = await this.repository.get(job.id, true); - if (!library) { - return JobStatus.FAILED; - } + const libraryId = job.id; - // TODO use pagination - const assetIds = await this.repository.getAssetIds(job.id, true); - this.logger.debug(`Will delete ${assetIds.length} asset(s) in library ${job.id}`); - await this.jobRepository.queueAll( - assetIds.map((assetId) => ({ - name: JobName.ASSET_DELETION, - data: { - id: assetId, - deleteOnDisk: false, - }, - })), + const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => + this.assetRepository.getAll(pagination, { libraryId, withDeleted: true }), ); - if (assetIds.length === 0) { - this.logger.log(`Deleting library ${job.id}`); - await this.repository.delete(job.id); + let assetsFound = false; + + this.logger.debug(`Will delete all assets in library ${libraryId}`); + for await (const assets of assetPagination) { + assetsFound = true; + this.logger.debug(`Queueing deletion of ${assets.length} asset(s) in library ${libraryId}`); + await this.jobRepository.queueAll( + assets.map((asset) => ({ + name: JobName.ASSET_DELETION, + data: { + id: asset.id, + deleteOnDisk: false, + }, + })), + ); + } + + if (!assetsFound) { + this.logger.log(`Deleting library ${libraryId}`); + await this.repository.delete(libraryId); } return JobStatus.SUCCESS; } @@ -452,6 +446,7 @@ export class LibraryService { sidecarPath = `${assetPath}.xmp`; } + // TODO: device asset id is deprecated, remove it const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); let assetId; @@ -470,7 +465,7 @@ export class LibraryService { libraryId: job.id, checksum: pathHash, originalPath: assetPath, - deviceAssetId: deviceAssetId, + deviceAssetId, deviceId: 'Library Import', fileCreatedAt: stats.mtime, fileModifiedAt: stats.mtime, @@ -493,7 +488,7 @@ export class LibraryService { return JobStatus.SKIPPED; } - this.logger.debug(`Queuing metadata extraction for: ${assetPath}`); + this.logger.debug(`Queueing metadata extraction for: ${assetPath}`); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: assetId, source: 'upload' } }); @@ -518,17 +513,15 @@ export class LibraryService { } async queueRemoveOffline(id: string) { - this.logger.verbose(`Removing offline files from library: ${id}`); + this.logger.verbose(`Queueing offline file removal from library ${id}`); await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } }); } async handleQueueAllScan(job: IBaseJob): Promise { this.logger.debug(`Refreshing all external libraries: force=${job.force}`); - // Queue cleanup await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); - // Queue all library refresh const libraries = await this.repository.getAll(true); await this.jobRepository.queueAll( libraries.map((library) => ({ @@ -543,22 +536,76 @@ export class LibraryService { return JobStatus.SUCCESS; } - async handleOfflineRemoval(job: IEntityJob): Promise { - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id), + async handleOfflineCheck(job: ILibraryOfflineJob): Promise { + const asset = await this.assetRepository.getById(job.id); + + if (!asset) { + // Asset is no longer in the database, skip + return JobStatus.SKIPPED; + } + + if (asset.isOffline) { + this.logger.verbose(`Asset is already offline: ${asset.originalPath}`); + return JobStatus.SUCCESS; + } + + const isInPath = job.importPaths.find((path) => asset.originalPath.startsWith(path)); + if (!isInPath) { + this.logger.debug(`Asset is no longer in an import path, marking offline: ${asset.originalPath}`); + await this.assetRepository.update({ id: asset.id, isOffline: true }); + return JobStatus.SUCCESS; + } + + const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern)); + if (isExcluded) { + this.logger.debug(`Asset is covered by an exclusion pattern, marking offline: ${asset.originalPath}`); + await this.assetRepository.update({ id: asset.id, isOffline: true }); + return JobStatus.SUCCESS; + } + + const fileExists = await this.storageRepository.checkFileExists(asset.originalPath, R_OK); + if (!fileExists) { + this.logger.debug(`Asset is no longer found on disk, marking offline: ${asset.originalPath}`); + await this.assetRepository.update({ id: asset.id, isOffline: true }); + return JobStatus.SUCCESS; + } + + this.logger.verbose( + `Asset is found on disk, not covered by an exclusion pattern, and is in an import path, keeping online: ${asset.originalPath}`, ); + return JobStatus.SUCCESS; + } + + async handleRemoveOffline(job: IEntityJob): Promise { + this.logger.debug(`Removing offline assets for library ${job.id}`); + + const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => + this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id, true), + ); + + let offlineAssets = 0; for await (const assets of assetPagination) { - this.logger.debug(`Removing ${assets.length} offline assets`); - await this.jobRepository.queueAll( - assets.map((asset) => ({ - name: JobName.ASSET_DELETION, - data: { - id: asset.id, - deleteOnDisk: false, - }, - })), - ); + offlineAssets += assets.length; + if (assets.length > 0) { + this.logger.debug(`Discovered ${offlineAssets} offline assets in library ${job.id}`); + await this.jobRepository.queueAll( + assets.map((asset) => ({ + name: JobName.ASSET_DELETION, + data: { + id: asset.id, + deleteOnDisk: false, + }, + })), + ); + this.logger.verbose(`Queued deletion of ${assets.length} offline assets in library ${job.id}`); + } + } + + if (offlineAssets) { + this.logger.debug(`Finished queueing deletion of ${offlineAssets} offline assets for library ${job.id}`); + } else { + this.logger.debug(`Found no offline assets to delete from library ${job.id}`); } return JobStatus.SUCCESS; @@ -567,73 +614,67 @@ export class LibraryService { async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { const library = await this.repository.get(job.id); if (!library) { - this.logger.warn('Library not found'); - return JobStatus.FAILED; + return JobStatus.SKIPPED; } - this.logger.log(`Refreshing library: ${job.id}`); + this.logger.log(`Refreshing library ${library.id}`); - const crawledAssetPaths = await this.getPathTrie(library); - this.logger.debug(`Found ${crawledAssetPaths.size} asset(s) when crawling import paths ${library.importPaths}`); + const validImportPaths: string[] = []; - const assetIdsToMarkOffline = []; - const assetIdsToMarkOnline = []; - const pagination = usePagination(LIBRARY_SCAN_BATCH_SIZE, (pagination) => - this.assetRepository.getExternalLibraryAssetPaths(pagination, library.id), + for (const importPath of library.importPaths) { + const validation = await this.validateImportPath(importPath); + if (validation.isValid) { + validImportPaths.push(path.normalize(importPath)); + } else { + this.logger.warn(`Skipping invalid import path: ${importPath}. Reason: ${validation.message}`); + } + } + + if (validImportPaths.length === 0) { + this.logger.warn(`No valid import paths found for library ${library.id}`); + } + + const assetsOnDisk = this.storageRepository.walk({ + pathsToCrawl: validImportPaths, + includeHidden: false, + exclusionPatterns: library.exclusionPatterns, + take: JOBS_LIBRARY_PAGINATION_SIZE, + }); + + let crawledAssets = 0; + + for await (const assetBatch of assetsOnDisk) { + crawledAssets += assetBatch.length; + this.logger.debug(`Discovered ${crawledAssets} asset(s) on disk for library ${library.id}...`); + await this.scanAssets(job.id, assetBatch, library.ownerId, job.refreshAllFiles ?? false); + this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + } + + if (crawledAssets) { + this.logger.debug(`Finished queueing scan of ${crawledAssets} assets on disk for library ${library.id}`); + } else { + this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); + } + + const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => + this.assetRepository.getWith(pagination, WithProperty.IS_ONLINE, job.id), ); - this.logger.verbose(`Crawled asset paths paginated`); - - const shouldScanAll = job.refreshAllFiles || job.refreshModifiedFiles; - for await (const page of pagination) { - for (const asset of page) { - const isOffline = !crawledAssetPaths.has(asset.originalPath); - if (isOffline && !asset.isOffline) { - assetIdsToMarkOffline.push(asset.id); - this.logger.verbose(`Added to mark-offline list: ${asset.originalPath}`); - } - - if (!isOffline && asset.isOffline) { - assetIdsToMarkOnline.push(asset.id); - this.logger.verbose(`Added to mark-online list: ${asset.originalPath}`); - } - - if (!shouldScanAll) { - crawledAssetPaths.delete(asset.originalPath); - } - } + let onlineAssetCount = 0; + for await (const assets of onlineAssets) { + onlineAssetCount += assets.length; + this.logger.debug(`Discovered ${onlineAssetCount} asset(s) in library ${library.id}...`); + await this.jobRepository.queueAll( + assets.map((asset) => ({ + name: JobName.LIBRARY_CHECK_OFFLINE, + data: { id: asset.id, importPaths: validImportPaths, exclusionPatterns: library.exclusionPatterns }, + })), + ); + this.logger.debug(`Queued online check of ${assets.length} asset(s) in library ${library.id}...`); } - this.logger.verbose(`Crawled assets have been checked for online/offline status`); - - if (assetIdsToMarkOffline.length > 0) { - this.logger.debug(`Found ${assetIdsToMarkOffline.length} offline asset(s) previously marked as online`); - await this.assetRepository.updateAll(assetIdsToMarkOffline, { isOffline: true }); - } - - if (assetIdsToMarkOnline.length > 0) { - this.logger.debug(`Found ${assetIdsToMarkOnline.length} online asset(s) previously marked as offline`); - await this.assetRepository.updateAll(assetIdsToMarkOnline, { isOffline: false }); - } - - if (crawledAssetPaths.size > 0) { - if (!shouldScanAll) { - this.logger.debug(`Will import ${crawledAssetPaths.size} new asset(s)`); - } - - let batch = []; - for (const assetPath of crawledAssetPaths) { - batch.push(assetPath); - - if (batch.length >= LIBRARY_SCAN_BATCH_SIZE) { - await this.scanAssets(job.id, batch, library.ownerId, job.refreshAllFiles ?? false); - batch = []; - } - } - - if (batch.length > 0) { - await this.scanAssets(job.id, batch, library.ownerId, job.refreshAllFiles ?? false); - } + if (onlineAssetCount) { + this.logger.log(`Finished queueing online check of ${onlineAssetCount} assets for library ${library.id}`); } await this.repository.update({ id: job.id, refreshedAt: new Date() }); @@ -641,34 +682,6 @@ export class LibraryService { return JobStatus.SUCCESS; } - private async getPathTrie(library: LibraryEntity): Promise> { - const pathValidation = await Promise.all( - library.importPaths.map(async (importPath) => await this.validateImportPath(importPath)), - ); - - const validImportPaths = pathValidation - .map((validation) => { - if (!validation.isValid) { - this.logger.error(`Skipping invalid import path: ${validation.importPath}. Reason: ${validation.message}`); - } - return validation; - }) - .filter((validation) => validation.isValid) - .map((validation) => validation.importPath); - - const generator = this.storageRepository.walk({ - pathsToCrawl: validImportPaths, - exclusionPatterns: library.exclusionPatterns, - }); - - const trie = new Trie(); - for await (const filePath of generator) { - trie.add(filePath); - } - - return trie; - } - private async findOrFail(id: string) { const library = await this.repository.get(id); if (!library) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index d9d5948cead19..634cd790ebd0f 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -9,7 +9,7 @@ import { VideoCodec, } from 'src/config'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetType } from 'src/enum'; +import { AssetFileType, AssetType } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -298,18 +298,20 @@ describe(MediaService.name, () => { colorspace: Colorspace.SRGB, processInvalidImages: false, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath }); + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: previewPath, + }); }); it('should delete previous preview if different path', async () => { - const previousPreviewPath = assetStub.image.previewPath; - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGeneratePreview({ id: assetStub.image.id }); - expect(storageMock.unlink).toHaveBeenCalledWith(previousPreviewPath); + expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); it('should generate a P3 thumbnail for a wide gamut image', async () => { @@ -330,9 +332,10 @@ describe(MediaService.name, () => { processInvalidImages: false, }, ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', }); }); @@ -357,9 +360,10 @@ describe(MediaService.name, () => { twoPass: false, }, ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', }); }); @@ -384,9 +388,10 @@ describe(MediaService.name, () => { twoPass: false, }, ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', }); }); @@ -472,19 +477,21 @@ describe(MediaService.name, () => { colorspace: Colorspace.SRGB, processInvalidImages: false, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath }); + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: thumbnailPath, + }); }, ); it('should delete previous thumbnail if different path', async () => { - const previousThumbnailPath = assetStub.image.thumbnailPath; - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - expect(storageMock.unlink).toHaveBeenCalledWith(previousThumbnailPath); + expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); }); }); @@ -504,9 +511,10 @@ describe(MediaService.name, () => { processInvalidImages: false, }, ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - thumbnailPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 5264da9fe9314..3f2513474154d 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -15,7 +15,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/entities/move.entity'; -import { AssetType } from 'src/enum'; +import { AssetFileType, AssetType } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { @@ -34,6 +34,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @@ -72,7 +73,11 @@ export class MediaService { async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { isVisible: true, withDeleted: true, withArchived: true }) + ? this.assetRepository.getAll(pagination, { + isVisible: true, + withDeleted: true, + withArchived: true, + }) : this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL); }); @@ -80,13 +85,17 @@ export class MediaService { const jobs: JobItem[] = []; for (const asset of assets) { - if (!asset.previewPath || force) { + const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + + if (!previewFile || force) { jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } }); continue; } - if (!asset.thumbnailPath) { + + if (!thumbnailFile) { jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } }); } + if (!asset.thumbhash) { jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } }); } @@ -152,7 +161,7 @@ export class MediaService { async handleAssetMigration({ id }: IEntityJob): Promise { const { image } = await this.configCore.getConfig({ withCache: true }); - const [asset] = await this.assetRepository.getByIds([id]); + const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; } @@ -178,11 +187,20 @@ export class MediaService { } const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); - if (asset.previewPath && asset.previewPath !== previewPath) { - this.logger.debug(`Deleting old preview for asset ${asset.id}`); - await this.storageRepository.unlink(asset.previewPath); + if (!previewPath) { + return JobStatus.SKIPPED; } - await this.assetRepository.update({ id: asset.id, previewPath }); + + const { previewFile } = getAssetFiles(asset.files); + if (previewFile && previewFile.path !== previewPath) { + this.logger.debug(`Deleting old preview for asset ${asset.id}`); + await this.storageRepository.unlink(previewFile.path); + } + + await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath }); + await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); + await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() }); + return JobStatus.SUCCESS; } @@ -237,16 +255,19 @@ export class MediaService { throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`); } } + + const assetLabel = asset.isExternal ? asset.originalPath : asset.id; this.logger.log( - `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${asset.id}`, + `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${assetLabel}`, ); + return path; } async handleGenerateThumbnail({ id }: IEntityJob): Promise { const [{ image }, [asset]] = await Promise.all([ this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true }), + this.assetRepository.getByIds([id], { exifInfo: true, files: true }), ]); if (!asset) { return JobStatus.FAILED; @@ -257,16 +278,25 @@ export class MediaService { } const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); - if (asset.thumbnailPath && asset.thumbnailPath !== thumbnailPath) { - this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); - await this.storageRepository.unlink(asset.thumbnailPath); + if (!thumbnailPath) { + return JobStatus.SKIPPED; } - await this.assetRepository.update({ id: asset.id, thumbnailPath }); + + const { thumbnailFile } = getAssetFiles(asset.files); + if (thumbnailFile && thumbnailFile.path !== thumbnailPath) { + this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); + await this.storageRepository.unlink(thumbnailFile.path); + } + + await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: thumbnailPath }); + await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); + await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() }); + return JobStatus.SUCCESS; } async handleGenerateThumbhash({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id]); + const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; } @@ -275,11 +305,12 @@ export class MediaService { return JobStatus.SKIPPED; } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { return JobStatus.FAILED; } - const thumbhash = await this.mediaRepository.generateThumbhash(asset.previewPath); + const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path); await this.assetRepository.update({ id: asset.id, thumbhash }); return JobStatus.SUCCESS; diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index c8c44d04b3793..fb1ff49f0b456 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; @@ -7,18 +6,15 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() export class MemoryService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) private accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IMemoryRepository) private repository: IMemoryRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} async search(auth: AuthDto) { const memories = await this.repository.search(auth.user.id); @@ -26,7 +22,7 @@ export class MemoryService { } async get(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_READ, id); + await requireAccess(this.access, { auth, permission: Permission.MEMORY_READ, ids: [id] }); const memory = await this.findOrFail(id); return mapMemory(memory); } @@ -35,7 +31,11 @@ export class MemoryService { // TODO validate type/data combination const assetIds = dto.assetIds || []; - const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, assetIds); + const allowedAssetIds = await checkAccess(this.access, { + auth, + permission: Permission.ASSET_SHARE, + ids: assetIds, + }); const memory = await this.repository.create({ ownerId: auth.user.id, type: dto.type, @@ -50,7 +50,7 @@ export class MemoryService { } async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); const memory = await this.repository.update({ id, @@ -63,14 +63,14 @@ export class MemoryService { } async remove(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_DELETE, id); + await requireAccess(this.access, { auth, permission: Permission.MEMORY_DELETE, ids: [id] }); await this.repository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_READ, id); + await requireAccess(this.access, { auth, permission: Permission.MEMORY_READ, ids: [id] }); - const repos = { accessRepository: this.accessRepository, repository: this.repository }; + const repos = { access: this.access, bulk: this.repository }; const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids }); const hasSuccess = results.find(({ success }) => success); @@ -82,9 +82,9 @@ export class MemoryService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); - const repos = { accessRepository: this.accessRepository, repository: this.repository }; + const repos = { access: this.access, bulk: this.repository }; const results = await removeAssets(auth, repos, { parentId: id, assetIds: dto.ids, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 05f6f9f658d93..3e3e5e0db1fc8 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -18,11 +18,13 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { MetadataService, Orientation } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; +import { tagStub } from 'test/fixtures/tag.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; @@ -37,6 +39,7 @@ import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked } from 'vitest'; @@ -56,6 +59,7 @@ describe(MetadataService.name, () => { let databaseMock: Mocked; let userMock: Mocked; let loggerMock: Mocked; + let tagMock: Mocked; let sut: MetadataService; beforeEach(() => { @@ -74,6 +78,7 @@ describe(MetadataService.name, () => { databaseMock = newDatabaseRepositoryMock(); userMock = newUserRepositoryMock(); loggerMock = newLoggerRepositoryMock(); + tagMock = newTagRepositoryMock(); sut = new MetadataService( albumMock, @@ -89,6 +94,7 @@ describe(MetadataService.name, () => { personMock, storageMock, systemMock, + tagMock, userMock, loggerMock, ); @@ -218,6 +224,29 @@ describe(MetadataService.name, () => { assetStub.livePhotoMotionAsset.id, ); }); + + it('should search by libraryId', async () => { + assetMock.getByIds.mockResolvedValue([ + { + ...assetStub.livePhotoStillAsset, + libraryId: 'library-id', + exifInfo: { livePhotoCID: 'CID' } as ExifEntity, + }, + ]); + assetMock.findLivePhotoMatch.mockResolvedValue(null); + + await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( + JobStatus.SKIPPED, + ); + + expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ + ownerId: 'user-id', + otherAssetId: 'live-photo-still-asset', + livePhotoCID: 'CID', + libraryId: 'library-id', + type: 'VIDEO', + }); + }); }); describe('handleQueueMetadataExtraction', () => { @@ -333,6 +362,78 @@ describe(MetadataService.name, () => { expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); }); + it('should extract tags from TagsList', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent'] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + }); + + it('should extract hierarchy from TagsList', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent/Child'] }); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + }); + + it('should extract tags from Keywords as a string', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent' }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + }); + + it('should extract tags from Keywords as a list', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent'] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + }); + + it('should extract tags from Keywords as a list with a number', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] as any[] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + }); + + it('should extract hierarchal tags from Keywords', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent/Child' }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + }); + it('should not apply motion photos if asset is video', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index f1d367fb7b9f4..a0a8f9ebef975 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -22,8 +22,8 @@ import { IEntityJob, IJobRepository, ISidecarWriteJob, - JOBS_ASSET_PAGINATION_SIZE, JobName, + JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName, } from 'src/interfaces/job.interface'; @@ -35,8 +35,10 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { usePagination } from 'src/utils/pagination'; +import { upsertTags } from 'src/utils/tag'; /** look for a date from these tags (in order) */ const EXIF_DATE_TAGS: Array = [ @@ -105,6 +107,7 @@ export class MetadataService { @Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, + @Inject(ITagRepository) private tagRepository: ITagRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { @@ -121,8 +124,8 @@ export class MetadataService { ); } - @OnEmit({ event: 'onBootstrap' }) - async onBootstrap(app: ArgOf<'onBootstrap'>) { + @OnEmit({ event: 'app.bootstrap' }) + async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; } @@ -130,8 +133,8 @@ export class MetadataService { await this.init(config); } - @OnEmit({ event: 'onConfigUpdate' }) - async onConfigUpdate({ newConfig }: ArgOf<'onConfigUpdate'>) { + @OnEmit({ event: 'config.update' }) + async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { await this.init(newConfig); } @@ -153,7 +156,7 @@ export class MetadataService { } } - @OnEmit({ event: 'onShutdown' }) + @OnEmit({ event: 'app.shutdown' }) async onShutdown() { await this.repository.teardown(); } @@ -173,6 +176,7 @@ export class MetadataService { const match = await this.assetRepository.findLivePhotoMatch({ livePhotoCID: asset.exifInfo.livePhotoCID, ownerId: asset.ownerId, + libraryId: asset.libraryId, otherAssetId: asset.id, type: otherType, }); @@ -216,24 +220,27 @@ export class MetadataService { return JobStatus.FAILED; } - const { exifData, tags } = await this.exifData(asset); + const { exifData, exifTags } = await this.exifData(asset); if (asset.type === AssetType.VIDEO) { await this.applyVideoMetadata(asset, exifData); } - await this.applyMotionPhotos(asset, tags); + await this.applyMotionPhotos(asset, exifTags); await this.applyReverseGeocoding(asset, exifData); + await this.applyTagList(asset, exifTags); + await this.assetRepository.upsertExif(exifData); const dateTimeOriginal = exifData.dateTimeOriginal; let localDateTime = dateTimeOriginal ?? undefined; - const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0; + const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0; if (dateTimeOriginal && timeZoneOffset) { localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000); } + await this.assetRepository.update({ id: asset.id, duration: asset.duration, @@ -277,22 +284,35 @@ export class MetadataService { return this.processSidecar(id, false); } + @OnEmit({ event: 'asset.tag' }) + async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) { + await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); + } + + @OnEmit({ event: 'asset.untag' }) + async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) { + await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); + } + async handleSidecarWrite(job: ISidecarWriteJob): Promise { - const { id, description, dateTimeOriginal, latitude, longitude, rating } = job; - const [asset] = await this.assetRepository.getByIds([id]); + const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; + const [asset] = await this.assetRepository.getByIds([id], { tags: true }); if (!asset) { return JobStatus.FAILED; } + const tagsList = (asset.tags || []).map((tag) => tag.value); + const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; - const exif = _.omitBy( - { + const exif = _.omitBy( + { Description: description, ImageDescription: description, DateTimeOriginal: dateTimeOriginal, GPSLatitude: latitude, GPSLongitude: longitude, Rating: rating, + TagsList: tags ? tagsList : undefined, }, _.isUndefined, ); @@ -331,6 +351,27 @@ export class MetadataService { } } + private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { + const tags: unknown[] = []; + if (exifTags.TagsList) { + tags.push(...exifTags.TagsList); + } + + if (exifTags.Keywords) { + let keywords = exifTags.Keywords; + if (!Array.isArray(keywords)) { + keywords = [keywords]; + } + tags.push(...keywords); + } + + if (tags.length > 0) { + const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags: tags.map(String) }); + const tagIds = results.map((tag) => tag.id); + await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds }); + } + } + private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) { if (asset.type !== AssetType.IMAGE) { return; @@ -465,7 +506,7 @@ export class MetadataService { private async exifData( asset: AssetEntity, - ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> { + ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> { const stats = await this.storageRepository.stat(asset.originalPath); const mediaTags = await this.repository.readTags(asset.originalPath); const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null; @@ -478,38 +519,38 @@ export class MetadataService { } } - const tags = { ...mediaTags, ...sidecarTags }; + const exifTags = { ...mediaTags, ...sidecarTags }; - this.logger.verbose('Exif Tags', tags); + this.logger.verbose('Exif Tags', exifTags); const exifData = { // altitude: tags.GPSAltitude ?? null, assetId: asset.id, - bitsPerSample: this.getBitsPerSample(tags), - colorspace: tags.ColorSpace ?? null, - dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt, - description: String(tags.ImageDescription || tags.Description || '').trim(), - exifImageHeight: validate(tags.ImageHeight), - exifImageWidth: validate(tags.ImageWidth), - exposureTime: tags.ExposureTime ?? null, + bitsPerSample: this.getBitsPerSample(exifTags), + colorspace: exifTags.ColorSpace ?? null, + dateTimeOriginal: this.getDateTimeOriginal(exifTags) ?? asset.fileCreatedAt, + description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), + exifImageHeight: validate(exifTags.ImageHeight), + exifImageWidth: validate(exifTags.ImageWidth), + exposureTime: exifTags.ExposureTime ?? null, fileSizeInByte: stats.size, - fNumber: validate(tags.FNumber), - focalLength: validate(tags.FocalLength), - fps: validate(Number.parseFloat(tags.VideoFrameRate!)), - iso: validate(tags.ISO), - latitude: validate(tags.GPSLatitude), - lensModel: tags.LensModel ?? null, - livePhotoCID: (tags.ContentIdentifier || tags.MediaGroupUUID) ?? null, - autoStackId: this.getAutoStackId(tags), - longitude: validate(tags.GPSLongitude), - make: tags.Make ?? null, - model: tags.Model ?? null, - modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt, - orientation: validate(tags.Orientation)?.toString() ?? null, - profileDescription: tags.ProfileDescription || null, - projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null, - timeZone: tags.tz ?? null, - rating: tags.Rating ?? null, + fNumber: validate(exifTags.FNumber), + focalLength: validate(exifTags.FocalLength), + fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), + iso: validate(exifTags.ISO), + latitude: validate(exifTags.GPSLatitude), + lensModel: exifTags.LensModel ?? null, + livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, + autoStackId: this.getAutoStackId(exifTags), + longitude: validate(exifTags.GPSLongitude), + make: exifTags.Make ?? null, + model: exifTags.Model ?? null, + modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt, + orientation: validate(exifTags.Orientation)?.toString() ?? null, + profileDescription: exifTags.ProfileDescription || null, + projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, + timeZone: exifTags.tz ?? null, + rating: exifTags.Rating ?? null, }; if (exifData.latitude === 0 && exifData.longitude === 0) { @@ -518,7 +559,7 @@ export class MetadataService { exifData.longitude = null; } - return { exifData, tags }; + return { exifData, exifTags }; } private getAutoStackId(tags: ImmichTags | null): string | null { diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 46ca4118d1954..025400cc9bde3 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -39,8 +39,8 @@ export class MicroservicesService { private versionService: VersionService, ) {} - @OnEmit({ event: 'onBootstrap' }) - async onBootstrap(app: ArgOf<'onBootstrap'>) { + @OnEmit({ event: 'app.bootstrap' }) + async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; } @@ -85,7 +85,8 @@ export class MicroservicesService { [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), - [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data), + [JobName.LIBRARY_CHECK_OFFLINE]: (data) => this.libraryService.handleOfflineCheck(data), + [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleRemoveOffline(data), [JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data), [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data), diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 74d2a12127dbd..5bcead0ff31ae 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -1,6 +1,9 @@ +import { plainToInstance } from 'class-transformer'; import { defaults, SystemConfig } from 'src/config'; +import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; -import { UserMetadataKey } from 'src/enum'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; +import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -111,6 +114,14 @@ describe(NotificationService.name, () => { expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); + it('skips smtp validation with DTO when there are no changes', async () => { + const oldConfig = { ...configs.smtpEnabled }; + const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled); + + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); + expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + }); + it('skips smtp validation when smtp is disabled', async () => { const oldConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpDisabled }; @@ -333,7 +344,9 @@ describe(NotificationService.name, () => { notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + files: true, + }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ @@ -358,10 +371,15 @@ describe(NotificationService.name, () => { }); systemMock.get.mockResolvedValue({ server: {} }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - assetMock.getById.mockResolvedValue({ ...assetStub.image, thumbnailPath: 'path-to-thumb.jpg' }); + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity], + }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + files: true, + }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ @@ -389,7 +407,9 @@ describe(NotificationService.name, () => { assetMock.getById.mockResolvedValue(assetStub.image); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + files: true, + }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 80abc4ca983d8..274c91661ca2b 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,5 +1,4 @@ import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { isEqual } from 'lodash'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } from 'src/decorators'; @@ -21,7 +20,9 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { getFilenameExtension } from 'src/utils/file'; +import { isEqualObject } from 'src/utils/object'; import { getPreferences } from 'src/utils/preferences'; @Injectable() @@ -41,12 +42,12 @@ export class NotificationService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } - @OnEmit({ event: 'onConfigValidate', priority: -100 }) - async onConfigValidate({ oldConfig, newConfig }: ArgOf<'onConfigValidate'>) { + @OnEmit({ event: 'config.validate', priority: -100 }) + async onConfigValidate({ oldConfig, newConfig }: ArgOf<'config.validate'>) { try { if ( newConfig.notifications.smtp.enabled && - !isEqual(oldConfig.notifications.smtp, newConfig.notifications.smtp) + !isEqualObject(oldConfig.notifications.smtp, newConfig.notifications.smtp) ) { await this.notificationRepository.verifySmtp(newConfig.notifications.smtp.transport); } @@ -56,20 +57,20 @@ export class NotificationService { } } - @OnEmit({ event: 'onUserSignup' }) - async onUserSignup({ notify, id, tempPassword }: ArgOf<'onUserSignup'>) { + @OnEmit({ event: 'user.signup' }) + async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - @OnEmit({ event: 'onAlbumUpdate' }) - async onAlbumUpdate({ id, updatedBy }: ArgOf<'onAlbumUpdate'>) { + @OnEmit({ event: 'album.update' }) + async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); } - @OnEmit({ event: 'onAlbumInvite' }) - async onAlbumInvite({ id, userId }: ArgOf<'onAlbumInvite'>) { + @OnEmit({ event: 'album.invite' }) + async onAlbumInvite({ id, userId }: ArgOf<'album.invite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } @@ -86,7 +87,7 @@ export class NotificationService { } const { server } = await this.configCore.getConfig({ withCache: false }); - const { html, text } = this.notificationRepository.renderEmail({ + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, @@ -112,7 +113,7 @@ export class NotificationService { } const { server } = await this.configCore.getConfig({ withCache: true }); - const { html, text } = this.notificationRepository.renderEmail({ + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.WELCOME, data: { baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, @@ -155,7 +156,7 @@ export class NotificationService { const attachment = await this.getAlbumThumbnailAttachment(album); const { server } = await this.configCore.getConfig({ withCache: false }); - const { html, text } = this.notificationRepository.renderEmail({ + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_INVITE, data: { baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, @@ -210,7 +211,7 @@ export class NotificationService { continue; } - const { html, text } = this.notificationRepository.renderEmail({ + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_UPDATE, data: { baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, @@ -268,14 +269,15 @@ export class NotificationService { return; } - const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId); - if (!albumThumbnail?.thumbnailPath) { + const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId, { files: true }); + const { thumbnailFile } = getAssetFiles(albumThumbnail?.files); + if (!thumbnailFile) { return; } return { - filename: `album-thumbnail${getFilenameExtension(albumThumbnail.thumbnailPath)}`, - path: albumThumbnail.thumbnailPath, + filename: `album-thumbnail${getFilenameExtension(thumbnailFile.path)}`, + path: thumbnailFile.path, cid: 'album-thumbnail', }; } diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index c20d43db5d1ca..4b7cd4c516e42 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; import { mapUser } from 'src/dtos/user.dto'; @@ -7,16 +6,14 @@ import { PartnerEntity } from 'src/entities/partner.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository, PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; +import { requireAccess } from 'src/utils/access'; @Injectable() export class PartnerService { - private access: AccessCore; constructor( @Inject(IPartnerRepository) private repository: IPartnerRepository, - @Inject(IAccessRepository) accessRepository: IAccessRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + @Inject(IAccessRepository) private access: IAccessRepository, + ) {} async create(auth: AuthDto, sharedWithId: string): Promise { const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId }; @@ -49,7 +46,7 @@ export class PartnerService { } async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise { - await this.access.requirePermission(auth, Permission.PARTNER_UPDATE, sharedById); + await requireAccess(this.access, { auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] }); const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline }); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 70e043cc7f3a7..f8608243ae92c 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -716,7 +716,7 @@ describe(PersonService.name, () => { await sut.handleDetectFaces({ id: assetStub.image.id }); expect(machineLearningMock.detectFaces).toHaveBeenCalledWith( 'http://immich-machine-learning:3003', - assetStub.image.previewPath, + '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); expect(personMock.createFaces).not.toHaveBeenCalled(); @@ -946,7 +946,7 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true }); + expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, @@ -1032,7 +1032,7 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.video.previewPath, + '/uploads/user-id/thumbs/path.jpg', 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { format: 'jpeg', diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 6d536f4bf84d7..6f2283b72c6e8 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ImageFormat } from 'src/config'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; -import { AccessCore } from 'src/cores/access.core'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; @@ -50,6 +49,8 @@ import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interf import { ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { checkAccess, requireAccess } from 'src/utils/access'; +import { getAssetFiles } from 'src/utils/asset.util'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFacialRecognitionEnabled } from 'src/utils/misc'; @@ -58,12 +59,11 @@ import { IsNull } from 'typeorm'; @Injectable() export class PersonService { - private access: AccessCore; private configCore: SystemConfigCore; private storageCore: StorageCore; constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, @@ -76,7 +76,6 @@ export class PersonService { @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { - this.access = AccessCore.create(accessRepository); this.logger.setContext(PersonService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( @@ -113,7 +112,7 @@ export class PersonService { } async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId); + await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); const person = await this.findOrFail(personId); const result: PersonResponseDto[] = []; const changeFeaturePhoto: string[] = []; @@ -121,7 +120,7 @@ export class PersonService { const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); for (const face of faces) { - await this.access.requirePermission(auth, Permission.PERSON_CREATE, face.id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_CREATE, ids: [face.id] }); if (person.faceAssetId === null) { changeFeaturePhoto.push(person.id); } @@ -142,9 +141,8 @@ export class PersonService { } async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId); - - await this.access.requirePermission(auth, Permission.PERSON_CREATE, dto.id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); + await requireAccess(this.access, { auth, permission: Permission.PERSON_CREATE, ids: [dto.id] }); const face = await this.repository.getFaceById(dto.id); const person = await this.findOrFail(personId); @@ -160,7 +158,7 @@ export class PersonService { } async getFacesById(auth: AuthDto, dto: FaceDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_READ, dto.id); + await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [dto.id] }); const faces = await this.repository.getFaces(dto.id); return faces.map((asset) => mapFaces(asset, auth)); } @@ -187,17 +185,17 @@ export class PersonService { } async getById(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); return this.findOrFail(id).then(mapPerson); } async getStatistics(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); return this.repository.getStatistics(id); } async getThumbnail(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); const person = await this.repository.getById(id); if (!person || !person.thumbnailPath) { throw new NotFoundException(); @@ -211,7 +209,7 @@ export class PersonService { } async getAssets(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); const assets = await this.repository.getAssets(id); return assets.map((asset) => mapAsset(asset)); } @@ -226,13 +224,13 @@ export class PersonService { } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { - await this.access.requirePermission(auth, Permission.ASSET_READ, assetId); + await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [assetId] }); const [face] = await this.repository.getFacesByIds([{ personId: id, assetId }]); if (!face) { throw new BadRequestException('Invalid assetId for feature face'); @@ -333,9 +331,11 @@ export class PersonService { faces: { person: false, }, + files: true, }; const [asset] = await this.assetRepository.getByIds([id], relations); - if (!asset || !asset.previewPath || asset.faces?.length > 0) { + const { previewFile } = getAssetFiles(asset.files); + if (!asset || !previewFile || asset.faces?.length > 0) { return JobStatus.FAILED; } @@ -349,11 +349,11 @@ export class PersonService { const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces( machineLearning.url, - asset.previewPath, + previewFile.path, machineLearning.facialRecognition, ); - this.logger.debug(`${faces.length} faces detected in ${asset.previewPath}`); + this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`); if (faces.length > 0) { await this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); @@ -549,7 +549,10 @@ export class PersonService { imageHeight: oldHeight, } = face; - const asset = await this.assetRepository.getById(assetId, { exifInfo: true }); + const asset = await this.assetRepository.getById(assetId, { + exifInfo: true, + files: true, + }); if (!asset) { this.logger.error(`Could not generate person thumbnail: asset ${assetId} does not exist`); return JobStatus.FAILED; @@ -581,13 +584,17 @@ export class PersonService { throw new BadRequestException('Cannot merge a person into themselves'); } - await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id); + await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); let primaryPerson = await this.findOrFail(id); const primaryName = primaryPerson.name || primaryPerson.id; const results: BulkIdResponseDto[] = []; - const allowedIds = await this.access.checkAccess(auth, Permission.PERSON_MERGE, mergeIds); + const allowedIds = await checkAccess(this.access, { + auth, + permission: Permission.PERSON_MERGE, + ids: mergeIds, + }); for (const mergeId of mergeIds) { const hasAccess = allowedIds.has(mergeId); @@ -646,7 +653,8 @@ export class PersonService { throw new Error(`Asset ${asset.id} dimensions are unknown`); } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { throw new Error(`Asset ${asset.id} has no preview path`); } @@ -659,8 +667,8 @@ export class PersonService { return { width, height, inputPath: asset.originalPath }; } - const { width, height } = await this.mediaRepository.getImageDimensions(asset.previewPath); - return { width, height, inputPath: asset.previewPath }; + const { width, height } = await this.mediaRepository.getImageDimensions(previewFile.path); + return { width, height, inputPath: previewFile.path }; } private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions { diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 6af42ac1f3993..35fd29a2de468 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PersonResponseDto } from 'src/dtos/person.dto'; import { @@ -92,7 +92,7 @@ export class SearchService { }, ); - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null); + return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { @@ -111,7 +111,7 @@ export class SearchService { { ...dto, userIds, embedding }, ); - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null); + return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } async getAssetsByCity(auth: AuthDto): Promise { @@ -157,13 +157,13 @@ export class SearchService { return [auth.user.id, ...partnerIds]; } - private mapResponse(assets: AssetEntity[], nextPage: string | null): SearchResponseDto { + private mapResponse(assets: AssetEntity[], nextPage: string | null, options: AssetMapOptions): SearchResponseDto { return { albums: { total: 0, count: 0, items: [], facets: [] }, assets: { total: assets.length, count: assets.length, - items: assets.map((asset) => mapAsset(asset)), + items: assets.map((asset) => mapAsset(asset, options)), facets: [], nextPage, }, diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index faf4d981644a3..5ea8a3e45921f 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -42,7 +42,7 @@ export class ServerService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'onBootstrap' }) + @OnEmit({ event: 'app.bootstrap' }) async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 01cf3a5c0906a..47abf3c380246 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { AccessCore } from 'src/cores/access.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { Permission } from 'src/enum'; @@ -8,18 +7,16 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; +import { requireAccess } from 'src/utils/access'; @Injectable() export class SessionService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ISessionRepository) private sessionRepository: ISessionRepository, ) { this.logger.setContext(SessionService.name); - this.access = AccessCore.create(accessRepository); } async handleCleanup() { @@ -47,7 +44,7 @@ export class SessionService { } async delete(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id); + await requireAccess(this.access, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); await this.sessionRepository.delete(id); } diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 4b6768e02879b..54c7fdf25bed7 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { AccessCore } from 'src/cores/access.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; @@ -21,22 +20,21 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { checkAccess, requireAccess } from 'src/utils/access'; import { OpenGraphTags } from 'src/utils/misc'; @Injectable() export class SharedLinkService { - private access: AccessCore; private configCore: SystemConfigCore; constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ISharedLinkRepository) private repository: ISharedLinkRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, ) { this.logger.setContext(SharedLinkService.name); - this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @@ -69,7 +67,7 @@ export class SharedLinkService { if (!dto.albumId) { throw new BadRequestException('Invalid albumId'); } - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, dto.albumId); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); break; } @@ -78,7 +76,7 @@ export class SharedLinkService { throw new BadRequestException('Invalid assetIds'); } - await this.access.requirePermission(auth, Permission.ASSET_SHARE, dto.assetIds); + await requireAccess(this.access, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds }); break; } @@ -139,7 +137,11 @@ export class SharedLinkService { const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id)); const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId)); - const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds); + const allowedAssetIds = await checkAccess(this.access, { + auth, + permission: Permission.ASSET_SHARE, + ids: notPresentAssetIds, + }); const results: AssetIdsResponseDto[] = []; for (const assetId of dto.assetIds) { diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 278e06d287db7..97d22da9b8a4f 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -318,7 +318,7 @@ describe(SmartInfoService.name, () => { expect(machineMock.encodeImage).toHaveBeenCalledWith( 'http://immich-machine-learning:3003', - assetStub.image.previewPath, + '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 883f320abf50c..a75594100f231 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -18,6 +18,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getAssetFiles } from 'src/utils/asset.util'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -38,8 +39,8 @@ export class SmartInfoService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'onBootstrap' }) - async onBootstrap(app: ArgOf<'onBootstrap'>) { + @OnEmit({ event: 'app.bootstrap' }) + async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; } @@ -48,8 +49,8 @@ export class SmartInfoService { await this.init(config); } - @OnEmit({ event: 'onConfigValidate' }) - onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { + @OnEmit({ event: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { getCLIPModelInfo(newConfig.machineLearning.clip.modelName); } catch { @@ -59,8 +60,8 @@ export class SmartInfoService { } } - @OnEmit({ event: 'onConfigUpdate' }) - async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'onConfigUpdate'>) { + @OnEmit({ event: 'config.update' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { await this.init(newConfig, oldConfig); } @@ -135,7 +136,7 @@ export class SmartInfoService { return JobStatus.SKIPPED; } - const [asset] = await this.assetRepository.getByIds([id]); + const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; } @@ -144,13 +145,14 @@ export class SmartInfoService { return JobStatus.SKIPPED; } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { return JobStatus.FAILED; } const embedding = await this.machineLearning.encodeImage( machineLearning.url, - asset.previewPath, + previewFile.path, machineLearning.clip, ); diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts new file mode 100644 index 0000000000000..bebc8517d6b7a --- /dev/null +++ b/server/src/services/stack.service.ts @@ -0,0 +1,80 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; +import { Permission } from 'src/enum'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IStackRepository } from 'src/interfaces/stack.interface'; +import { requireAccess } from 'src/utils/access'; + +@Injectable() +export class StackService { + constructor( + @Inject(IAccessRepository) private access: IAccessRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(IStackRepository) private stackRepository: IStackRepository, + ) {} + + async search(auth: AuthDto, dto: StackSearchDto): Promise { + const stacks = await this.stackRepository.search({ + ownerId: auth.user.id, + primaryAssetId: dto.primaryAssetId, + }); + + return stacks.map((stack) => mapStack(stack, { auth })); + } + + async create(auth: AuthDto, dto: StackCreateDto): Promise { + await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); + + const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds }); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + + return mapStack(stack, { auth }); + } + + async get(auth: AuthDto, id: string): Promise { + await requireAccess(this.access, { auth, permission: Permission.STACK_READ, ids: [id] }); + const stack = await this.findOrFail(id); + return mapStack(stack, { auth }); + } + + async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise { + await requireAccess(this.access, { auth, permission: Permission.STACK_UPDATE, ids: [id] }); + const stack = await this.findOrFail(id); + if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) { + throw new BadRequestException('Primary asset must be in the stack'); + } + + const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId }); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + + return mapStack(updatedStack, { auth }); + } + + async delete(auth: AuthDto, id: string): Promise { + await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: [id] }); + await this.stackRepository.delete(id); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + } + + async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise { + await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: dto.ids }); + await this.stackRepository.deleteAll(dto.ids); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + } + + private async findOrFail(id: string) { + const stack = await this.stackRepository.getById(id); + if (!stack) { + throw new Error('Asset stack not found'); + } + + return stack; + } +} diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index c1e0410a3d89c..093cc5b2ff1d6 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -15,6 +15,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { StorageTemplateService } from 'src/services/storage-template.service'; +import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; @@ -83,7 +84,7 @@ describe(StorageTemplateService.name, () => { newConfig: { storageTemplate: { template: - '{{y}}{{M}}{{W}}{{d}}{{h}}{{m}}{{s}}{{filename}}{{ext}}{{filetype}}{{filetypefull}}{{assetId}}{{album}}', + '{{y}}{{M}}{{W}}{{d}}{{h}}{{m}}{{s}}{{filename}}{{ext}}{{filetype}}{{filetypefull}}{{assetId}}{{#if album}}{{album}}{{else}}other{{/if}}', }, } as SystemConfig, oldConfig: {} as SystemConfig, @@ -163,6 +164,47 @@ describe(StorageTemplateService.name, () => { originalPath: newMotionPicturePath, }); }); + it('Should use handlebar if condition for album', async () => { + const asset = assetStub.image; + const user = userStub.user1; + const album = albumStub.oneAsset; + const config = structuredClone(defaults); + config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; + SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + + userMock.get.mockResolvedValue(user); + assetMock.getByIds.mockResolvedValueOnce([asset]); + albumMock.getByAssetId.mockResolvedValueOnce([album]); + + expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); + + expect(moveMock.create).toHaveBeenCalledWith({ + entityId: asset.id, + newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`, + oldPath: asset.originalPath, + pathType: AssetPathType.ORIGINAL, + }); + }); + it('Should use handlebar else condition for album', async () => { + const asset = assetStub.image; + const user = userStub.user1; + const config = structuredClone(defaults); + config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; + SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + + userMock.get.mockResolvedValue(user); + assetMock.getByIds.mockResolvedValueOnce([asset]); + + expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); + + const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); + expect(moveMock.create).toHaveBeenCalledWith({ + entityId: asset.id, + newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`, + oldPath: asset.originalPath, + pathType: AssetPathType.ORIGINAL, + }); + }); it('should migrate previously failed move from original path when it still exists', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; @@ -267,7 +309,7 @@ describe(StorageTemplateService.name, () => { entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, - newPath: newPath, + newPath, }); expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); expect(storageMock.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 0ee5bdd3b56de..9836ad40ace47 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -89,8 +89,8 @@ export class StorageTemplateService { ); } - @OnEmit({ event: 'onConfigValidate' }) - onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) { + @OnEmit({ event: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); this.render(compiled, { @@ -227,7 +227,7 @@ export class StorageTemplateService { const storagePath = this.render(this.template.compiled, { asset, filename: sanitized, - extension: extension, + extension, albumName, }); const fullPath = path.normalize(path.join(rootPath, storagePath)); @@ -308,7 +308,7 @@ export class StorageTemplateService { filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', assetId: asset.id, //just throw into the root if it doesn't belong to an album - album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '.', + album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '', }; const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -329,6 +329,6 @@ export class StorageTemplateService { substitutions[token] = dt.toFormat(token); } - return template(substitutions); + return template(substitutions).replaceAll(/\/{2,}/gm, '/'); } } diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 1535d53d95e23..c3f2c06438340 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -14,7 +14,7 @@ export class StorageService { this.logger.setContext(StorageService.name); } - @OnEmit({ event: 'onBootstrap' }) + @OnEmit({ event: 'app.bootstrap' }) onBootstrap() { const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); this.storageRepository.mkdirSync(libraryBase); diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 6af43d6ebc41f..7da3fbd9be58d 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,7 +1,6 @@ import { Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { AccessCore } from 'src/cores/access.core'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; @@ -10,27 +9,24 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { requireAccess } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { setIsEqual } from 'src/utils/set'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; export class SyncService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IAuditRepository) private auditRepository: IAuditRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id const userId = dto.userId || auth.user.id; - await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); + await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); const assets = await this.assetRepository.getAllForUserFullSync({ ownerId: userId, updatedUntil: dto.updatedUntil, @@ -54,7 +50,7 @@ export class SyncService { return FULL_SYNC; } - await this.access.requirePermission(auth, Permission.TIMELINE_READ, dto.userIds); + await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds }); const limit = 10_000; const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds }); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index b4e6f903b1a03..5ec9ab7a5db05 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -18,6 +18,7 @@ import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { toPlainObject } from 'src/utils/object'; @Injectable() export class SystemConfigService { @@ -33,7 +34,7 @@ export class SystemConfigService { this.core.config$.subscribe((config) => this.setLogLevel(config)); } - @OnEmit({ event: 'onBootstrap', priority: -100 }) + @OnEmit({ event: 'app.bootstrap', priority: -100 }) async onBootstrap() { const config = await this.core.getConfig({ withCache: false }); this.core.config$.next(config); @@ -48,8 +49,8 @@ export class SystemConfigService { return mapConfig(defaults); } - @OnEmit({ event: 'onConfigValidate' }) - onConfigValidate({ newConfig, oldConfig }: ArgOf<'onConfigValidate'>) { + @OnEmit({ event: 'config.validate' }) + onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); } @@ -63,7 +64,7 @@ export class SystemConfigService { const oldConfig = await this.core.getConfig({ withCache: false }); try { - await this.eventRepository.emit('onConfigValidate', { newConfig: dto, oldConfig }); + await this.eventRepository.emit('config.validate', { newConfig: toPlainObject(dto), oldConfig }); } catch (error) { this.logger.warn(`Unable to save system config due to a validation error: ${error}`); throw new BadRequestException(error instanceof Error ? error.message : error); @@ -74,7 +75,7 @@ export class SystemConfigService { // TODO probably move web socket emits to a separate service this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); - await this.eventRepository.emit('onConfigUpdate', { newConfig, oldConfig }); + await this.eventRepository.emit('config.update', { newConfig, oldConfig }); return mapConfig(newConfig); } diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 4323c061e1f1d..de270777b06c5 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -1,21 +1,28 @@ import { BadRequestException } from '@nestjs/common'; -import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { TagType } from 'src/entities/tag.entity'; +import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { TagService } from 'src/services/tag.service'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; import { Mocked } from 'vitest'; describe(TagService.name, () => { let sut: TagService; + let accessMock: IAccessRepositoryMock; + let eventMock: Mocked; let tagMock: Mocked; beforeEach(() => { + accessMock = newAccessRepositoryMock(); + eventMock = newEventRepositoryMock(); tagMock = newTagRepositoryMock(); - sut = new TagService(tagMock); + sut = new TagService(accessMock, eventMock, tagMock); + + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); }); it('should work', () => { @@ -30,148 +37,216 @@ describe(TagService.name, () => { }); }); - describe('getById', () => { + describe('get', () => { it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.getById(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + tagMock.get.mockResolvedValue(null); + await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); + expect(tagMock.get).toHaveBeenCalledWith('tag-1'); }); it('should return a tag for a user', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - await expect(sut.getById(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + tagMock.get.mockResolvedValue(tagStub.tag1); + await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1); + expect(tagMock.get).toHaveBeenCalledWith('tag-1'); + }); + }); + + describe('create', () => { + it('should throw an error for no parent tag access', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(tagMock.create).not.toHaveBeenCalled(); + }); + + it('should create a tag with a parent', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + tagMock.create.mockResolvedValue(tagStub.tag1); + tagMock.get.mockResolvedValueOnce(tagStub.parent); + tagMock.get.mockResolvedValueOnce(tagStub.child); + await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined(); + expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' })); + }); + + it('should handle invalid parent ids', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(tagMock.create).not.toHaveBeenCalled(); }); }); describe('create', () => { it('should throw an error for a duplicate tag', async () => { - tagMock.hasName.mockResolvedValue(true); - await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(tagMock.hasName).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + tagMock.getByValue.mockResolvedValue(tagStub.tag1); + await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException); + expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); expect(tagMock.create).not.toHaveBeenCalled(); }); it('should create a new tag', async () => { tagMock.create.mockResolvedValue(tagStub.tag1); - await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).resolves.toEqual( - tagResponseStub.tag1, - ); + await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1); expect(tagMock.create).toHaveBeenCalledWith({ userId: authStub.admin.user.id, - name: 'tag-1', - type: TagType.CUSTOM, + value: 'tag-1', }); }); }); describe('update', () => { - it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).not.toHaveBeenCalled(); + it('should throw an error for no update permission', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(tagMock.update).not.toHaveBeenCalled(); }); it('should update a tag', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.update.mockResolvedValue(tagStub.tag1); - await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', name: 'tag-2' }); + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); + tagMock.update.mockResolvedValue(tagStub.color1); + await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1); + expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' }); + }); + }); + + describe('upsert', () => { + it('should upsert a new tag', async () => { + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined(); + expect(tagMock.upsertValue).toHaveBeenCalledWith({ + value: 'Parent', + userId: 'admin_id', + parentId: undefined, + }); + }); + + it('should upsert a nested tag', async () => { + tagMock.getByValue.mockResolvedValueOnce(null); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined(); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { + value: 'Parent', + userId: 'admin_id', + parent: undefined, + }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + value: 'Parent/Child', + userId: 'admin_id', + parent: expect.objectContaining({ id: 'tag-parent' }), + }); }); }); describe('remove', () => { it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).not.toHaveBeenCalled(); + expect(tagMock.delete).not.toHaveBeenCalled(); }); it('should remove a tag', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); + tagMock.get.mockResolvedValue(tagStub.tag1); await sut.remove(authStub.admin, 'tag-1'); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).toHaveBeenCalledWith(tagStub.tag1); + expect(tagMock.delete).toHaveBeenCalledWith('tag-1'); }); }); - describe('getAssets', () => { - it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).not.toHaveBeenCalled(); + describe('bulkTagAssets', () => { + it('should handle invalid requests', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + tagMock.upsertAssetIds.mockResolvedValue([]); + await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({ + count: 0, + }); + expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]); }); - it('should get the assets for a tag', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.getAssets.mockResolvedValue([assetStub.image]); - await sut.getAssets(authStub.admin, 'tag-1'); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.getAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + it('should upsert records', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + tagMock.upsertAssetIds.mockResolvedValue([ + { tagId: 'tag-1', assetId: 'asset-1' }, + { tagId: 'tag-1', assetId: 'asset-2' }, + { tagId: 'tag-1', assetId: 'asset-3' }, + { tagId: 'tag-2', assetId: 'asset-1' }, + { tagId: 'tag-2', assetId: 'asset-2' }, + { tagId: 'tag-2', assetId: 'asset-3' }, + ]); + await expect( + sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }), + ).resolves.toEqual({ + count: 6, + }); + expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([ + { tagId: 'tag-1', assetId: 'asset-1' }, + { tagId: 'tag-1', assetId: 'asset-2' }, + { tagId: 'tag-1', assetId: 'asset-3' }, + { tagId: 'tag-2', assetId: 'asset-1' }, + { tagId: 'tag-2', assetId: 'asset-2' }, + { tagId: 'tag-2', assetId: 'asset-3' }, + ]); }); }); describe('addAssets', () => { - it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.addAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.addAssets).not.toHaveBeenCalled(); + it('should handle invalid ids', async () => { + tagMock.get.mockResolvedValue(null); + tagMock.getAssetIds.mockResolvedValue(new Set([])); + await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ + { id: 'asset-1', success: false, error: 'no_permission' }, + ]); + expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); + expect(tagMock.addAssetIds).not.toHaveBeenCalled(); }); - it('should reject duplicate asset ids and accept new ones', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1')); + it('should accept accept ids that are new and reject the rest', async () => { + tagMock.get.mockResolvedValue(tagStub.tag1); + tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); await expect( sut.addAssets(authStub.admin, 'tag-1', { - assetIds: ['asset-1', 'asset-2'], + ids: ['asset-1', 'asset-2'], }), ).resolves.toEqual([ - { assetId: 'asset-1', success: false, error: AssetIdErrorReason.DUPLICATE }, - { assetId: 'asset-2', success: true }, + { id: 'asset-1', success: false, error: BulkIdErrorReason.DUPLICATE }, + { id: 'asset-2', success: true }, ]); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.hasAsset).toHaveBeenCalledTimes(2); - expect(tagMock.addAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-2']); + expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); }); }); describe('removeAssets', () => { it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.removeAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.removeAssets).not.toHaveBeenCalled(); + tagMock.get.mockResolvedValue(null); + tagMock.getAssetIds.mockResolvedValue(new Set()); + await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ + { id: 'asset-1', success: false, error: 'not_found' }, + ]); }); it('should accept accept ids that are tagged and reject the rest', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1')); + tagMock.get.mockResolvedValue(tagStub.tag1); + tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); await expect( sut.removeAssets(authStub.admin, 'tag-1', { - assetIds: ['asset-1', 'asset-2'], + ids: ['asset-1', 'asset-2'], }), ).resolves.toEqual([ - { assetId: 'asset-1', success: true }, - { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, + { id: 'asset-1', success: true }, + { id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.hasAsset).toHaveBeenCalledTimes(2); - expect(tagMock.removeAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-1']); + expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); }); }); }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index c04f9b14c4b33..97b0ef1be6843 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -1,102 +1,145 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; +import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { CreateTagDto, TagResponseDto, UpdateTagDto, mapTag } from 'src/dtos/tag.dto'; -import { ITagRepository } from 'src/interfaces/tag.interface'; +import { + TagBulkAssetsDto, + TagBulkAssetsResponseDto, + TagCreateDto, + TagResponseDto, + TagUpdateDto, + TagUpsertDto, + mapTag, +} from 'src/dtos/tag.dto'; +import { TagEntity } from 'src/entities/tag.entity'; +import { Permission } from 'src/enum'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; +import { checkAccess, requireAccess } from 'src/utils/access'; +import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { upsertTags } from 'src/utils/tag'; @Injectable() export class TagService { - constructor(@Inject(ITagRepository) private repository: ITagRepository) {} + constructor( + @Inject(IAccessRepository) private access: IAccessRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(ITagRepository) private repository: ITagRepository, + ) {} - getAll(auth: AuthDto) { - return this.repository.getAll(auth.user.id).then((tags) => tags.map((tag) => mapTag(tag))); + async getAll(auth: AuthDto) { + const tags = await this.repository.getAll(auth.user.id); + return tags.map((tag) => mapTag(tag)); } - async getById(auth: AuthDto, id: string): Promise { - const tag = await this.findOrFail(auth, id); + async get(auth: AuthDto, id: string): Promise { + await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [id] }); + const tag = await this.findOrFail(id); return mapTag(tag); } - async create(auth: AuthDto, dto: CreateTagDto) { - const duplicate = await this.repository.hasName(auth.user.id, dto.name); + async create(auth: AuthDto, dto: TagCreateDto) { + let parent: TagEntity | undefined; + if (dto.parentId) { + await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); + parent = (await this.repository.get(dto.parentId)) || undefined; + if (!parent) { + throw new BadRequestException('Tag not found'); + } + } + + const userId = auth.user.id; + const value = parent ? `${parent.value}/${dto.name}` : dto.name; + const duplicate = await this.repository.getByValue(userId, value); if (duplicate) { throw new BadRequestException(`A tag with that name already exists`); } - const tag = await this.repository.create({ - userId: auth.user.id, - name: dto.name, - type: dto.type, - }); + const tag = await this.repository.create({ userId, value, parent }); return mapTag(tag); } - async update(auth: AuthDto, id: string, dto: UpdateTagDto): Promise { - await this.findOrFail(auth, id); - const tag = await this.repository.update({ id, name: dto.name }); + async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise { + await requireAccess(this.access, { auth, permission: Permission.TAG_UPDATE, ids: [id] }); + + const { color } = dto; + const tag = await this.repository.update({ id, color }); return mapTag(tag); } + async upsert(auth: AuthDto, dto: TagUpsertDto) { + const tags = await upsertTags(this.repository, { userId: auth.user.id, tags: dto.tags }); + return tags.map((tag) => mapTag(tag)); + } + async remove(auth: AuthDto, id: string): Promise { - const tag = await this.findOrFail(auth, id); - await this.repository.remove(tag); + await requireAccess(this.access, { auth, permission: Permission.TAG_DELETE, ids: [id] }); + + // TODO sync tag changes for affected assets + + await this.repository.delete(id); } - async getAssets(auth: AuthDto, id: string): Promise { - await this.findOrFail(auth, id); - const assets = await this.repository.getAssets(auth.user.id, id); - return assets.map((asset) => mapAsset(asset)); - } + async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise { + const [tagIds, assetIds] = await Promise.all([ + checkAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }), + checkAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), + ]); - async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { - await this.findOrFail(auth, id); - - const results: AssetIdsResponseDto[] = []; - for (const assetId of dto.assetIds) { - const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId); - if (hasAsset) { - results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE }); - } else { - results.push({ assetId, success: true }); + const items: AssetTagItem[] = []; + for (const tagId of tagIds) { + for (const assetId of assetIds) { + items.push({ tagId, assetId }); } } - await this.repository.addAssets( - auth.user.id, - id, - results.filter((result) => result.success).map((result) => result.assetId), + const results = await this.repository.upsertAssetIds(items); + for (const assetId of new Set(results.map((item) => item.assetId))) { + await this.eventRepository.emit('asset.tag', { assetId }); + } + + return { count: results.length }; + } + + async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] }); + + const results = await addAssets( + auth, + { access: this.access, bulk: this.repository }, + { parentId: id, assetIds: dto.ids }, ); + for (const { id: assetId, success } of results) { + if (success) { + await this.eventRepository.emit('asset.tag', { assetId }); + } + } + return results; } - async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { - await this.findOrFail(auth, id); + async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] }); - const results: AssetIdsResponseDto[] = []; - for (const assetId of dto.assetIds) { - const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId); - if (hasAsset) { - results.push({ assetId, success: true }); - } else { - results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND }); + const results = await removeAssets( + auth, + { access: this.access, bulk: this.repository }, + { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE }, + ); + + for (const { id: assetId, success } of results) { + if (success) { + await this.eventRepository.emit('asset.untag', { assetId }); } } - await this.repository.removeAssets( - auth.user.id, - id, - results.filter((result) => result.success).map((result) => result.assetId), - ); - return results; } - private async findOrFail(auth: AuthDto, id: string) { - const tag = await this.repository.getById(auth.user.id, id); + private async findOrFail(id: string) { + const tag = await this.repository.get(id); if (!tag) { throw new BadRequestException('Tag not found'); } diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 44f1136da100e..bc08505b944eb 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject } from '@nestjs/common'; -import { AccessCore } from 'src/cores/access.core'; import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; @@ -7,18 +6,15 @@ import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { requireAccess } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; export class TimelineService { - private accessCore: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private repository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - ) { - this.accessCore = AccessCore.create(accessRepository); - } + ) {} async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { await this.timeBucketChecks(auth, dto); @@ -60,18 +56,22 @@ export class TimelineService { private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { if (dto.albumId) { - await this.accessCore.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]); + await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); } else { dto.userId = dto.userId || auth.user.id; } if (dto.userId) { - await this.accessCore.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]); + await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] }); if (dto.isArchived !== false) { - await this.accessCore.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]); + await requireAccess(this.access, { auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); } } + if (dto.tagId) { + await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] }); + } + if (dto.withPartners) { const requestedArchived = dto.isArchived === true || dto.isArchived === undefined; const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index 7e2582fd24b34..8376ba9221dea 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,6 +1,5 @@ import { Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { AccessCore } from 'src/cores/access.core'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { Permission } from 'src/enum'; @@ -8,23 +7,20 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface'; +import { requireAccess } from 'src/utils/access'; import { usePagination } from 'src/utils/pagination'; export class TrashService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, - ) { - this.access = AccessCore.create(accessRepository); - } + ) {} async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise { const { ids } = dto; - await this.access.requirePermission(auth, Permission.ASSET_RESTORE, ids); + await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids }); await this.restoreAndSend(auth, ids); } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 95eeed0475b7f..6a5b6ea06e520 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -45,7 +45,7 @@ export class UserAdminService { const { notify, ...rest } = dto; const user = await this.userCore.createUser(rest); - await this.eventRepository.emit('onUserSignup', { + await this.eventRepository.emit('user.signup', { notify: !!notify, id: user.id, tempPassword: user.shouldChangePassword ? rest.password : undefined, diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 2f04a510146cc..468e8c9bdd52c 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -37,7 +37,7 @@ export class VersionService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'onBootstrap' }) + @OnEmit({ event: 'app.bootstrap' }) async onBootstrap(): Promise { await this.handleVersionCheck(); } diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts new file mode 100644 index 0000000000000..3f9aa9f2f50a3 --- /dev/null +++ b/server/src/services/view.service.spec.ts @@ -0,0 +1,55 @@ +import { mapAsset } from 'src/dtos/asset-response.dto'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; + +import { ViewService } from 'src/services/view.service'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { authStub } from 'test/fixtures/auth.stub'; +import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; + +import { Mocked } from 'vitest'; + +describe(ViewService.name, () => { + let sut: ViewService; + let assetMock: Mocked; + + beforeEach(() => { + assetMock = newAssetRepositoryMock(); + + sut = new ViewService(assetMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('getUniqueOriginalPaths', () => { + it('should return unique original paths', async () => { + const mockPaths = ['path1', 'path2', 'path3']; + assetMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths); + + const result = await sut.getUniqueOriginalPaths(authStub.admin); + + expect(result).toEqual(mockPaths); + expect(assetMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); + }); + }); + + describe('getAssetsByOriginalPath', () => { + it('should return assets by original path', async () => { + const path = '/asset'; + + const asset1 = { ...assetStub.image, originalPath: '/asset/path1' }; + const asset2 = { ...assetStub.image, originalPath: '/asset/path2' }; + + const mockAssets = [asset1, asset2]; + + const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); + + assetMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets); + + const result = await sut.getAssetsByOriginalPath(authStub.admin, path); + expect(result).toEqual(mockAssetReponseDto); + await expect(assetMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); + }); + }); +}); diff --git a/server/src/services/view.service.ts b/server/src/services/view.service.ts new file mode 100644 index 0000000000000..1bf9a3408c63a --- /dev/null +++ b/server/src/services/view.service.ts @@ -0,0 +1,18 @@ +import { Inject } from '@nestjs/common'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; + +export class ViewService { + constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {} + + getUniqueOriginalPaths(auth: AuthDto): Promise { + return this.assetRepository.getUniqueOriginalPaths(auth.user.id); + } + + async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise { + const assets = await this.assetRepository.getAssetsByOriginalPath(auth.user.id, path); + + return assets.map((asset) => mapAsset(asset, { auth })); + } +} diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index cd24087d9bd2b..d3219a1a6c4b6 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -1,5 +1,9 @@ -import { Permission } from 'src/enum'; -import { setIsSuperset } from 'src/utils/set'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { AlbumUserRole, Permission } from 'src/enum'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; export type GrantedRequest = { requested: Permission[]; @@ -13,3 +17,277 @@ export const isGranted = ({ requested, current }: GrantedRequest) => { return setIsSuperset(new Set(current), new Set(requested)); }; + +export type AccessRequest = { + auth: AuthDto; + permission: Permission; + ids: Set | string[]; +}; + +type SharedLinkAccessRequest = { sharedLink: SharedLinkEntity; permission: Permission; ids: Set }; +type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set }; + +export const requireUploadAccess = (auth: AuthDto | null): AuthDto => { + if (!auth || (auth.sharedLink && !auth.sharedLink.allowUpload)) { + throw new UnauthorizedException(); + } + return auth; +}; + +export const requireAccess = async (access: IAccessRepository, request: AccessRequest) => { + const allowedIds = await checkAccess(access, request); + if (!setIsEqual(new Set(request.ids), allowedIds)) { + throw new BadRequestException(`Not found or no ${request.permission} access`); + } +}; + +export const checkAccess = async ( + access: IAccessRepository, + { ids, auth, permission }: AccessRequest, +): Promise> => { + const idSet = Array.isArray(ids) ? new Set(ids) : ids; + if (idSet.size === 0) { + return new Set(); + } + + return auth.sharedLink + ? checkSharedLinkAccess(access, { sharedLink: auth.sharedLink, permission, ids: idSet }) + : checkOtherAccess(access, { auth, permission, ids: idSet }); +}; + +const checkSharedLinkAccess = async ( + access: IAccessRepository, + request: SharedLinkAccessRequest, +): Promise> => { + const { sharedLink, permission, ids } = request; + const sharedLinkId = sharedLink.id; + + switch (permission) { + case Permission.ASSET_READ: { + return await access.asset.checkSharedLinkAccess(sharedLinkId, ids); + } + + case Permission.ASSET_VIEW: { + return await access.asset.checkSharedLinkAccess(sharedLinkId, ids); + } + + case Permission.ASSET_DOWNLOAD: { + return sharedLink.allowDownload ? await access.asset.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); + } + + case Permission.ASSET_UPLOAD: { + return sharedLink.allowUpload ? ids : new Set(); + } + + case Permission.ASSET_SHARE: { + // TODO: fix this to not use sharedLink.userId for access control + return await access.asset.checkOwnerAccess(sharedLink.userId, ids); + } + + case Permission.ALBUM_READ: { + return await access.album.checkSharedLinkAccess(sharedLinkId, ids); + } + + case Permission.ALBUM_DOWNLOAD: { + return sharedLink.allowDownload ? await access.album.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); + } + + case Permission.ALBUM_ADD_ASSET: { + return sharedLink.allowUpload ? await access.album.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); + } + + default: { + return new Set(); + } + } +}; + +const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest): Promise> => { + const { auth, permission, ids } = request; + + switch (permission) { + // uses album id + case Permission.ACTIVITY_CREATE: { + return await access.activity.checkCreateAccess(auth.user.id, ids); + } + + // uses activity id + case Permission.ACTIVITY_DELETE: { + const isOwner = await access.activity.checkOwnerAccess(auth.user.id, ids); + const isAlbumOwner = await access.activity.checkAlbumOwnerAccess(auth.user.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isAlbumOwner); + } + + case Permission.ASSET_READ: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_SHARE: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isPartner); + } + + case Permission.ASSET_VIEW: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_DOWNLOAD: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_UPDATE: { + return await access.asset.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ASSET_DELETE: { + return await access.asset.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_READ: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.VIEWER, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_ADD_ASSET: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.EDITOR, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_UPDATE: { + return await access.album.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_DELETE: { + return await access.album.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_SHARE: { + return await access.album.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_DOWNLOAD: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.VIEWER, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_REMOVE_ASSET: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.EDITOR, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ASSET_UPLOAD: { + return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + } + + case Permission.ARCHIVE_READ: { + return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + } + + case Permission.AUTH_DEVICE_DELETE: { + return await access.authDevice.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.TAG_ASSET: + case Permission.TAG_READ: + case Permission.TAG_UPDATE: + case Permission.TAG_DELETE: { + return await access.tag.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.TIMELINE_READ: { + const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + const isPartner = await access.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isPartner); + } + + case Permission.TIMELINE_DOWNLOAD: { + return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + } + + case Permission.MEMORY_READ: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_UPDATE: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_DELETE: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_DELETE: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_READ: { + return await access.person.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_UPDATE: { + return await access.person.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_MERGE: { + return await access.person.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_CREATE: { + return access.person.checkFaceOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_REASSIGN: { + return access.person.checkFaceOwnerAccess(auth.user.id, ids); + } + + case Permission.PARTNER_UPDATE: { + return await access.partner.checkUpdateAccess(auth.user.id, ids); + } + + case Permission.STACK_READ: { + return access.stack.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.STACK_UPDATE: { + return access.stack.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.STACK_DELETE: { + return access.stack.checkOwnerAccess(auth.user.id, ids); + } + + default: { + return new Set(); + } + } +}; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index aa77a0b144315..26d5f9292ebeb 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,9 +1,10 @@ -import { AccessCore } from 'src/cores/access.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { Permission } from 'src/enum'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; +import { AssetFileType, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { checkAccess } from 'src/utils/access'; export interface IBulkAsset { getAssetIds: (id: string, assetIds: string[]) => Promise>; @@ -11,17 +12,28 @@ export interface IBulkAsset { removeAssetIds: (id: string, assetIds: string[]) => Promise; } +const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType) => { + return (files || []).find((file) => file.type === type); +}; + +export const getAssetFiles = (files?: AssetFileEntity[]) => ({ + previewFile: getFileByType(files, AssetFileType.PREVIEW), + thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL), +}); + export const addAssets = async ( auth: AuthDto, - repositories: { accessRepository: IAccessRepository; repository: IBulkAsset }, + repositories: { access: IAccessRepository; bulk: IBulkAsset }, dto: { parentId: string; assetIds: string[] }, ) => { - const { accessRepository, repository } = repositories; - const access = AccessCore.create(accessRepository); - - const existingAssetIds = await repository.getAssetIds(dto.parentId, dto.assetIds); + const { access, bulk } = repositories; + const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds); const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id)); - const allowedAssetIds = await access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds); + const allowedAssetIds = await checkAccess(access, { + auth, + permission: Permission.ASSET_SHARE, + ids: notPresentAssetIds, + }); const results: BulkIdResponseDto[] = []; for (const assetId of dto.assetIds) { @@ -43,7 +55,7 @@ export const addAssets = async ( const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id); if (newAssetIds.length > 0) { - await repository.addAssetIds(dto.parentId, newAssetIds); + await bulk.addAssetIds(dto.parentId, newAssetIds); } return results; @@ -51,18 +63,17 @@ export const addAssets = async ( export const removeAssets = async ( auth: AuthDto, - repositories: { accessRepository: IAccessRepository; repository: IBulkAsset }, + repositories: { access: IAccessRepository; bulk: IBulkAsset }, dto: { parentId: string; assetIds: string[]; canAlwaysRemove: Permission }, ) => { - const { accessRepository, repository } = repositories; - const access = AccessCore.create(accessRepository); + const { access, bulk } = repositories; // check if the user can always remove from the parent album, memory, etc. - const canAlwaysRemove = await access.checkAccess(auth, dto.canAlwaysRemove, [dto.parentId]); - const existingAssetIds = await repository.getAssetIds(dto.parentId, dto.assetIds); + const canAlwaysRemove = await checkAccess(access, { auth, permission: dto.canAlwaysRemove, ids: [dto.parentId] }); + const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds); const allowedAssetIds = canAlwaysRemove.has(dto.parentId) ? existingAssetIds - : await access.checkAccess(auth, Permission.ASSET_SHARE, existingAssetIds); + : await checkAccess(access, { auth, permission: Permission.ASSET_SHARE, ids: existingAssetIds }); const results: BulkIdResponseDto[] = []; for (const assetId of dto.assetIds) { @@ -84,7 +95,7 @@ export const removeAssets = async ( const removedIds = results.filter(({ success }) => success).map(({ id }) => id); if (removedIds.length > 0) { - await repository.removeAssetIds(dto.parentId, removedIds); + await bulk.removeAssetIds(dto.parentId, removedIds); } return results; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 21a40ffcc8766..f3232eb78bb2e 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -71,7 +71,7 @@ export function searchAssetBuilder( builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds }); } - const path = _.pick(options, ['encodedVideoPath', 'originalPath', 'previewPath', 'thumbnailPath']); + const path = _.pick(options, ['encodedVideoPath', 'originalPath']); builder.andWhere(_.omitBy(path, _.isUndefined)); if (options.originalFileName) { diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index 996ea6c744569..50fe760a04b82 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -30,6 +30,7 @@ describe('mimeTypes', () => { { mimetype: 'image/kdc', extension: '.kdc' }, { mimetype: 'image/mrw', extension: '.mrw' }, { mimetype: 'image/nef', extension: '.nef' }, + { mimetype: 'image/nrw', extension: '.nrw' }, { mimetype: 'image/orf', extension: '.orf' }, { mimetype: 'image/ori', extension: '.ori' }, { mimetype: 'image/pef', extension: '.pef' }, diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 6b59d2cd41dc5..cbf6e5b489069 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -19,6 +19,7 @@ const raw: Record = { '.kdc': ['image/kdc', 'image/x-kodak-kdc'], '.mrw': ['image/mrw', 'image/x-minolta-mrw'], '.nef': ['image/nef', 'image/x-nikon-nef'], + '.nrw': ['image/nrw', 'image/x-nikon-nrw'], '.orf': ['image/orf', 'image/x-olympus-orf'], '.ori': ['image/ori', 'image/x-olympus-ori'], '.pef': ['image/pef', 'image/x-pentax-pef'], diff --git a/server/src/utils/object.ts b/server/src/utils/object.ts new file mode 100644 index 0000000000000..25ae42cba8b2a --- /dev/null +++ b/server/src/utils/object.ts @@ -0,0 +1,15 @@ +import { isEqual, isPlainObject } from 'lodash'; + +/** + * Deeply clones and converts a class instance to a plain object. + */ +export function toPlainObject(obj: T): T { + return isPlainObject(obj) ? obj : structuredClone(obj); +} + +/** + * Performs a deep comparison between objects, converting them to plain objects first if needed. + */ +export function isEqualObject(value: object, other: object): boolean { + return isEqual(toPlainObject(value), toPlainObject(other)); +} diff --git a/server/src/utils/request.ts b/server/src/utils/request.ts index f6edb2f8b34a6..19d3cac661248 100644 --- a/server/src/utils/request.ts +++ b/server/src/utils/request.ts @@ -2,4 +2,4 @@ export const fromChecksum = (checksum: string): Buffer => { return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex'); }; -export const fromMaybeArray = (param: string | string[] | undefined) => (Array.isArray(param) ? param[0] : param); +export const fromMaybeArray = (param: T | T[]) => (Array.isArray(param) ? param[0] : param); diff --git a/server/src/utils/tag.ts b/server/src/utils/tag.ts new file mode 100644 index 0000000000000..6d6c70f1d73ac --- /dev/null +++ b/server/src/utils/tag.ts @@ -0,0 +1,25 @@ +import { TagEntity } from 'src/entities/tag.entity'; +import { ITagRepository } from 'src/interfaces/tag.interface'; + +type UpsertRequest = { userId: string; tags: string[] }; +export const upsertTags = async (repository: ITagRepository, { userId, tags }: UpsertRequest) => { + tags = [...new Set(tags)]; + + const results: TagEntity[] = []; + + for (const tag of tags) { + const parts = tag.split('/'); + let parent: TagEntity | undefined; + + for (const part of parts) { + const value = parent ? `${parent.value}/${part}` : part; + parent = await repository.upsertValue({ userId, value, parent }); + } + + if (parent) { + results.push(parent); + } + } + + return results; +}; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 23df5e4f56217..5ee42224ba862 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,16 +1,37 @@ +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { AssetType } from 'src/enum'; +import { AssetFileType, AssetType } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { libraryStub } from 'test/fixtures/library.stub'; import { userStub } from 'test/fixtures/user.stub'; +const previewFile: AssetFileEntity = { + id: 'file-1', + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: '/uploads/user-id/thumbs/path.jpg', + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), +}; + +const thumbnailFile: AssetFileEntity = { + id: 'file-2', + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: '/uploads/user-id/webp/path.ext', + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), +}; + +const files: AssetFileEntity[] = [previewFile, thumbnailFile]; + export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => { return { id: stackId, - assets: assets, + assets, owner: assets[0].owner, ownerId: assets[0].ownerId, primaryAsset: assets[0], @@ -29,10 +50,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: 'upload/library/IMG_123.jpg', - previewPath: null, + files: [thumbnailFile], checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -63,10 +83,10 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: 'upload/library/IMG_456.jpg', - previewPath: '/uploads/user-id/thumbs/path.ext', + + files: [previewFile], checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -101,10 +121,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', + files, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: null, encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -136,10 +155,9 @@ export const assetStub = { ownerId: 'admin-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/admin-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), + files, type: AssetType.IMAGE, - thumbnailPath: '/uploads/admin-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -181,10 +199,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', + files, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -221,10 +238,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -261,10 +277,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -301,10 +316,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('path hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -341,10 +355,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -379,10 +392,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('path hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -419,10 +431,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -457,10 +468,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', + files, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2015-02-23T05:06:29.716Z'), @@ -496,10 +506,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.VIDEO, - thumbnailPath: null, + files: [previewFile], thumbhash: null, encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -548,8 +557,22 @@ export const assetStub = { isVisible: false, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - previewPath: '/uploads/user-id/thumbs/path.ext', - thumbnailPath: '/uploads/user-id/webp/path.ext', + files: [ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: '/uploads/user-id/thumbs/path.ext', + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: '/uploads/user-id/webp/path.ext', + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + }, + ], exifInfo: { fileSizeInByte: 100_000, timeZone: `America/New_York`, @@ -612,10 +635,9 @@ export const assetStub = { deviceId: 'device-id', checksum: Buffer.from('file hash', 'utf8'), originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', sidecarPath: null, type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], thumbhash: null, encodedVideoPath: null, createdAt: new Date('2023-02-22T05:06:29.716Z'), @@ -653,11 +675,10 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -687,11 +708,10 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -722,11 +742,10 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -758,10 +777,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.VIDEO, - thumbnailPath: null, + files: [previewFile], thumbhash: null, encodedVideoPath: '/encoded/video/path.mp4', createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -794,10 +812,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -833,10 +850,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -872,10 +888,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.dng', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -911,10 +926,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -952,10 +966,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 1635f8d24f35e..54898d8693e75 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -54,7 +54,6 @@ const assetResponse: AssetResponseDto = { originalMimeType: 'image/jpeg', originalPath: 'fake_path/jpeg', originalFileName: 'asset_1.jpeg', - resized: false, thumbhash: null, fileModifiedAt: today, isOffline: false, @@ -76,14 +75,12 @@ const assetResponse: AssetResponseDto = { isTrashed: false, libraryId: 'library-id', hasMetadata: true, - stackCount: 0, }; const assetResponseWithoutMetadata = { id: 'id_1', type: AssetType.VIDEO, originalMimeType: 'image/jpeg', - resized: false, thumbhash: null, localDateTime: today, duration: '0:00:00.00000', @@ -197,7 +194,6 @@ export const sharedLinkStub = { deviceId: 'device_id_1', type: AssetType.VIDEO, originalPath: 'fake_path/jpeg', - previewPath: '', checksum: Buffer.from('file hash', 'utf8'), fileModifiedAt: today, fileCreatedAt: today, @@ -214,7 +210,7 @@ export const sharedLinkStub = { objects: ['a', 'b', 'c'], asset: null as any, }, - thumbnailPath: '', + files: [], thumbhash: null, encodedVideoPath: '', duration: null, diff --git a/server/test/fixtures/tag.stub.ts b/server/test/fixtures/tag.stub.ts index 537c65db47e96..b245bfe9e5641 100644 --- a/server/test/fixtures/tag.stub.ts +++ b/server/test/fixtures/tag.stub.ts @@ -1,24 +1,65 @@ import { TagResponseDto } from 'src/dtos/tag.dto'; -import { TagEntity, TagType } from 'src/entities/tag.entity'; +import { TagEntity } from 'src/entities/tag.entity'; import { userStub } from 'test/fixtures/user.stub'; +const parent = Object.freeze({ + id: 'tag-parent', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Parent', + color: null, + userId: userStub.admin.id, + user: userStub.admin, +}); + +const child = Object.freeze({ + id: 'tag-child', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Parent/Child', + color: null, + parent, + userId: userStub.admin.id, + user: userStub.admin, +}); + export const tagStub = { tag1: Object.freeze({ id: 'tag-1', - name: 'Tag1', - type: TagType.CUSTOM, + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Tag1', + color: null, + userId: userStub.admin.id, + user: userStub.admin, + }), + parent, + child, + color1: Object.freeze({ + id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Tag1', + color: '#000000', userId: userStub.admin.id, user: userStub.admin, - renameTagId: null, - assets: [], }), }; export const tagResponseStub = { tag1: Object.freeze({ id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), name: 'Tag1', - type: 'CUSTOM', - userId: 'admin_id', + value: 'Tag1', + }), + color1: Object.freeze({ + id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + color: '#000000', + name: 'Tag1', + value: 'Tag1', }), }; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 8d69e35c05971..9e9bf5406bd6d 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -1,4 +1,3 @@ -import { AccessCore } from 'src/cores/access.core'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { Mocked, vitest } from 'vitest'; @@ -7,17 +6,15 @@ export interface IAccessRepositoryMock { asset: Mocked; album: Mocked; authDevice: Mocked; - timeline: Mocked; memory: Mocked; person: Mocked; partner: Mocked; + stack: Mocked; + timeline: Mocked; + tag: Mocked; } -export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => { - if (reset) { - AccessCore.reset(); - } - +export const newAccessRepositoryMock = (): IAccessRepositoryMock => { return { activity: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), @@ -42,10 +39,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, - timeline: { - checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), - }, - memory: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, @@ -58,5 +51,17 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => partner: { checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()), }, + + stack: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + + timeline: { + checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + + tag: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, }; }; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index f1091c041f8b1..69f07bf1051c4 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -42,5 +42,8 @@ export const newAssetRepositoryMock = (): Mocked => { getAllForUserFullSync: vitest.fn(), getChangedDeltaSync: vitest.fn(), getDuplicates: vitest.fn(), + upsertFile: vitest.fn(), + getAssetsByOriginalPath: vitest.fn(), + getUniqueOriginalPaths: vitest.fn(), }; }; diff --git a/server/test/repositories/library.repository.mock.ts b/server/test/repositories/library.repository.mock.ts index e5b8e5c7636d6..83e97c7ffaa9b 100644 --- a/server/test/repositories/library.repository.mock.ts +++ b/server/test/repositories/library.repository.mock.ts @@ -9,7 +9,6 @@ export const newLibraryRepositoryMock = (): Mocked => { softDelete: vitest.fn(), update: vitest.fn(), getStatistics: vitest.fn(), - getAssetIds: vitest.fn(), getAllDeleted: vitest.fn(), getAll: vitest.fn(), }; diff --git a/server/test/repositories/stack.repository.mock.ts b/server/test/repositories/stack.repository.mock.ts index 5567d2e1acca1..35d1614de7dd8 100644 --- a/server/test/repositories/stack.repository.mock.ts +++ b/server/test/repositories/stack.repository.mock.ts @@ -3,9 +3,11 @@ import { Mocked, vitest } from 'vitest'; export const newStackRepositoryMock = (): Mocked => { return { + search: vitest.fn(), create: vitest.fn(), update: vitest.fn(), delete: vitest.fn(), getById: vitest.fn(), + deleteAll: vitest.fn(), }; }; diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index a5123e0f36eaf..a3fc0e77e0312 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -4,14 +4,18 @@ import { Mocked, vitest } from 'vitest'; export const newTagRepositoryMock = (): Mocked => { return { getAll: vitest.fn(), - getById: vitest.fn(), + getByValue: vitest.fn(), + upsertValue: vitest.fn(), + upsertAssetTags: vitest.fn(), + + get: vitest.fn(), create: vitest.fn(), update: vitest.fn(), - remove: vitest.fn(), - hasAsset: vitest.fn(), - hasName: vitest.fn(), - getAssets: vitest.fn(), - addAssets: vitest.fn(), - removeAssets: vitest.fn(), + delete: vitest.fn(), + + getAssetIds: vitest.fn(), + addAssetIds: vitest.fn(), + removeAssetIds: vitest.fn(), + upsertAssetIds: vitest.fn(), }; }; diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs index 8811dafaf81df..3c0ea00c84c7a 100644 --- a/server/vitest.config.mjs +++ b/server/vitest.config.mjs @@ -6,6 +6,16 @@ export default defineConfig({ test: { root: './', globals: true, + coverage: { + provider: 'v8', + include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**'], + thresholds: { + lines: 80, + statements: 80, + branches: 85, + functions: 85, + }, + }, server: { deps: { fallbackCJS: true, diff --git a/web/.nvmrc b/web/.nvmrc index 8ce7030825b5e..3516580bbbc04 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/web/Dockerfile b/web/Dockerfile index 5e1dd28020ac3..4bc711e15ece5 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.16.0-alpine3.20@sha256:eb8101caae9ac02229bd64c024919fe3d4504ff7f329da79ca60a04db08cef52 +FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 RUN apk add --no-cache tini USER node diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index f4aec0e728011..f1ba46355f73a 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -87,6 +87,7 @@ export default [ '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-misused-promises': 'error', '@typescript-eslint/require-await': 'error', + 'object-shorthand': ['error', 'always'], }, }, { diff --git a/web/package-lock.json b/web/package-lock.json index fee3148631e7a..d62a186189d18 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.112.1", + "version": "1.113.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -24,6 +24,7 @@ "lodash-es": "^4.17.21", "luxon": "^3.4.4", "socket.io-client": "^4.7.4", + "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.0", "svelte-local-storage-store": "^0.6.4", "svelte-maplibre": "^0.9.0", @@ -73,13 +74,13 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.14", + "@types/node": "^20.16.1", "typescript": "^5.3.3" } }, @@ -984,9 +985,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.8.0.tgz", - "integrity": "sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.0.tgz", + "integrity": "sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==", "dev": true, "license": "MIT", "engines": { @@ -1882,169 +1883,224 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.1.tgz", + "integrity": "sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.1.tgz", + "integrity": "sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.1.tgz", + "integrity": "sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.1.tgz", + "integrity": "sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.1.tgz", + "integrity": "sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.1.tgz", + "integrity": "sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.1.tgz", + "integrity": "sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.1.tgz", + "integrity": "sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.1.tgz", + "integrity": "sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.1.tgz", + "integrity": "sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.1.tgz", + "integrity": "sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.1.tgz", + "integrity": "sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.1.tgz", + "integrity": "sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.1.tgz", + "integrity": "sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.1.tgz", + "integrity": "sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.1.tgz", + "integrity": "sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2056,9 +2112,9 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.2.tgz", - "integrity": "sha512-/EBFydZDwfwFfFEuF1vzUseBoRziwKP7AoHAwv+Ot3M084sE/HTVBHf9mCmXfdM9ijprY5YEugZjleflncX5fQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.4.tgz", + "integrity": "sha512-Qm4GAHCnRXwfWG9/AtnQ7mqjyjTs7i0Opyb8H2KH9rMR7fLxqiPx/tXeoE6HHo66+72CjyOb4nFH3lrejY4vzA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2066,9 +2122,9 @@ } }, "node_modules/@sveltejs/enhanced-img": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.1.tgz", - "integrity": "sha512-75A4YiXQp+GRc54EyiNOlhHnHt9O8e0CdCHLm3RWESLRaazd5OIciSa4SbKIo9DM84yGwSVShU0buyUmNJvgWg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.3.tgz", + "integrity": "sha512-nsqJkVuYLUXARDLjMoGKAt4oLzwtY8X2E8rIl/TJl7ueLjpTISxrAhVRN3r8yMO+R+so4G6Taiix2mpiPpqZeg==", "dev": true, "license": "MIT", "dependencies": { @@ -2082,9 +2138,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.20", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.20.tgz", - "integrity": "sha512-47rJ5BoYwURE/Rp7FNMLp3NzdbWC9DQ/PmKd0mebxT2D/PrPxZxcLImcD3zsWdX2iS6oJk8ITJbO/N2lWnnUqA==", + "version": "2.5.24", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.24.tgz", + "integrity": "sha512-Nr2oxsCsDfEkdS/zzQQQbsPYTbu692Qs3/iE3L7VHzCVjG2+WujF9oMUozWI7GuX98KxYSoPMlAsfmDLSg44hQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2109,15 +2165,15 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3" } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.1.tgz", - "integrity": "sha512-rimpFEAboBBHIlzISibg94iP09k/KYdHgVhJlcsTfn7KMBhc70jFX/GRWkRdFCc2fdnk+4+Bdfej23cMDnJS6A==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", + "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", "dev": true, "license": "MIT", "dependencies": { @@ -2506,17 +2562,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.2.0.tgz", + "integrity": "sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/type-utils": "8.2.0", + "@typescript-eslint/utils": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2540,16 +2596,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", + "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4" }, "engines": { @@ -2569,14 +2625,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", + "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2587,14 +2643,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.2.0.tgz", + "integrity": "sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.2.0", + "@typescript-eslint/utils": "8.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -2612,9 +2668,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", + "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", "dev": true, "license": "MIT", "engines": { @@ -2626,14 +2682,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", + "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/visitor-keys": "8.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2694,16 +2750,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.2.0.tgz", + "integrity": "sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@typescript-eslint/scope-manager": "8.2.0", + "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/typescript-estree": "8.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2717,13 +2773,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", + "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/types": "8.2.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -3966,9 +4022,9 @@ } }, "node_modules/eslint": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.8.0.tgz", - "integrity": "sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.0.tgz", + "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "license": "MIT", "dependencies": { @@ -3976,7 +4032,7 @@ "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.17.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.8.0", + "@eslint/js": "9.9.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -4015,6 +4071,14 @@ }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-compat-utils": { @@ -6956,10 +7020,11 @@ } }, "node_modules/rollup": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", - "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.1.tgz", + "integrity": "sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "1.0.5" }, @@ -6971,19 +7036,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.0", - "@rollup/rollup-android-arm64": "4.13.0", - "@rollup/rollup-darwin-arm64": "4.13.0", - "@rollup/rollup-darwin-x64": "4.13.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", - "@rollup/rollup-linux-arm64-gnu": "4.13.0", - "@rollup/rollup-linux-arm64-musl": "4.13.0", - "@rollup/rollup-linux-riscv64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-musl": "4.13.0", - "@rollup/rollup-win32-arm64-msvc": "4.13.0", - "@rollup/rollup-win32-ia32-msvc": "4.13.0", - "@rollup/rollup-win32-x64-msvc": "4.13.0", + "@rollup/rollup-android-arm-eabi": "4.21.1", + "@rollup/rollup-android-arm64": "4.21.1", + "@rollup/rollup-darwin-arm64": "4.21.1", + "@rollup/rollup-darwin-x64": "4.21.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.1", + "@rollup/rollup-linux-arm-musleabihf": "4.21.1", + "@rollup/rollup-linux-arm64-gnu": "4.21.1", + "@rollup/rollup-linux-arm64-musl": "4.21.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.1", + "@rollup/rollup-linux-riscv64-gnu": "4.21.1", + "@rollup/rollup-linux-s390x-gnu": "4.21.1", + "@rollup/rollup-linux-x64-gnu": "4.21.1", + "@rollup/rollup-linux-x64-musl": "4.21.1", + "@rollup/rollup-win32-arm64-msvc": "4.21.1", + "@rollup/rollup-win32-ia32-msvc": "4.21.1", + "@rollup/rollup-win32-x64-msvc": "4.21.1", "fsevents": "~2.3.2" } }, @@ -7651,9 +7719,9 @@ } }, "node_modules/svelte": { - "version": "4.2.18", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.18.tgz", - "integrity": "sha512-d0FdzYIiAePqRJEb90WlJDkjUEx42xhivxN8muUBmfZnP+tzUgz12DJ2hRJi8sIHCME7jeK1PTMgKPSfTd8JrA==", + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", + "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.1", @@ -7676,9 +7744,9 @@ } }, "node_modules/svelte-check": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.5.tgz", - "integrity": "sha512-3OGGgr9+bJ/+1nbPgsvulkLC48xBsqsgtc8Wam281H4G9F5v3mYGa2bHRsPuwHC5brKl4AxJH95QF73kmfihGQ==", + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.6.tgz", + "integrity": "sha512-ij0u4Lw/sOTREP13BdWZjiXD/BlHE6/e2e34XzmVmsp5IN4kVa3PWP65NM32JAgwjZlwBg/+JtiNV1MM8khu0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7724,6 +7792,12 @@ } } }, + "node_modules/svelte-gestures": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/svelte-gestures/-/svelte-gestures-5.0.4.tgz", + "integrity": "sha512-a6cnR46AfFZ8zZyvA38A1wBLBFI7rYuAWQnmv3yYgSdbaJK/U7JG34rSkjMCePRvf4BETJSDfMNngLs5zEAfbw==", + "license": "MIT" + }, "node_modules/svelte-hmr": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", @@ -8158,9 +8232,9 @@ } }, "node_modules/svelte-maplibre": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.10.tgz", - "integrity": "sha512-MYTMogRPzzgXDZGub4ivfdY1/P0uPxZfo/REQhne0zdBLc6cd4n1U4SqY9SoEGNN0CGW1KvSLfc7acx0kxzXlw==", + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.12.tgz", + "integrity": "sha512-92kYWgR+/qkO3lrsPoNFPpgULhcpKeOQ+IqqVsduDY7lOkhWKgCmx4r8i8UTfFZ6KGezSN0y7pweHEhBdhV3Xw==", "license": "MIT", "dependencies": { "d3-geo": "^3.1.0", @@ -8272,9 +8346,9 @@ "peer": true }, "node_modules/tailwindcss": { - "version": "3.4.9", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", - "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", + "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", "dev": true, "license": "MIT", "dependencies": { @@ -8758,15 +8832,15 @@ } }, "node_modules/vite": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", - "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", + "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.40", - "rollup": "^4.13.0" + "postcss": "^8.4.41", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" diff --git a/web/package.json b/web/package.json index 7d7751b67f2ff..383bde7ac88e5 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.112.1", + "version": "1.113.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", @@ -80,12 +80,13 @@ "lodash-es": "^4.17.21", "luxon": "^3.4.4", "socket.io-client": "^4.7.4", + "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.0", "svelte-local-storage-store": "^0.6.4", "svelte-maplibre": "^0.9.0", "thumbhash": "^0.1.1" }, "volta": { - "node": "20.16.0" + "node": "20.17.0" } } diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 4fcb901892306..b13a0c97d59de 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -27,3 +27,31 @@ interface Element { // Make optional, because it's unavailable on iPhones. requestFullscreen?(options?: FullscreenOptions): Promise; } + +import type en from '$lib/i18n/en.json'; +import 'svelte-i18n'; + +type NestedKeys = K extends keyof T & string + ? `${K}` | (T[K] extends object ? `${K}.${NestedKeys}` : never) + : never; + +declare module 'svelte-i18n' { + import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte'; + import type { Readable } from 'svelte/store'; + + type Translations = NestedKeys; + + interface MessageObject { + id: Translations; + locale?: string; + format?: string; + default?: string; + values?: InterpolationValues; + } + + type MessageFormatter = (id: Translations | MessageObject, options?: Omit) => string; + + const format: Readable; + const t: Readable; + const _: Readable; +} diff --git a/web/src/app.html b/web/src/app.html index d1db02f493483..778375c1e142b 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -1,5 +1,5 @@ - + @@ -14,35 +14,82 @@ %sveltekit.head% +

- +

- import { shortcut } from '$lib/actions/shortcut'; + import { shortcuts } from '$lib/actions/shortcut'; import Icon from '$lib/components/elements/icon.svelte'; import { mdiChevronRight } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -8,7 +8,12 @@ export let onNextAsset: () => void; - + diff --git a/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte b/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte index ef836b618c93f..9f8c638e1220a 100644 --- a/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte @@ -1,5 +1,5 @@ - + diff --git a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte index 40178c472d4be..bd18e0e8bffde 100644 --- a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte @@ -1,17 +1,17 @@ -{#if !isSharedLink() && $preferences?.rating?.enabled} -
+{#if !isSharedLink() && $preferences?.ratings.enabled} +
handlePromiseError(handleChangeRating(rating))} />
{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte new file mode 100644 index 0000000000000..434682f73ec27 --- /dev/null +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -0,0 +1,80 @@ + + +{#if isOwner && !isSharedLink()} +
+
+

{$t('tags').toUpperCase()}

+
+
+ {#each tags as tag (tag.id)} +
+ +

+ {tag.value} +

+
+ + +
+ {/each} + +
+
+{/if} + +{#if isOpen} + handleTag(tagsIds)} onCancel={handleCancel} /> +{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 4ff2084b9a46a..04f4476e07635 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -6,7 +6,7 @@ import { boundingBoxesArray } from '$lib/stores/people.store'; import { locale } from '$lib/stores/preferences.store'; import { featureFlags } from '$lib/stores/server-config.store'; - import { user } from '$lib/stores/user.store'; + import { preferences, user } from '$lib/stores/user.store'; import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError, isSharedLink } from '$lib/utils'; import { delay, isFlipped } from '$lib/utils/asset-utils'; import { @@ -43,6 +43,7 @@ import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte'; import { t } from 'svelte-i18n'; import { goto } from '$app/navigation'; + import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte'; export let asset: AssetResponseDto; export let albums: AlbumResponseDto[] = []; @@ -157,7 +158,7 @@ {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0} -
+

{$t('people').toUpperCase()}

@@ -212,7 +213,6 @@ title={person.name} widthStyle="90px" heightStyle="90px" - thumbhash={null} hidden={person.isHidden} />
@@ -473,11 +473,11 @@ {/if} {#if albums.length > 0} -
+

{$t('appears_in').toUpperCase()}

{#each albums as album} - -
+ +
{album.albumName} {/if} +{#if $preferences?.tags?.enabled} +
+ +
+{/if} + {#if showEditFaces} - import { BucketPosition } from '$lib/stores/assets.store'; - import { createEventDispatcher, onMount } from 'svelte'; - - export let once = false; - export let top = 0; - export let bottom = 0; - export let left = 0; - export let right = 0; - export let root: HTMLElement | null = null; - - export let intersecting = false; - let container: HTMLDivElement; - const dispatch = createEventDispatcher<{ - hidden: HTMLDivElement; - intersected: { - container: HTMLDivElement; - position: BucketPosition; - }; - }>(); - - onMount(() => { - if (typeof IntersectionObserver !== 'undefined') { - const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`; - const observer = new IntersectionObserver( - (entries) => { - intersecting = entries.some((entry) => entry.isIntersecting); - if (!intersecting) { - dispatch('hidden', container); - } - - if (intersecting && once) { - observer.unobserve(container); - } - - if (intersecting) { - let position: BucketPosition = BucketPosition.Visible; - if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) { - position = BucketPosition.Below; - } else if (entries[0].boundingClientRect.bottom < 0) { - position = BucketPosition.Above; - } - - dispatch('intersected', { - container, - position, - }); - } - }, - { - rootMargin, - root, - }, - ); - - observer.observe(container); - return () => observer.unobserve(container); - } - - // The following is a fallback for older browsers - function handler() { - const bcr = container.getBoundingClientRect(); - - intersecting = - bcr.bottom + bottom > 0 && - bcr.right + right > 0 && - bcr.top - top < window.innerHeight && - bcr.left - left < window.innerWidth; - - if (intersecting && once) { - window.removeEventListener('scroll', handler); - } - } - - window.addEventListener('scroll', handler); - return () => window.removeEventListener('scroll', handler); - }); - - -
- -
diff --git a/web/src/lib/components/asset-viewer/panorama-viewer.svelte b/web/src/lib/components/asset-viewer/panorama-viewer.svelte index 71ed4b899779a..dee9a5f8ec542 100644 --- a/web/src/lib/components/asset-viewer/panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/panorama-viewer.svelte @@ -1,11 +1,12 @@
@@ -34,7 +38,14 @@ {#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])} {:then [data, module, adapter, plugins, navbar]} - + {:catch} {$t('errors.failed_to_load_asset')} {/await} diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 0c0e707693b7c..30a2018febc80 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -1,6 +1,7 @@ {#if imageError} -
{$t('error_loading_image')}
+ {/if} + +
(imageError = imageLoaded = true)} /> {#if !imageLoaded} -
+
{:else if !imageError} -
+
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {/if}
+ + diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 6186358dcb149..e0adcdfc9dcc3 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -5,12 +5,16 @@ import { handleError } from '$lib/utils/handle-error'; import { AssetMediaSize } from '@immich/sdk'; import { createEventDispatcher, tick } from 'svelte'; + import { swipe } from 'svelte-gestures'; + import type { SwipeCustomEvent } from 'svelte-gestures'; import { fade } from 'svelte/transition'; import { t } from 'svelte-i18n'; export let assetId: string; export let loopVideo: boolean; export let checksum: string; + export let onPreviousAsset: () => void; + export let onNextAsset: () => void; let element: HTMLVideoElement | undefined = undefined; let isVideoLoading = true; @@ -49,6 +53,15 @@ handleError(error, $t('errors.unable_to_play_video')); } }; + + const onSwipe = (event: SwipeCustomEvent) => { + if (event.detail.direction === 'left') { + onNextAsset(); + } + if (event.detail.direction === 'right') { + onPreviousAsset(); + } + };
@@ -59,6 +72,8 @@ playsinline controls class="h-full object-contain" + use:swipe + on:swipe={onSwipe} on:canplay={(e) => handleCanPlay(e.currentTarget)} on:ended={() => dispatch('onVideoEnded')} on:volumechange={(e) => { diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index 129b6c8be796b..5f03784c42258 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -8,10 +8,20 @@ export let projectionType: string | null | undefined; export let checksum: string; export let loopVideo: boolean; + export let onPreviousAsset: () => void; + export let onNextAsset: () => void; {#if projectionType === ProjectionType.EQUIRECTANGULAR} {:else} - + {/if} diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte new file mode 100644 index 0000000000000..216a8f6f848b0 --- /dev/null +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -0,0 +1,25 @@ + + +
+
+ + {#if !noMessage} +
{$t('error_loading_image')}
+ {/if} +
+
+ +
+
+
diff --git a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts index 91ea7d3ab1e91..2525b86160dc9 100644 --- a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts +++ b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts @@ -3,8 +3,8 @@ import { render } from '@testing-library/svelte'; describe('ImageThumbnail component', () => { beforeAll(() => { - Object.defineProperty(HTMLImageElement.prototype, 'decode', { - value: vi.fn(), + Object.defineProperty(HTMLImageElement.prototype, 'complete', { + value: true, }); }); @@ -12,13 +12,11 @@ describe('ImageThumbnail component', () => { const sut = render(ImageThumbnail, { url: 'http://localhost/img.png', altText: 'test', - thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', + base64ThumbHash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', widthStyle: '250px', }); - const [_, thumbhash] = sut.getAllByRole('img'); - expect(thumbhash.getAttribute('src')).toContain( - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAgCAYAAAD5VeO1AAAMRklEQVR4AQBdAKL/', // truncated - ); + const thumbhash = sut.getByTestId('thumbhash'); + expect(thumbhash).not.toBeFalsy(); }); }); diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 8e391ecb59b65..38f2ff4dbb506 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,17 +1,19 @@ -{altText} +{#if errored} + +
{$t('error_loading_image')}
+
+{:else} + {loaded +{/if} {#if hidden}
@@ -57,18 +80,18 @@
{/if} -{#if thumbhash && !complete} - {altText} {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 600c30e265ae9..69f777f530658 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -1,5 +1,5 @@ - -
- {#if intersecting} +
+ {#if !loaded && asset.thumbhash} + + {/if} + + {#if display} + +
{ + if (evt.key === 'Enter') { + callClickHandlers(); + } + }} + tabindex={0} + on:click={handleClick} + role="link" + > + {#if mouseOver} + + evt.preventDefault()} + tabindex={0} + > + + {/if}
{#if !readonly && (mouseOver || selected || selectionCandidate)} @@ -170,37 +283,32 @@ - {#if asset.stackCount && showStackedIcon} + {#if asset.stack && showStackedIcon}
-

{asset.stackCount.toLocaleString($locale)}

+

{asset.stack.assetCount.toLocaleString($locale)}

{/if} - {#if asset.resized} - - {:else} -
- -
- {/if} + (loaded = true)} + /> {#if asset.type === AssetTypeEnum.Video}
{/if} - {/if} - - +
+ {/if} +
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 5c4196e54bee8..5cac0b1945f1a 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -3,7 +3,11 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; + import { AssetStore } from '$lib/stores/assets.store'; + import { generateId } from '$lib/utils/generate-id'; + import { onDestroy } from 'svelte'; + export let assetStore: AssetStore | undefined = undefined; export let url: string; export let durationInSeconds = 0; export let enablePlayback = false; @@ -13,6 +17,7 @@ export let playIcon = mdiPlayCircleOutline; export let pauseIcon = mdiPauseCircleOutline; + const componentId = generateId(); let remainingSeconds = durationInSeconds; let loading = true; let error = false; @@ -27,6 +32,43 @@ player.src = ''; } } + const onMouseEnter = () => { + if (assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + if (playbackOnIconHover) { + enablePlayback = true; + } + }, + }); + } else { + if (playbackOnIconHover) { + enablePlayback = true; + } + } + }; + + const onMouseLeave = () => { + if (assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + if (playbackOnIconHover) { + enablePlayback = false; + } + }, + }); + } else { + if (playbackOnIconHover) { + enablePlayback = false; + } + } + }; + + onDestroy(() => { + assetStore?.taskManager.removeAllTasksForComponent(componentId); + });
@@ -37,19 +79,7 @@ {/if} - { - if (playbackOnIconHover) { - enablePlayback = true; - } - }} - on:mouseleave={() => { - if (playbackOnIconHover) { - enablePlayback = false; - } - }} - > + {#if enablePlayback} {#if loading} diff --git a/web/src/lib/components/error.svelte b/web/src/lib/components/error.svelte new file mode 100644 index 0000000000000..cbc8c26bd8521 --- /dev/null +++ b/web/src/lib/components/error.svelte @@ -0,0 +1,105 @@ + + +
+
+
+ + + +
+
+ +
+
+
+
+
+

+ 🚨 {$t('error_title')} +

+
+ handleCopy()} + /> +
+
+ +
+ +
+
+

{error?.message} ({error?.code})

+ {#if error?.stack} + +
{error?.stack || 'No stack'}
+ {/if} +
+
+ +
+ + +
+
+
+
+
diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index 0dd4251dabadc..eba26e6e618b3 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -113,7 +113,6 @@ title={$getPersonNameWithHiddenValue(person.name, person.isHidden)} widthStyle="90px" heightStyle="90px" - thumbhash={null} hidden={person.isHidden} />
diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte index ea1445a938320..71358361ce257 100644 --- a/web/src/lib/components/faces-page/merge-face-selector.svelte +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -81,7 +81,7 @@ const mergedPerson = await getPerson({ id: person.id }); const count = results.filter(({ success }) => success).length; notificationController.show({ - message: $t('merged_people_count', { values: { count: count } }), + message: $t('merged_people_count', { values: { count } }), type: NotificationType.Info, }); dispatch('merge', mergedPerson); diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 712100763c696..fd4fbdf964072 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -265,8 +265,6 @@ title={$t('face_unassigned')} widthStyle="90px" heightStyle="90px" - thumbhash={null} - hidden={false} /> {:then data}
diff --git a/web/src/lib/components/photos-page/actions/remove-from-album.svelte b/web/src/lib/components/photos-page/actions/remove-from-album.svelte index d76ea7b275560..2384f95d2e0a1 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-album.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-album.svelte @@ -40,7 +40,7 @@ const count = results.filter(({ success }) => success).length; notificationController.show({ type: NotificationType.Info, - message: $t('assets_removed_count', { values: { count: count } }), + message: $t('assets_removed_count', { values: { count } }), }); clearSelect(); diff --git a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte index 0c785830d0937..e838f0813d461 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte @@ -45,7 +45,7 @@ notificationController.show({ type: NotificationType.Info, - message: $t('assets_removed_count', { values: { count: count } }), + message: $t('assets_removed_count', { values: { count } }), }); clearSelect(); diff --git a/web/src/lib/components/photos-page/actions/stack-action.svelte b/web/src/lib/components/photos-page/actions/stack-action.svelte index 5e425c4f00a82..c1f2bf212ff50 100644 --- a/web/src/lib/components/photos-page/actions/stack-action.svelte +++ b/web/src/lib/components/photos-page/actions/stack-action.svelte @@ -2,7 +2,7 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte'; import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js'; - import { stackAssets, unstackAssets } from '$lib/utils/asset-utils'; + import { stackAssets, deleteStack } from '$lib/utils/asset-utils'; import type { OnStack, OnUnstack } from '$lib/utils/actions'; import { t } from 'svelte-i18n'; @@ -30,8 +30,7 @@ if (!stack) { return; } - const assets = [selectedAssets[0], ...stack]; - const unstackedAssets = await unstackAssets(assets); + const unstackedAssets = await deleteStack([stack.id]); if (unstackedAssets) { onUnstack?.(unstackedAssets); } diff --git a/web/src/lib/components/photos-page/actions/tag-action.svelte b/web/src/lib/components/photos-page/actions/tag-action.svelte new file mode 100644 index 0000000000000..77e91d7235aa4 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/tag-action.svelte @@ -0,0 +1,47 @@ + + +{#if menuItem} + +{/if} + +{#if !menuItem} + {#if loading} + + {:else} + + {/if} +{/if} + +{#if isOpen} + handleTag(tagIds)} onCancel={handleCancel} /> +{/if} diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index dd57160fb4c51..5cbc2e7dcaaca 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -1,84 +1,69 @@ -
- {#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)} - {@const asset = groupAssets[0]} - {@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))} - +
+ {#each dateGroups as dateGroup, groupIndex (dateGroup.date)} + {@const display = + dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)} -
{ - isMouseOverGroup = true; - assetMouseEventHandler(groupTitle, null); - }} - on:mouseleave={() => { - isMouseOverGroup = false; - assetMouseEventHandler(groupTitle, null); + id="date-group" + use:intersectionObserver={{ + onIntersect: () => { + $assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () => + assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }), + ); + }, + onSeparate: () => { + $assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () => + assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }), + ); + }, + top: INTERSECTION_ROOT_TOP, + bottom: INTERSECTION_ROOT_BOTTOM, + root: assetGridElement, + disabled: INTERSECTION_DISABLED, }} + data-display={display} + data-date-group={dateGroup.date} + style:height={dateGroup.height + 'px'} + style:width={dateGroup.geometry.containerWidth + 'px'} + style:overflow={'clip'} > - -
- {#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))} + {#if !display} + + {/if} + {#if display} + + +
+ $assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + isMouseOverGroup = true; + assetMouseEventHandler(dateGroup.groupTitle, null); + }, + })} + on:mouseleave={() => { + $assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + isMouseOverGroup = false; + assetMouseEventHandler(dateGroup.groupTitle, null); + }, + }); + }} + > +
handleSelectGroup(groupTitle, groupAssets)} - on:keydown={() => handleSelectGroup(groupTitle, groupAssets)} + class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" + style:width={dateGroup.geometry.containerWidth + 'px'} > - {#if $selectedGroup.has(groupTitle)} - - {:else} - + {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))} +
handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} + on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} + > + {#if $selectedGroup.has(dateGroup.groupTitle)} + + {:else} + + {/if} +
{/if} + + + {dateGroup.groupTitle} +
- {/if} - - {groupTitle} - -
- - -
- {#each groupAssets as asset, index (asset.id)} - {@const box = geometry[groupIndex].boxes[index]} +
- { - if (isSelectionMode || $isMultiSelectState) { - event.preventDefault(); - assetSelectHandler(asset, groupAssets, groupTitle); - return; - } - - assetViewingStore.setAsset(asset); - }} - on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)} - on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)} - selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} - selectionCandidate={$assetSelectionCandidates.has(asset)} - disabled={$assetStore.albumAssets.has(asset.id)} - thumbnailWidth={box.width} - thumbnailHeight={box.height} - /> + {#each dateGroup.assets as asset, index (asset.id)} + {@const box = dateGroup.geometry.boxes[index]} + +
onAssetInGrid?.(asset), + top: `${-TITLE_HEIGHT}px`, + bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`, + right: `${-(viewport.width - 1)}px`, + root: assetGridElement, + }} + data-asset-id={asset.id} + class="absolute" + style:width={box.width + 'px'} + style:height={box.height + 'px'} + style:top={box.top + 'px'} + style:left={box.left + 'px'} + > + onRetrieveElement(dateGroup, asset, element)} + showStackedIcon={withStacked} + {showArchiveIcon} + {asset} + {groupIndex} + onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)} + onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)} + onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)} + selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} + selectionCandidate={$assetSelectionCandidates.has(asset)} + disabled={$assetStore.albumAssets.has(asset.id)} + thumbnailWidth={box.width} + thumbnailHeight={box.height} + /> +
+ {/each}
- {/each} -
+
+ {/if}
{/each}
diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 3e0935d938886..94e7803b97ff8 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -1,11 +1,17 @@ @@ -426,79 +762,99 @@ {#if showShortcuts} (showShortcuts = !showShortcuts)} /> {/if} - - (element.scrollTop = detail)} -/> +{#if assetStore.buckets.length > 0} + +{/if}
((viewport.width = width), (viewport.height = height))} bind:this={element} - on:scroll={handleTimelineScroll} + on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())} > - - {#if showSkeleton} -
-
-
- {#each Array.from({ length: 100 }) as _} -
- {/each} -
-
- {/if} - - {#if element} +
((topSectionHeight = height), (topSectionOffset = target.offsetTop))} + class:invisible={showSkeleton} + > - - {#if isEmpty} + {/if} -
- {#each $assetStore.buckets as bucket (bucket.bucketDate)} - assetStore.cancelBucket(bucket)} - let:intersecting - top={750} - bottom={750} - root={element} - > -
- {#if intersecting} - handleGroupSelect(group.title, group.assets)} - on:shift={handleScrollTimeline} - on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} - on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} - assets={bucket.assets} - bucketDate={bucket.bucketDate} - bucketHeight={bucket.bucketHeight} - {viewport} - /> - {/if} -
-
- {/each} -
- {/if} +
+ +
+ {#each $assetStore.buckets as bucket (bucket.bucketDate)} + {@const isPremeasure = preMeasure.includes(bucket)} + {@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure} +
handleIntersect(bucket), + onSeparate: () => handleSeparate(bucket), + top: BUCKET_INTERSECTION_ROOT_TOP, + bottom: BUCKET_INTERSECTION_ROOT_BOTTOM, + root: element, + }} + data-bucket-display={bucket.intersecting} + data-bucket-date={bucket.bucketDate} + style:height={bucket.bucketHeight + 'px'} + > + {#if display && !bucket.measured} + (preMeasure = preMeasure.filter((b) => b !== bucket))} + > + {/if} + + {#if !display || !bucket.measured} + + {/if} + {#if display && bucket.measured} + handleGroupSelect(group.title, group.assets)} + on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} + on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} + /> + {/if} +
+ {/each} +
+
@@ -522,7 +878,7 @@ diff --git a/web/src/lib/components/photos-page/measure-date-group.svelte b/web/src/lib/components/photos-page/measure-date-group.svelte new file mode 100644 index 0000000000000..f458fe40dd84b --- /dev/null +++ b/web/src/lib/components/photos-page/measure-date-group.svelte @@ -0,0 +1,87 @@ + + + + +
+ {#each bucket.dateGroups as dateGroup} +
+
$assetStore.updateBucketDateGroup(bucket, dateGroup, { height })}> +
+ + {dateGroup.groupTitle} + +
+ +
+
+
+ {/each} +
diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 43c2958944e47..5bc55796aecb2 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -1,4 +1,5 @@ + +
+ {#if title} +
+ {title} +
+ {/if} +
+
+ + diff --git a/web/src/lib/components/shared-components/__test__/star-rating.spec.ts b/web/src/lib/components/shared-components/__test__/star-rating.spec.ts new file mode 100644 index 0000000000000..cf33573b771b9 --- /dev/null +++ b/web/src/lib/components/shared-components/__test__/star-rating.spec.ts @@ -0,0 +1,78 @@ +import StarRating from '$lib/components/shared-components/star-rating.svelte'; +import { render } from '@testing-library/svelte'; + +describe('StarRating component', () => { + it('renders correctly', () => { + const component = render(StarRating, { + count: 3, + rating: 2, + readOnly: false, + onRating: vi.fn(), + }); + const container = component.getByTestId('star-container') as HTMLImageElement; + expect(container.className).toBe('flex flex-row'); + + const radioButtons = component.getAllByRole('radio') as HTMLInputElement[]; + expect(radioButtons.length).toBe(3); + const labels = component.getAllByTestId('star') as HTMLLabelElement[]; + expect(labels.length).toBe(3); + const labelText = component.getAllByText('rating_count') as HTMLSpanElement[]; + expect(labelText.length).toBe(3); + const clearButton = component.getByRole('button') as HTMLButtonElement; + expect(clearButton).toBeInTheDocument(); + + // Check the clear button content + expect(clearButton.textContent).toBe('rating_clear'); + + // Check the initial state + expect(radioButtons[0].checked).toBe(false); + expect(radioButtons[1].checked).toBe(true); + expect(radioButtons[2].checked).toBe(false); + + // Check the radio button attributes + for (const [index, radioButton] of radioButtons.entries()) { + expect(radioButton.id).toBe(labels[index].htmlFor); + expect(radioButton.name).toBe('stars'); + expect(radioButton.value).toBe((index + 1).toString()); + expect(radioButton.disabled).toBe(false); + expect(radioButton.className).toBe('sr-only'); + } + + // Check the label attributes + for (const label of labels) { + expect(label.className).toBe('cursor-pointer'); + expect(label.tabIndex).toBe(-1); + } + }); + + it('renders correctly with readOnly', () => { + const component = render(StarRating, { + count: 3, + rating: 2, + readOnly: true, + onRating: vi.fn(), + }); + const radioButtons = component.getAllByRole('radio') as HTMLInputElement[]; + expect(radioButtons.length).toBe(3); + const labels = component.getAllByTestId('star') as HTMLLabelElement[]; + expect(labels.length).toBe(3); + const clearButton = component.queryByRole('button'); + expect(clearButton).toBeNull(); + + // Check the initial state + expect(radioButtons[0].checked).toBe(false); + expect(radioButtons[1].checked).toBe(true); + expect(radioButtons[2].checked).toBe(false); + + // Check the radio button attributes + for (const [index, radioButton] of radioButtons.entries()) { + expect(radioButton.id).toBe(labels[index].htmlFor); + expect(radioButton.disabled).toBe(true); + } + + // Check the label attributes + for (const label of labels) { + expect(label.className).toBe(''); + } + }); +}); diff --git a/web/src/lib/components/shared-components/autogrow-textarea.svelte b/web/src/lib/components/shared-components/autogrow-textarea.svelte index 4dd0c193f9de4..efbcf218e6c09 100644 --- a/web/src/lib/components/shared-components/autogrow-textarea.svelte +++ b/web/src/lib/components/shared-components/autogrow-textarea.svelte @@ -16,7 +16,7 @@ // re-visit with svelte 5. runes will make this better. // eslint-disable-next-line @typescript-eslint/no-unused-expressions newContent; - if (textarea) { + if (textarea && newContent.length > 0) { void tick().then(() => autoGrowHeight(textarea)); } } diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 7cdcef9e40681..d3e022a75933c 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -1,5 +1,6 @@
- +
- +
diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte index 97c3aaf17e62c..c50a07ad37413 100644 --- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -8,7 +8,6 @@ import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk'; import { mdiContentCopy, mdiLink } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; - import DropdownButton, { type DropDownOption } from '../dropdown-button.svelte'; import { NotificationType, notificationController } from '../notification/notification'; import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte'; import SettingSwitch from '../settings/setting-switch.svelte'; @@ -16,6 +15,7 @@ import { t } from 'svelte-i18n'; import { locale } from '$lib/stores/preferences.store'; import { DateTime, Duration } from 'luxon'; + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; export let onClose: () => void; export let albumId: string | undefined = undefined; @@ -27,7 +27,7 @@ let allowDownload = true; let allowUpload = false; let showMetadata = true; - let expirationOption: DropDownOption | undefined; + let expirationOption: number = 0; let password = ''; let shouldChangeExpirationTime = false; let enablePassword = false; @@ -48,14 +48,12 @@ ]; $: relativeTime = new Intl.RelativeTimeFormat($locale); - $: expiredDateOption = [ - { label: $t('never'), value: 0 }, - ...expirationOptions.map( - ([value, unit]): DropDownOption => ({ - label: relativeTime.format(value, unit), - value: Duration.fromObject({ [unit]: value }).toMillis(), - }), - ), + $: expiredDateOptions = [ + { text: $t('never'), value: 0 }, + ...expirationOptions.map(([value, unit]) => ({ + text: relativeTime.format(value, unit), + value: Duration.fromObject({ [unit]: value }).toMillis(), + })), ]; $: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual; @@ -82,8 +80,7 @@ } const handleCreateSharedLink = async () => { - const expirationDate = - expirationOption && expirationOption.value > 0 ? DateTime.now().plus(expirationOption.value).toISO() : undefined; + const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : undefined; try { const data = await createSharedLink({ @@ -112,8 +109,7 @@ } try { - const expirationDate = - expirationOption && expirationOption.value > 0 ? DateTime.now().plus(expirationOption.value).toISO() : null; + const expirationDate = expirationOption > 0 ? DateTime.now().plus(expirationOption).toISO() : null; await updateSharedLink({ id: editingLink.id, @@ -212,19 +208,18 @@
-
- {#if editingLink} -

- -

- {:else} -

{$t('expire_after')}

- {/if} - - + +
+ {/if} +
+
diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte index 466b3d083e48a..e84d2d66f08f2 100644 --- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte +++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte @@ -26,12 +26,79 @@ const onDrop = async (e: DragEvent) => { dragStartTarget = null; - await handleFiles(e.dataTransfer?.files); + await handleDataTransfer(e.dataTransfer); }; - const onPaste = ({ clipboardData }: ClipboardEvent) => handleFiles(clipboardData?.files); + const onPaste = ({ clipboardData }: ClipboardEvent) => handleDataTransfer(clipboardData); - const handleFiles = async (files?: FileList) => { + const handleDataTransfer = async (dataTransfer?: DataTransfer | null) => { + if (!dataTransfer) { + return; + } + + if (!browserSupportsDirectoryUpload()) { + return handleFiles(dataTransfer.files); + } + + const entries: FileSystemEntry[] = []; + const files: File[] = []; + for (const item of dataTransfer.items) { + const entry = item.webkitGetAsEntry(); + if (entry) { + entries.push(entry); + continue; + } + + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + + const directoryFiles = await getAllFilesFromTransferEntries(entries); + return handleFiles([...files, ...directoryFiles]); + }; + + const browserSupportsDirectoryUpload = () => typeof DataTransferItem.prototype.webkitGetAsEntry === 'function'; + + const getAllFilesFromTransferEntries = async (transferEntries: FileSystemEntry[]): Promise => { + const allFiles: File[] = []; + let entriesToCheckForSubDirectories = [...transferEntries]; + while (entriesToCheckForSubDirectories.length > 0) { + const currentEntry = entriesToCheckForSubDirectories.pop(); + + if (isFileSystemDirectoryEntry(currentEntry)) { + entriesToCheckForSubDirectories = entriesToCheckForSubDirectories.concat( + await getContentsFromFileSystemDirectoryEntry(currentEntry), + ); + } else if (isFileSystemFileEntry(currentEntry)) { + allFiles.push(await getFileFromFileSystemEntry(currentEntry)); + } + } + + return allFiles; + }; + + const isFileSystemDirectoryEntry = (entry?: FileSystemEntry): entry is FileSystemDirectoryEntry => + !!entry && entry.isDirectory; + const isFileSystemFileEntry = (entry?: FileSystemEntry): entry is FileSystemFileEntry => !!entry && entry.isFile; + + const getFileFromFileSystemEntry = async (fileSystemFileEntry: FileSystemFileEntry): Promise => { + return new Promise((resolve, reject) => { + fileSystemFileEntry.file(resolve, reject); + }); + }; + + const getContentsFromFileSystemDirectoryEntry = async ( + fileSystemDirectoryEntry: FileSystemDirectoryEntry, + ): Promise => { + return new Promise((resolve, reject) => { + const reader = fileSystemDirectoryEntry.createReader(); + reader.readEntries(resolve, reject); + }); + }; + + const handleFiles = async (files?: FileList | File[]) => { if (!files) { return; } diff --git a/web/src/lib/components/shared-components/dropdown-button.svelte b/web/src/lib/components/shared-components/dropdown-button.svelte deleted file mode 100644 index 450b3d5ce6381..0000000000000 --- a/web/src/lib/components/shared-components/dropdown-button.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - - - -
- - - {#if isOpen} -
- {#each options as option} - - {/each} -
- {/if} -
- - diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 337b681a22413..c7b49f60127f5 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -4,25 +4,26 @@ import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import { AppRoute, AssetAction } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { BucketPosition, Viewport } from '$lib/stores/assets.store'; + import type { Viewport } from '$lib/stores/assets.store'; import { getAssetRatio } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { navigate } from '$lib/utils/navigation'; import { calculateWidth } from '$lib/utils/timeline-util'; import { type AssetResponseDto } from '@immich/sdk'; import justifiedLayout from 'justified-layout'; - import { createEventDispatcher, onDestroy } from 'svelte'; + import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import Portal from '../portal/portal.svelte'; - - const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>(); + import { handlePromiseError } from '$lib/utils'; export let assets: AssetResponseDto[]; export let selectedAssets: Set = new Set(); export let disableAssetSelect = false; export let showArchiveIcon = false; export let viewport: Viewport; + export let onIntersected: (() => void) | undefined = undefined; + export let showAssetName = false; let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; @@ -121,27 +122,32 @@ class="absolute" style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i] .top}px; left: {geometry.boxes[i].left}px" + title={showAssetName ? asset.originalFileName : ''} > { - e.preventDefault(); - + onClick={(asset) => { if (isMultiSelectionMode) { selectAssetHandler(asset); return; } - await viewAssetHandler(asset); + void viewAssetHandler(asset); }} - on:select={(e) => selectAssetHandler(e.detail.asset)} - on:intersected={(event) => - i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined} + onSelect={(asset) => selectAssetHandler(asset)} + onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)} selected={selectedAssets.has(asset)} {showArchiveIcon} thumbnailWidth={geometry.boxes[i].width} thumbnailHeight={geometry.boxes[i].height} /> + {#if showAssetName} +
+ {asset.originalFileName} +
+ {/if} {/each} @@ -150,6 +156,15 @@ {#if $isViewerOpen} - + { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} + /> {/if} diff --git a/web/src/lib/components/shared-components/loading-spinner.svelte b/web/src/lib/components/shared-components/loading-spinner.svelte index 7835e17310234..48626a50f485a 100644 --- a/web/src/lib/components/shared-components/loading-spinner.svelte +++ b/web/src/lib/components/shared-components/loading-spinner.svelte @@ -11,6 +11,7 @@ viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg" + data-testid="loading-spinner" > diff --git a/web/src/lib/components/shared-components/notification/__tests__/notification-card.spec.ts b/web/src/lib/components/shared-components/notification/__tests__/notification-card.spec.ts index bed072f5b7e27..2d92e773772b5 100644 --- a/web/src/lib/components/shared-components/notification/__tests__/notification-card.spec.ts +++ b/web/src/lib/components/shared-components/notification/__tests__/notification-card.spec.ts @@ -39,6 +39,29 @@ describe('NotificationCard component', () => { expect(sut.getByTestId('message')).toHaveTextContent('Notification message'); }); + it('makes all buttons non-focusable and hidden from screen readers', () => { + sut = render(NotificationCard, { + notification: { + id: 1234, + message: 'Notification message', + timeout: 1000, + type: NotificationType.Info, + action: { type: 'discard' }, + button: { + text: 'button', + onClick: vi.fn(), + }, + }, + }); + const buttons = sut.container.querySelectorAll('button'); + + expect(buttons).toHaveLength(2); + for (const button of buttons) { + expect(button.getAttribute('tabindex')).toBe('-1'); + expect(button.getAttribute('aria-hidden')).toBe('true'); + } + }); + it('shows title and renders component', () => { sut = render(NotificationCard, { notification: { diff --git a/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts b/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts index 44634d6b20038..669b7d75bd855 100644 --- a/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts +++ b/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts @@ -9,8 +9,6 @@ function _getNotificationListElement(sut: RenderResult): HTMLA } describe('NotificationList component', () => { - const sut: RenderResult = render(NotificationList); - beforeAll(() => { // https://testing-library.com/docs/svelte-testing-library/faq#why-arent-transition-events-running vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { @@ -23,6 +21,10 @@ describe('NotificationList component', () => { }); it('shows a notification when added and closes it automatically after the delay timeout', async () => { + const sut: RenderResult = render(NotificationList); + const status = await sut.findAllByRole('status'); + + expect(status).toHaveLength(1); expect(_getNotificationListElement(sut)).not.toBeInTheDocument(); notificationController.show({ diff --git a/web/src/lib/components/shared-components/notification/notification-card.svelte b/web/src/lib/components/shared-components/notification/notification-card.svelte index aac0823bf563b..61e710a1707d2 100644 --- a/web/src/lib/components/shared-components/notification/notification-card.svelte +++ b/web/src/lib/components/shared-components/notification/notification-card.svelte @@ -91,6 +91,8 @@ size="20" padding="2" on:click={discard} + aria-hidden="true" + tabindex={-1} /> @@ -108,6 +110,8 @@ type="button" class="{buttonStyle[notification.type]} rounded px-3 pt-1.5 pb-1 transition-all duration-200" on:click={handleButtonClick} + aria-hidden="true" + tabindex={-1} > {notification.button.text} diff --git a/web/src/lib/components/shared-components/notification/notification-list.svelte b/web/src/lib/components/shared-components/notification/notification-list.svelte index d94ff5c14dd97..c7c54be26720c 100644 --- a/web/src/lib/components/shared-components/notification/notification-list.svelte +++ b/web/src/lib/components/shared-components/notification/notification-list.svelte @@ -1,7 +1,7 @@ -{#if $notificationList.length > 0} -
- {#each $notificationList as notification (notification.id)} -
- -
- {/each} -
-{/if} +
+ {#if $notificationList.length > 0} +
+ {#each $notificationList as notification (notification.id)} +
+ +
+ {/each} +
+ {/if} +
diff --git a/web/src/lib/components/shared-components/number-range-input.svelte b/web/src/lib/components/shared-components/number-range-input.svelte index e4c780a708981..2e7dca878129e 100644 --- a/web/src/lib/components/shared-components/number-range-input.svelte +++ b/web/src/lib/components/shared-components/number-range-input.svelte @@ -1,5 +1,6 @@ diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte deleted file mode 100644 index 9282c760c2a7c..0000000000000 --- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte +++ /dev/null @@ -1,183 +0,0 @@ - - - (isDragging || isHover) && handleMouseEvent({ clientY })} - on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} - on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} -/> - - - -{#if $assetStore.timelineHeight > height} -
(isHover = true)} - on:mouseleave={() => (isHover = false)} - > - {#if isHover || isDragging} -
- {hoverLabel} -
- {/if} - - - {#if !isDragging} -
- {/if} - - {#each segments as segment} -
- {#if segment.hasLabel} -
- {segment.date.year} -
- {:else if segment.height > 5} -
- {/if} -
- {/each} -
-{/if} - - diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte new file mode 100644 index 0000000000000..e2cc638650cdc --- /dev/null +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -0,0 +1,281 @@ + + + (isDragging || isHover) && handleMouseEvent({ clientY })} + on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} + on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} +/> + + + +
(isHover = true)} + on:mouseleave={() => (isHover = false)} +> + {#if hoverLabel && (isHover || isDragging)} +
+ {hoverLabel} +
+ {/if} + + {#if !isDragging} +
+ {/if} +
+ {#if relativeTopOffset > 6} +
+ {/if} +
+ + {#each segments as segment} +
+ {#if segment.hasLabel} +
+ {segment.date.year} +
+ {/if} + {#if segment.hasDot} +
+ {/if} +
+ {/each} +
+
+ + diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 8fa923377137e..07d4df6e66f7e 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -2,7 +2,6 @@ import { AppRoute } from '$lib/constants'; import { goto } from '$app/navigation'; import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store'; - import { clickOutside } from '$lib/actions/click-outside'; import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js'; import SearchHistoryBox from './search-history-box.svelte'; import SearchFilterBox from './search-filter-box.svelte'; @@ -142,7 +141,7 @@ ]} /> -
+
-
+
= await getSearchSuggestions({ $type: SearchSuggestionType.CameraMake, - model, includeNull: true, }); + + makes = results.map((result) => result ?? ''); + if (filters.make && !makes.includes(filters.make)) { filters.make = undefined; } - - makes = results.map((result) => result ?? ''); } async function updateModels(make?: string) { @@ -41,7 +41,7 @@ includeNull: true, }); - const models = results.map((result) => result ?? ''); + models = results.map((result) => result ?? ''); if (filters.model && !models.includes(filters.model)) { filters.model = undefined; diff --git a/web/src/lib/components/shared-components/settings/setting-accordion.svelte b/web/src/lib/components/shared-components/settings/setting-accordion.svelte index 3a367624a07b4..eec4fae9c2a5c 100755 --- a/web/src/lib/components/shared-components/settings/setting-accordion.svelte +++ b/web/src/lib/components/shared-components/settings/setting-accordion.svelte @@ -9,6 +9,7 @@ export let subtitle = ''; export let key: string; export let isOpen = $accordionState.has(key); + export let autoScrollTo = false; let accordionElement: HTMLDivElement; @@ -18,12 +19,14 @@ if (isOpen) { $accordionState = $accordionState.add(key); - setTimeout(() => { - accordionElement.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - }, 200); + if (autoScrollTo) { + setTimeout(() => { + accordionElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + }, 200); + } } else { $accordionState.delete(key); $accordionState = $accordionState; @@ -72,7 +75,7 @@ {#if isOpen} -
    +
    {/if} diff --git a/web/src/lib/components/shared-components/settings/setting-input-field.svelte b/web/src/lib/components/shared-components/settings/setting-input-field.svelte index 04bc72f3f6b32..0767ecf12f03d 100644 --- a/web/src/lib/components/shared-components/settings/setting-input-field.svelte +++ b/web/src/lib/components/shared-components/settings/setting-input-field.svelte @@ -4,6 +4,7 @@ TEXT = 'text', NUMBER = 'number', PASSWORD = 'password', + COLOR = 'color', } @@ -13,6 +14,7 @@ import { fly } from 'svelte/transition'; import PasswordField from '../password-field.svelte'; import { t } from 'svelte-i18n'; + import { onMount, tick } from 'svelte'; export let inputType: SettingInputFieldType; export let value: string | number; @@ -25,8 +27,11 @@ export let required = false; export let disabled = false; export let isEdited = false; + export let autofocus = false; export let passwordAutocomplete: string = 'current-password'; + let input: HTMLInputElement; + const handleChange: FormEventHandler = (e) => { value = e.currentTarget.value; @@ -41,6 +46,14 @@ value = newValue; } }; + + onMount(() => { + if (autofocus) { + tick() + .then(() => input?.focus()) + .catch((_) => {}); + } + });
    @@ -69,22 +82,46 @@ {/if} {#if inputType !== SettingInputFieldType.PASSWORD} - +
    + {#if inputType === SettingInputFieldType.COLOR} + + {/if} + + +
    {:else} {/if}
    + + diff --git a/web/src/lib/components/shared-components/settings/setting-select.svelte b/web/src/lib/components/shared-components/settings/setting-select.svelte index b4efd90056386..c5b9e2c02e17d 100644 --- a/web/src/lib/components/shared-components/settings/setting-select.svelte +++ b/web/src/lib/components/shared-components/settings/setting-select.svelte @@ -3,6 +3,8 @@ import { fly } from 'svelte/transition'; import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; + import Icon from '$lib/components/elements/icon.svelte'; + import { mdiChevronDown } from '@mdi/js'; export let value: string | number; export let options: { value: string | number; text: string }[]; @@ -46,17 +48,27 @@

    {/if} - +
    + + +
diff --git a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte b/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte index e47daaf86b767..68c58ab155e6d 100644 --- a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte +++ b/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte @@ -1,13 +1,13 @@ -
(hoverRating = 0)} on:blur|preventDefault> - {#each { length: count } as _, index} - {@const value = index + 1} - {@const filled = hoverRating >= value || (hoverRating === 0 && rating >= value)} - - {/each} -
+ {/each} +
+ +{#if ratingSelection > 0 && !readOnly} + +{/if} diff --git a/web/src/lib/components/shared-components/theme-button.svelte b/web/src/lib/components/shared-components/theme-button.svelte index 7fc823f8ed995..8376b7235990a 100644 --- a/web/src/lib/components/shared-components/theme-button.svelte +++ b/web/src/lib/components/shared-components/theme-button.svelte @@ -7,8 +7,16 @@ $: icon = $colorTheme.value === Theme.LIGHT ? moonPath : sunPath; $: viewBox = $colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox; + $: isDark = $colorTheme.value === Theme.DARK; {#if !$colorTheme.system} - + {/if} diff --git a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte new file mode 100644 index 0000000000000..a3c49a1430c4e --- /dev/null +++ b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte @@ -0,0 +1,59 @@ + + + diff --git a/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte b/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte new file mode 100644 index 0000000000000..759a3e5e6579e --- /dev/null +++ b/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte @@ -0,0 +1,25 @@ + + +{#if items.length > 0} +
+ {#each items as item} + + {/each} +
+{/if} diff --git a/web/src/lib/components/shared-components/tree/tree-items.svelte b/web/src/lib/components/shared-components/tree/tree-items.svelte new file mode 100644 index 0000000000000..4bdc95db9f0f6 --- /dev/null +++ b/web/src/lib/components/shared-components/tree/tree-items.svelte @@ -0,0 +1,23 @@ + + +
    + {#each Object.entries(items) as [path, tree]} + {@const value = normalizeTreePath(`${parent}/${path}`)} + {@const key = value + getColor(value)} + {#key key} +
  • + +
  • + {/key} + {/each} +
diff --git a/web/src/lib/components/shared-components/tree/tree.svelte b/web/src/lib/components/shared-components/tree/tree.svelte new file mode 100644 index 0000000000000..5bc7a715ac793 --- /dev/null +++ b/web/src/lib/components/shared-components/tree/tree.svelte @@ -0,0 +1,47 @@ + + + + +
+ +
+ {value} +
+ +{#if isOpen} + +{/if} diff --git a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts index 1f1fa65cf8361..83ca07b40e0e5 100644 --- a/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts +++ b/web/src/lib/components/sharedlinks-page/covers/__tests__/share-cover.spec.ts @@ -47,13 +47,15 @@ describe('ShareCover component', () => { expect(img.className).toBe('z-0 rounded-xl object-cover aspect-square text'); }); - it('renders fallback image when asset is not resized', () => { - const link = sharedLinkFactory.build({ assets: [assetFactory.build({ resized: false })] }); + it.skip('renders fallback image when asset is not resized', () => { + const link = sharedLinkFactory.build({ assets: [assetFactory.build()] }); render(ShareCover, { - link: link, + link, preload: false, }); + // TODO emit image error event and check if fallback image is rendered + const img = screen.getByTestId('album-image'); expect(img.alt).toBe('unnamed_share'); }); diff --git a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte index b8335be6b0632..69c11e079c51b 100644 --- a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte @@ -1,16 +1,25 @@ - +{#if isBroken} + +{:else} + (isBroken = true)} + class="z-0 rounded-xl object-cover aspect-square {className}" + data-testid="album-image" + draggable="false" + loading={preload ? 'eager' : 'lazy'} + {src} + /> +{/if} diff --git a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte index 3a21a60989ce2..09f32d7dacebb 100644 --- a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte @@ -12,10 +12,10 @@ export { className as class }; -
+
{#if link?.album} - {:else if link.assets[0]?.resized} + {:else if link.assets[0]} - +
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index cd1177d279464..de4bbafdd94c9 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -11,7 +11,6 @@ loopVideo, playVideoThumbnailOnHover, showDeleteModal, - sidebarSettings, } from '$lib/stores/preferences.store'; import { findLocale } from '$lib/utils'; import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n'; @@ -19,13 +18,6 @@ import { locale as i18nLocale, t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import { invalidateAll } from '$app/navigation'; - import { preferences } from '$lib/stores/user.store'; - import { updateMyPreferences } from '@immich/sdk'; - import { handleError } from '../../utils/handle-error'; - import { - notificationController, - NotificationType, - } from '$lib/components/shared-components/notification/notification'; let time = new Date(); @@ -46,7 +38,6 @@ label: findLocale(editedLocale).name || fallbackLocale.name, }; $: closestLanguage = getClosestAvailableLocale([$lang], langCodes); - $: ratingEnabled = $preferences?.rating?.enabled; onMount(() => { const interval = setInterval(() => { @@ -98,17 +89,6 @@ $locale = newLocale; } }; - - const handleRatingChange = async (enabled: boolean) => { - try { - const data = await updateMyPreferences({ userPreferencesUpdateDto: { rating: { enabled } } }); - $preferences.rating.enabled = data.rating.enabled; - - notificationController.show({ message: $t('saved_settings'), type: NotificationType.Info }); - } catch (error) { - handleError(error, $t('errors.unable_to_update_settings')); - } - };
@@ -189,29 +169,6 @@ bind:checked={$showDeleteModal} />
- -
- -
-
- -
-
- handleRatingChange(enabled)} - /> -
diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 57daeed54ff34..676e9843641c3 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -12,6 +12,7 @@ mdiLinux, mdiMicrosoftWindows, mdiTrashCanOutline, + mdiUbuntu, } from '@mdi/js'; import { DateTime, type ToRelativeCalendarOptions } from 'luxon'; import { createEventDispatcher } from 'svelte'; @@ -41,6 +42,8 @@ {:else if device.deviceOS === 'Linux'} + {:else if device.deviceOS === 'Ubuntu'} + {:else if device.deviceOS === 'Chromium OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium'} {:else} diff --git a/web/src/lib/components/user-settings-page/feature-settings.svelte b/web/src/lib/components/user-settings-page/feature-settings.svelte new file mode 100644 index 0000000000000..dc11dab15e8d0 --- /dev/null +++ b/web/src/lib/components/user-settings-page/feature-settings.svelte @@ -0,0 +1,124 @@ + + +
+
+ +
+ +
+ +
+ + {#if foldersEnabled} +
+ +
+ {/if} +
+ + +
+ +
+
+ + +
+ +
+ + {#if peopleEnabled} +
+ +
+ {/if} +
+ + +
+ +
+
+ + +
+ +
+ {#if tagsEnabled} +
+ +
+ {/if} +
+ +
+ +
+
+ +
+
diff --git a/web/src/lib/components/user-settings-page/memories-settings.svelte b/web/src/lib/components/user-settings-page/memories-settings.svelte deleted file mode 100644 index e8a58bf01651b..0000000000000 --- a/web/src/lib/components/user-settings-page/memories-settings.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - -
-
-
-
-
- -
-
- -
-
-
-
-
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index df32126a2d47a..596efaedef86d 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -10,7 +10,6 @@ import AppSettings from './app-settings.svelte'; import ChangePasswordSettings from './change-password-settings.svelte'; import DeviceList from './device-list.svelte'; - import MemoriesSettings from './memories-settings.svelte'; import OAuthSettings from './oauth-settings.svelte'; import PartnerSettings from './partner-settings.svelte'; import UserAPIKeyList from './user-api-key-list.svelte'; @@ -19,6 +18,7 @@ import { t } from 'svelte-i18n'; import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte'; import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte'; + import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte'; export let keys: ApiKeyResponseDto[] = []; export let sessions: SessionResponseDto[] = []; @@ -53,8 +53,8 @@ - - + + @@ -84,6 +84,7 @@ key="user-purchase-settings" title={$t('user_purchase_settings')} subtitle={$t('user_purchase_settings_description')} + autoScrollTo={true} > diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte index 5fc2177e88e07..2103250b54da9 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -14,7 +14,6 @@ $: isFromExternalLibrary = !!asset.libraryId; $: assetData = JSON.stringify(asset, null, 2); - $: stackCount = asset.stackCount;
- +
{#if isFromExternalLibrary}
{$t('external')}
{/if} - {#if stackCount != null && stackCount != 0} + {#if asset.stack?.assetCount}
-
{stackCount}
+
{asset.stack.assetCount}
diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index fcf68fdb91815..2f1efc487cdd2 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -4,7 +4,8 @@ import Portal from '$lib/components/shared-components/portal/portal.svelte'; import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { suggestDuplicateByFileSize } from '$lib/utils'; + import { handlePromiseError, suggestDuplicateByFileSize } from '$lib/utils'; + import { navigate } from '$lib/utils/navigation'; import { shortcuts } from '$lib/actions/shortcut'; import { type AssetResponseDto } from '@immich/sdk'; import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js'; @@ -158,7 +159,10 @@ const index = getAssetIndex($viewingAsset.id) - 1 + assets.length; setAsset(assets[index % assets.length]); }} - on:close={() => assetViewingStore.showAssetViewer(false)} + on:close={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} /> {/await} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 57242ae66e038..ce5cefd8153d2 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -45,6 +45,9 @@ export enum AppRoute { UTILITIES = '/utilities', DUPLICATES = '/utilities/duplicates', + + FOLDERS = '/folders', + TAGS = '/tags', } export enum ProjectionType { @@ -78,6 +81,7 @@ export enum QueryParameter { SEARCHED_PEOPLE = 'searchedPeople', SMART_SEARCH = 'smartSearch', PAGE = 'page', + PATH = 'path', } export enum OpenSettingQueryParameterValue { diff --git a/web/src/lib/i18n.spec.ts b/web/src/lib/i18n.spec.ts index c9261dcec5a08..13d926e6473f3 100644 --- a/web/src/lib/i18n.spec.ts +++ b/web/src/lib/i18n.spec.ts @@ -1,39 +1,8 @@ import { langs } from '$lib/constants'; -import messages from '$lib/i18n/en.json'; import { getClosestAvailableLocale } from '$lib/utils/i18n'; -import { exec as execCallback } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; -import { promisify } from 'node:util'; - -type Messages = { [key: string]: string | Messages }; - -const exec = promisify(execCallback); - -function setEmptyMessages(messages: Messages) { - const copy = { ...messages }; - - for (const key in copy) { - const message = copy[key]; - if (typeof message === 'string') { - copy[key] = ''; - } else if (typeof message === 'object') { - setEmptyMessages(message); - } - } - - return copy; -} describe('i18n', () => { - test('no missing messages', async () => { - const { stdout } = await exec('npx svelte-i18n extract -c svelte.config.js "src/**/*"'); - const extractedMessages: Messages = JSON.parse(stdout); - const existingMessages = setEmptyMessages(messages); - - // Only translations directly using the store seem to get extracted - expect({ ...extractedMessages, ...existingMessages }).toEqual(existingMessages); - }); - describe('loaders', () => { const languageFiles = readdirSync('src/lib/i18n').sort(); for (const filename of languageFiles) { diff --git a/web/src/lib/i18n/ar.json b/web/src/lib/i18n/ar.json index 98f9bb2bd2d8f..1d90f9e625dde 100644 --- a/web/src/lib/i18n/ar.json +++ b/web/src/lib/i18n/ar.json @@ -129,12 +129,13 @@ "map_enable_description": "تفعيل ميزات الخرائط", "map_gps_settings": "إعدادات الخريطة ونظام تحديد المواقع", "map_gps_settings_description": "إدارة إعدادات الخريطة و نظام تحديد المواقع (عكس الترميز الجغرافي)", + "map_implications": "تعتمد ميزة الخريطة على خدمة خارجية (tiles.immich.cloud)", "map_light_style": "النمط الفاتح", "map_manage_reverse_geocoding_settings": "إدارة إعدادات التكوين الجغرافي المعكوس", "map_reverse_geocoding": "عكس الترميز الجغرافي", "map_reverse_geocoding_enable_description": "تفعيل عكس الترميز الجغرافي", "map_reverse_geocoding_settings": "إعدادات عكس الترميز الجغرافي", - "map_settings": "إعدادات الخريطة", + "map_settings": "الخريطة", "map_settings_description": "إدارة إعدادات الخريطة", "map_style_description": "عنوان URL لسمة الخريطة style.json", "metadata_extraction_job": "استخراج البيانات الوصفية", @@ -173,7 +174,7 @@ "oauth_issuer_url": "عنوان URL الخاص بجهة الإصدار", "oauth_mobile_redirect_uri": "عنوان URI لإعادة التوجيه على الهاتف", "oauth_mobile_redirect_uri_override": "تجاوز عنوان URI لإعادة التوجيه على الهاتف", - "oauth_mobile_redirect_uri_override_description": "قم بتفعيله عندما يكون عنوان URI إعادة التوجيه 'app.immich:/' غير صالح.", + "oauth_mobile_redirect_uri_override_description": "قم بتفعيله عندما لا يسمح موفر OAuth بمعرف URI للجوال، مثل '{callback}'", "oauth_profile_signing_algorithm": "خوارزمية توقيع الملف الشخصي", "oauth_profile_signing_algorithm_description": "الخوارزمية المستخدمة للتوقيع على ملف تعريف المستخدم.", "oauth_scope": "النطاق", @@ -320,7 +321,8 @@ "user_settings": "إعدادات المستخدم", "user_settings_description": "إدارة إعدادات المستخدم", "user_successfully_removed": "تمت إزالة المستخدم {email} بنجاح.", - "version_check_enabled_description": "تفعيل إرسال طلبات دورية إلى GitHub للتحقق من الإصدارات الجديدة", + "version_check_enabled_description": "تفعيل التحقق من الإصدارات الجديدة", + "version_check_implications": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع github.com", "version_check_settings": "التحقق من الإصدار", "version_check_settings_description": "تفعيل/تعطيل الإشعار لإصدار جديد", "video_conversion_job": "تحويل أشرطة الفيديو", @@ -336,7 +338,8 @@ "album_added": "تمت إضافة الألبوم", "album_added_notification_setting_description": "تلقي إشعارًا بالبريد الإلكتروني عند إضافتك إلى ألبوم مشترك", "album_cover_updated": "تم تحديث غلاف الألبوم", - "album_delete_confirmation": "هل أنت متأكد أنك تريد حذف الألبوم {album}؟\nإذا تمت مشاركة هذا الألبوم، فلن يتمكن المستخدمون الآخرون من الوصول إليه بعد الآن.", + "album_delete_confirmation": "هل أنت متأكد أنك تريد حذف الألبوم {album}؟", + "album_delete_confirmation_description": "إذا تمت مشاركة هذا الألبوم، فلن يتمكن المستخدمون الآخرون من الوصول إليه بعد الآن.", "album_info_updated": "تم تحديث معلومات الألبوم", "album_leave": "هل تريد مغادرة الألبوم؟", "album_leave_confirmation": "هل أنت متأكد أنك تريد مغادرة {album}؟", @@ -360,6 +363,7 @@ "allow_edits": "إسمح بالتعديل", "allow_public_user_to_download": "السماح لأي مستخدم عام بالتنزيل", "allow_public_user_to_upload": "السماح للمستخدم العام بالرفع", + "anti_clockwise": "عكس اتجاه عقارب الساعة", "api_key": "مفتاح واجهة برمجة التطبيقات", "api_key_description": "سيتم عرض هذه القيمة مرة واحدة فقط. يرجى التأكد من نسخها قبل إغلاق النافذة.", "api_key_empty": "يجب ألا يكون اسم مفتاح API فارغًا", @@ -440,9 +444,11 @@ "clear_all_recent_searches": "مسح جميع عمليات البحث الأخيرة", "clear_message": "إخلاء الرسالة", "clear_value": "إخلاء القيمة", + "clockwise": "باتجاه عقارب الساعة", "close": "إغلاق", "collapse": "طي", "collapse_all": "طيّ الكل", + "color": "اللون", "color_theme": "نمط الألوان", "comment_deleted": "تم حذف التعليق", "comment_options": "خيارات التعليق", @@ -476,6 +482,8 @@ "create_new_person": "إنشاء شخص جديد", "create_new_person_hint": "تعيين المحتويات المحددة لشخص جديد", "create_new_user": "إنشاء مستخدم جديد", + "create_tag": "إنشاء علامة", + "create_tag_description": "أنشئ علامة جديدة. بالنسبة للعلامات المتداخلة، يرجى إدخال المسار الكامل للعلامة بما في ذلك الخطوط المائلة للأمام.", "create_user": "إنشاء مستخدم", "created": "تم الإنشاء", "current_device": "الجهاز الحالي", @@ -499,6 +507,8 @@ "delete_library": "حذف المكتبة", "delete_link": "حذف الرابط", "delete_shared_link": "حذف الرابط المشترك", + "delete_tag": "حذف العلامة", + "delete_tag_confirmation_prompt": "هل أنت متأكد أنك تريد حذف العلامة {tagName}؟", "delete_user": "حذف المستخدم", "deleted_shared_link": "تم حذف الرابط المشارك", "description": "وصف", @@ -516,6 +526,8 @@ "do_not_show_again": "لا تُظهر هذه الرسالة مرة آخرى", "done": "تم", "download": "تنزيل", + "download_include_embedded_motion_videos": "مقاطع الفيديو المدمجة", + "download_include_embedded_motion_videos_description": "تضمين مقاطع الفيديو المضمنة في الصور المتحركة كملف منفصل", "download_settings": "التنزيلات", "download_settings_description": "إدارة الإعدادات المتعلقة بتنزيل المحتويات", "downloading": "جارٍ التنزيل", @@ -545,10 +557,15 @@ "edit_location": "تعديل الموقع", "edit_name": "تعديل الاسم", "edit_people": "تعديل الأشخاص", + "edit_tag": "تعديل العلامة", "edit_title": "تعديل العنوان", "edit_user": "تعديل المستخدم", "edited": "تم التعديل", - "editor": "", + "editor": "محرر", + "editor_close_without_save_prompt": "لن يتم حفظ التغييرات", + "editor_close_without_save_title": "إغلاق المحرر؟", + "editor_crop_tool_h2_aspect_ratios": "نسب العرض إلى الارتفاع", + "editor_crop_tool_h2_rotation": "التدوير", "email": "البريد الإلكتروني", "empty": "", "empty_album": "", @@ -698,6 +715,7 @@ "expired": "منتهي الصلاحية", "expires_date": "تنتهي الصلاحية في {date}", "explore": "استكشاف", + "explorer": "المستكشف", "export": "تصدير", "export_as_json": "تصدير كـ JSON", "extension": "الإمتداد", @@ -711,6 +729,8 @@ "feature": "", "feature_photo_updated": "تم تحديث الصورة المميزة", "featurecollection": "", + "features": "الميزات", + "features_setting_description": "إدارة ميزات التطبيق", "file_name": "إسم الملف", "file_name_or_extension": "اسم الملف أو امتداده", "filename": "اسم الملف", @@ -719,6 +739,8 @@ "filter_people": "تصفية الاشخاص", "find_them_fast": "يمكنك العثور عليها بسرعة بالاسم من خلال البحث", "fix_incorrect_match": "إصلاح المطابقة غير الصحيحة", + "folders": "المجلدات", + "folders_feature_description": "تصفح عرض المجلد للصور ومقاطع الفيديو الموجودة على نظام الملفات", "force_re-scan_library_files": "فرض إعادة فحص جميع ملفات المكتبة", "forward": "إلى الأمام", "general": "عام", @@ -911,6 +933,7 @@ "ok": "نعم", "oldest_first": "الأقدم أولا", "onboarding": "الإعداد الأولي", + "onboarding_privacy_description": "تعتمد الميزات التالية (اختياري) على خدمات خارجية، ويمكن تعطيلها في أي وقت في إعدادات الإدارة.", "onboarding_theme_description": "اختر نسق الألوان للنسخة الخاصة بك. يمكنك تغيير ذلك لاحقًا في إعداداتك.", "onboarding_welcome_description": "لنقم بإعداد نسختك باستخدام بعض الإعدادات الشائعة.", "onboarding_welcome_user": "مرحبا، {user}", @@ -952,6 +975,7 @@ "pending": "قيد الانتظار", "people": "الأشخاص", "people_edits_count": "تم تعديل {count, plural, one {# شخص } other {# أشخاص }}", + "people_feature_description": "تصفح الصور ومقاطع الفيديو المجمعة حسب الأشخاص", "people_sidebar_description": "عرض رابط للأشخاص في الشريط الجانبي", "perform_library_tasks": "", "permanent_deletion_warning": "تحذير الحذف الدائم", @@ -983,6 +1007,7 @@ "previous_memory": "الذكرى السابقة", "previous_or_next_photo": "الصورة السابقة أو التالية", "primary": "أساسي", + "privacy": "الخصوصية", "profile_image_of_user": "صورة الملف الشخصي لـ {user}", "profile_picture_set": "مجموعة الصور الشخصية.", "public_album": "الألبوم العام", @@ -1021,7 +1046,9 @@ "purchase_settings_server_activated": "يتم إدارة مفتاح منتج الخادم من قبل مدير النظام", "range": "", "rating": "تقييم نجمي", - "rating_description": "‫‌اعرض تقييم exif في لوحة المعلومات", + "rating_clear": "مسح التقييم", + "rating_count": "{count, plural, one {# نجمة} other {# نجوم}}", + "rating_description": "‫‌اعرض تقييم EXIF في لوحة المعلومات", "raw": "", "reaction_options": "خيارات رد الفعل", "read_changelog": "قراءة سجل التغيير", @@ -1054,6 +1081,7 @@ "removed_from_archive": "تمت إزالتها من الأرشيف", "removed_from_favorites": "تمت الإزالة من المفضلة", "removed_from_favorites_count": "{count, plural, other {أُزيلت #}} من التفضيلات", + "removed_tagged_assets": "تمت إزالة العلامة من {count, plural, one {# الأصل} other {# الأصول}}", "rename": "إعادة تسمية", "repair": "إصلاح", "repair_no_results_message": "ستظهر هنا الملفات المفقودة وأيضاً التي لم يتم تعقبها", @@ -1103,6 +1131,7 @@ "search_people": "البحث عن الأشخاص", "search_places": "البحث عن الأماكن", "search_state": "البحث حسب الولاية...", + "search_tags": "البحث عن العلامات...", "search_timezone": "البحث حسب المنطقة الزمنية...", "search_type": "نوع البحث", "search_your_photos": "ابحث عن صورك", @@ -1241,6 +1270,7 @@ "unlink_oauth": "إلغاء ربط OAuth", "unlinked_oauth_account": "تم إلغاء ربط حساب OAuth", "unnamed_album": "ألبوم بلا إسم", + "unnamed_album_delete_confirmation": "هل أنت متأكد أنك تريد حذف هذا الألبوم؟", "unnamed_share": "مشاركة بلا إسم", "unsaved_change": "تغيير غير محفوظ", "unselect_all": "إلغاء تحديد الكل", diff --git a/web/src/lib/i18n/bg.json b/web/src/lib/i18n/bg.json index c4b5304dd1716..9a5702db183dd 100644 --- a/web/src/lib/i18n/bg.json +++ b/web/src/lib/i18n/bg.json @@ -127,12 +127,13 @@ "map_enable_description": "Активиране на картата", "map_gps_settings": "Настройки на картата и GPS", "map_gps_settings_description": "Управление на настройките на картата и GPS (обратно геокодиране)", + "map_implications": "Функцията за карта разчита на външна услуга (tiles.immich.cloud)", "map_light_style": "Светъл стил", "map_manage_reverse_geocoding_settings": "Управление на настройките за обратно геокодиране", "map_reverse_geocoding": "Обратно геокодиране", "map_reverse_geocoding_enable_description": "Включване на обратно геокодиране", "map_reverse_geocoding_settings": "Настройки на опбратно геокодиране", - "map_settings": "Настройки на картата", + "map_settings": "Карта", "map_settings_description": "Управление на настройките на картата", "map_style_description": "URL адрес към файл \"style.json\" за задаване на стил на картата", "metadata_extraction_job": "Извличане на метаданни", @@ -297,7 +298,7 @@ "transcoding_two_pass_encoding_setting_description": "", "transcoding_video_codec": "Видеокодек", "transcoding_video_codec_description": "VP9 има висока ефективност и уеб съвместимост, но отнема повече време за транскодиране. HEVC работи по подобен начин, но има по-ниска уеб съвместимост. H.264 е широко съвместим и бърз за разкодиране, но създава много по-големи файлове. AV1 е най-ефективният кодек, но му липсва поддръжка на по-стари устройства.", - "trash_enabled_description": "", + "trash_enabled_description": "Активирайте функциите за кошче", "trash_number_of_days": "Брой дни", "trash_number_of_days_description": "Брой дни, в които файловете да се съхраняват на боклука, преди да бъдат окончателно премахнати", "trash_settings": "Настройки на кошчето", @@ -306,8 +307,9 @@ "untracked_files_description": "Тези файлове не се проследяват от приложението. Те могат да бъдат резултат от неуспешни премествания, прекъснати качвания или оставени поради грешка", "user_delete_delay": "{user} aкаунтът и файловете на потребителя ще бъдат планирани за постоянно изтриване след {delay, plural, one {# ден} other {# дни}}.", "user_delete_delay_settings": "Забавяне на изтриване", - "user_delete_delay_settings_description": "", + "user_delete_delay_settings_description": "Брой дни след окончателно изтриване акаунта на потребителя. Задачата за изтриване на потребител се изпълнява в полунощ, за да се провери за потребители, които са готови за изтриване. Промените на тази настройка ще влязат в сила при следващото изпълнение.", "user_delete_immediately": "{user} акаунтът и файловете на потребителя ще бъдат включени в опашката за окончателно изтриване незабавно.", + "user_delete_immediately_checkbox": "Заявка на потребител и активи в опашка за незабавно изтриване", "user_management": "Управление на потребителите", "user_password_has_been_reset": "Паролата на потребителя е променена:", "user_password_reset_description": "Моля, предоставете временната парола на потребителя и го информирайте, че ще трябва да я смени при следващото си влизане в системата.", @@ -316,7 +318,8 @@ "user_settings": "Настройки на потребителя", "user_settings_description": "Управление на потребителските настройки", "user_successfully_removed": "Потребителят {email} е успешно премахнат.", - "version_check_enabled_description": "Активирайте периодични заявки към GitHub, за да проверявате за нови версии", + "version_check_enabled_description": "Активирай проверка на версията", + "version_check_implications": "Функцията за проверка на версията разчита на периодична комуникация с github.com", "version_check_settings": "Проверка на версията", "version_check_settings_description": "Активирайте/деактивирайте известието за нова версия", "video_conversion_job": "Транскодиране на видеоклиповете", @@ -329,7 +332,8 @@ "album_added": "Албумът е добавен", "album_added_notification_setting_description": "Получавайте известие по имейл, когато бъдете добавени към споделен албум", "album_cover_updated": "Обложката на албума е актуализирана", - "album_delete_confirmation": "Сигурни ли сте, че искате да изтриете албума {album}?\nАко този албум е споделен, другите потребители вече няма да имат достъп до него.", + "album_delete_confirmation": "Сигурни ли сте, че искате да изтриете албума {album}?", + "album_delete_confirmation_description": "Ако този албум е споделен, други потребители вече няма да имат достъп до него.", "album_info_updated": "Информацията за албума е актуализирана", "album_leave": "Да напусна ли албума?", "album_leave_confirmation": "Сигурни ли сте, че искате да напуснете {album}?", @@ -349,17 +353,22 @@ "all_albums": "Всички албуми", "all_people": "Всички хора", "all_videos": "Всички видеоклипове", - "allow_dark_mode": "", + "allow_dark_mode": "Разреши тъмен режим", "allow_edits": "Позволяване на редакции", - "api_key": "", - "api_keys": "", + "allow_public_user_to_download": "Позволете на публичен потребител да може да изтегля", + "allow_public_user_to_upload": "Позволете на публичния потребител да може да качва", + "api_key": "API ключ", + "api_key_description": "Тази стойност ще бъде показана само веднъж. Моля, не забравяйте да го копирате, преди да затворите прозореца.", + "api_key_empty": "Името на вашия API ключ не трябва да е празно", + "api_keys": "API ключове", "app_settings": "Настройки ма приложението", "appears_in": "", "archive": "Архив", - "archive_or_unarchive_photo": "", + "archive_or_unarchive_photo": "Архивиране или деархивиране на снимка", "archive_size": "Размер на архива", - "archive_size_description": "", + "archive_size_description": "Конфигурирайте размера на архива за изтегляния (в GiB)", "archived": "", + "are_these_the_same_person": "Това едно и също лице ли е?", "asset_offline": "Ресурсът е офлайн", "asset_skipped": "Пропуснато", "asset_uploaded": "Качено", @@ -936,44 +945,52 @@ "select_face": "", "select_featured_photo": "", "select_keep_all": "", - "select_library_owner": "", - "select_new_face": "", - "select_photos": "", - "select_trash_all": "", + "select_library_owner": "Изберете собственик на библиотека", + "select_new_face": "Изберете ново лице", + "select_photos": "Изберете снимки", + "select_trash_all": "Изберете всичко за кошчето", "selected": "Избрано", - "send_message": "", - "send_welcome_email": "", + "send_message": "Изпратете съобщение", + "send_welcome_email": "Изпратете имейл за добре дошли", "server": "Сървър", - "server_stats": "", + "server_offline": "Сървър офлайн", + "server_online": "Сървър онлайн", + "server_stats": "Статус на сървъра", + "server_version": "Версия на сървъра", "set": "Задай", - "set_as_album_cover": "", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", + "set_as_album_cover": "Задаване като обложка на албум", + "set_as_profile_picture": "Задаване като профилна снимка", + "set_date_of_birth": "Задайте дата на раждане", + "set_profile_picture": "Задайте профилна снимка", + "set_slideshow_to_fullscreen": "Задайте Слайдшоу на цял екран", "settings": "Настройки", - "settings_saved": "", + "settings_saved": "Настройките са запазени", "share": "Споделяне", "shared": "Споделено", - "shared_by": "", - "shared_by_you": "", - "shared_from_partner": "", - "shared_links": "", + "shared_by": "Споделено от", + "shared_by_user": "Споделено от {user}", + "shared_by_you": "Споделено от теб", + "shared_from_partner": "Снимки от {partner}", + "shared_link_options": "Опции за споделена връзка", + "shared_links": "Споделени връзки", "shared_photos_and_videos_count": "", - "shared_with_partner": "", + "shared_with_partner": "Споделено с {partner}", "sharing": "Споделени", - "sharing_sidebar_description": "", - "show_album_options": "", - "show_and_hide_people": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", + "sharing_enter_password": "Моля, въведете паролата, за да видите тази страница.", + "sharing_sidebar_description": "Покажи връзка към Споделяне в страничната лента", + "show_album_options": "Показване опции за албум", + "show_albums": "Покажи албуми", + "show_all_people": "Покажи всички хора", + "show_and_hide_people": "Показване и скриване на хора", + "show_file_location": "Покажи местоположението на файла", + "show_gallery": "Покажи галерия", + "show_hidden_people": "Показване на скритите хора", "show_in_timeline": "Показване във времевата линия", "show_in_timeline_setting_description": "Показване на снимки и видеа от този потребител във времевата линия", - "show_keyboard_shortcuts": "", - "show_metadata": "", - "show_or_hide_info": "", - "show_password": "", + "show_keyboard_shortcuts": "Покажи клавишни комбинации", + "show_metadata": "Покажи метаданни", + "show_or_hide_info": "Покажи или скрий информацията", + "show_password": "Покажи паролата", "show_person_options": "", "show_progress_bar": "", "show_search_options": "", diff --git a/web/src/lib/i18n/cs.json b/web/src/lib/i18n/cs.json index faa5af16d5e62..238ab7ebaf28f 100644 --- a/web/src/lib/i18n/cs.json +++ b/web/src/lib/i18n/cs.json @@ -174,7 +174,7 @@ "oauth_issuer_url": "URL vydavatele", "oauth_mobile_redirect_uri": "Mobilní přesměrování URI", "oauth_mobile_redirect_uri_override": "Přepsat mobilní přesměrování URI", - "oauth_mobile_redirect_uri_override_description": "Povolit, pokud je 'app.immich:/' neplatné URI přesměrování.", + "oauth_mobile_redirect_uri_override_description": "Povolit, pokud poskytovatel OAuth nepovoluje mobilní URI, například '{callback}'.", "oauth_profile_signing_algorithm": "Algoritmus podepisování profilu", "oauth_profile_signing_algorithm_description": "Algoritmus použitý k podepsání profilu uživatele.", "oauth_scope": "Rozsah", @@ -322,6 +322,7 @@ "user_settings_description": "Správa nastavení uživatelů", "user_successfully_removed": "Uživatel {email} byl úspěšně odstraněn.", "version_check_enabled_description": "Povolit kontrolu verzí", + "version_check_implications": "Kontrola verze je založena na pravidelné komunikaci s github.com", "version_check_settings": "Kontrola verze", "version_check_settings_description": "Povolení/zakázání oznámení o nové verzi", "video_conversion_job": "Překódování videí", @@ -337,7 +338,8 @@ "album_added": "Přidáno album", "album_added_notification_setting_description": "Dostávat e-mailové oznámení, když jste přidáni do sdíleného alba", "album_cover_updated": "Obal alba aktualizován", - "album_delete_confirmation": "Opravdu chcete album {album} odstranit?\nPokud je toto album sdílené, ostatní uživatelé k němu již nebudou mít přístup.", + "album_delete_confirmation": "Opravdu chcete album {album} odstranit?", + "album_delete_confirmation_description": "Pokud je toto album sdíleno, ostatní uživatelé k němu již nebudou mít přístup.", "album_info_updated": "Informace o albu aktualizovány", "album_leave": "Opustit album?", "album_leave_confirmation": "Opravdu chcete opustit {album}?", @@ -361,6 +363,7 @@ "allow_edits": "Povolit úpravy", "allow_public_user_to_download": "Povolit veřejnosti stahování", "allow_public_user_to_upload": "Povolit veřejnosti nahrávat", + "anti_clockwise": "Proti směru hodinových ručiček", "api_key": "API klíč", "api_key_description": "Tato hodnota se zobrazí pouze jednou. Před zavřením okna ji nezapomeňte zkopírovat.", "api_key_empty": "Název klíče API by neměl být prázdný", @@ -442,9 +445,11 @@ "clear_all_recent_searches": "Vymazat všechna nedávná vyhledávání", "clear_message": "Vyčistit zprávu", "clear_value": "Vyčistit hodnotu", + "clockwise": "Po směru hodinových ručiček", "close": "Zavřít", "collapse": "Sbalit", "collapse_all": "Sbalit vše", + "color": "Barva", "color_theme": "Barevný motiv", "comment_deleted": "Komentář odstraněn", "comment_options": "Možnosti komentáře", @@ -478,6 +483,8 @@ "create_new_person": "Vytvořit novou osobu", "create_new_person_hint": "Přiřadit vybrané položky nové osobě", "create_new_user": "Vytvořit nového uživatele", + "create_tag": "Vytvořit značku", + "create_tag_description": "Vytvoření nové značky. U vnořených značek zadejte celou cestu ke značce včetně dopředných lomítek.", "create_user": "Vytvořit uživatele", "created": "Vytvořeno", "current_device": "Současné zařízení", @@ -501,6 +508,8 @@ "delete_library": "Smazat knihovnu", "delete_link": "Smazat odkaz", "delete_shared_link": "Smazat sdílený odkaz", + "delete_tag": "Smazat značku", + "delete_tag_confirmation_prompt": "Opravdu chcete odstranit značku {tagName}?", "delete_user": "Odstranit uživatele", "deleted_shared_link": "Smazat sdílený odkaz", "description": "Popis", @@ -518,6 +527,8 @@ "do_not_show_again": "Tuto zprávu již nezobrazovat", "done": "Hotovo", "download": "Stáhnout", + "download_include_embedded_motion_videos": "Vložená videa", + "download_include_embedded_motion_videos_description": "Zahrnout videa vložená do pohyblivých fotografií jako samostatný soubor", "download_settings": "Stahování", "download_settings_description": "Správa nastavení souvisejících se stahováním", "downloading": "Stahování", @@ -547,10 +558,15 @@ "edit_location": "Upravit polohu", "edit_name": "Upravit jméno", "edit_people": "Upravit lidi", + "edit_tag": "Upravit značku", "edit_title": "Upravit název", "edit_user": "Upravit uživatele", "edited": "Upraveno", "editor": "Editor", + "editor_close_without_save_prompt": "Změny nebudou uloženy", + "editor_close_without_save_title": "Zavřít editor?", + "editor_crop_tool_h2_aspect_ratios": "Poměr stran", + "editor_crop_tool_h2_rotation": "Otočení", "email": "E-mail", "empty": "Prázdné", "empty_album": "Prázdné album", @@ -700,6 +716,7 @@ "expired": "Vypršela platnost", "expires_date": "Platnost končí {date}", "explore": "Prozkoumat", + "explorer": "Průzkumník", "export": "Export", "export_as_json": "Exportovat jako JSON", "extension": "Přípona", @@ -713,6 +730,8 @@ "feature": "Funkce", "feature_photo_updated": "Hlavní fotka aktualizována", "featurecollection": "Kolekce Funkcí", + "features": "Funkce", + "features_setting_description": "Správa funkcí aplikace", "file_name": "Název souboru", "file_name_or_extension": "Název nebo přípona souboru", "filename": "Filename", @@ -721,6 +740,8 @@ "filter_people": "Filtrovat lidi", "find_them_fast": "Najděte je rychle vyhledáním jejich jména", "fix_incorrect_match": "Opravit nesprávnou shodu", + "folders": "Složky", + "folders_feature_description": "Procházení zobrazení složek s fotografiemi a videi v souborovém systému", "force_re-scan_library_files": "Vynucené prohledání všech souborů knihovny", "forward": "Dopředu", "general": "Obecné", @@ -913,6 +934,7 @@ "ok": "Ok", "oldest_first": "Nejstarší první", "onboarding": "Zahájení", + "onboarding_privacy_description": "Následující (volitelné) funkce jsou závislé na externích službách a lze je kdykoli zakázat v nastavení správy.", "onboarding_storage_template_description": "Pokud je tato funkce povolena, automaticky uspořádá soubory na základě uživatelem definované šablony. Vzhledem k problémům se stabilitou byla tato funkce ve výchozím nastavení vypnuta. Další informace naleznete v [dokumentaci].", "onboarding_theme_description": "Zvolte si barevné téma pro svou instanci. Můžete to později změnit v nastavení.", "onboarding_welcome_description": "Nastavíme vaši instanci pomocí několika běžných nastavení.", @@ -955,6 +977,7 @@ "pending": "Čekající", "people": "Lidé", "people_edits_count": "Upraveno {count, plural, one {# osoba} few {# osoby} other {# lidí}}", + "people_feature_description": "Procházení fotografií a videí seskupených podle osob", "people_sidebar_description": "Zobrazit sekci Lidé v postranním panelu", "perform_library_tasks": "", "permanent_deletion_warning": "Upozornění na trvalé smazání", @@ -987,6 +1010,7 @@ "previous_memory": "Předchozí vzpomínka", "previous_or_next_photo": "Předchozí nebo další fotka", "primary": "Primární", + "privacy": "Soukromí", "profile_image_of_user": "Profilový obrázek uživatele {user}", "profile_picture_set": "Profilový obrázek nastaven.", "public_album": "Veřejné album", @@ -1025,7 +1049,9 @@ "purchase_settings_server_activated": "Produktový klíč serveru spravuje správce", "range": "Rozsah", "rating": "Hodnocení hvězdičkami", - "rating_description": "Zobrazit exif hodnocení v informačním panelu", + "rating_clear": "Vyčistit hodnocení", + "rating_count": "{count, plural, one {# hvězdička} few {# hvězdičky} other {# hvězdček}}", + "rating_description": "Zobrazit EXIF hodnocení v informačním panelu", "raw": "Raw", "reaction_options": "Možnosti reakce", "read_changelog": "Přečtěte si seznam změn", @@ -1058,6 +1084,7 @@ "removed_from_archive": "Odstraněno z archivu", "removed_from_favorites": "Odstraněno z oblíbených", "removed_from_favorites_count": "{count, plural, one {Odstraněn #} few {Odstraněny #} other {Odstraněno #}} z oblíbených", + "removed_tagged_assets": "Odstraněná značka z {count, plural, one {# položky} other {# položek}}", "rename": "Přejmenovat", "repair": "Opravy", "repair_no_results_message": "Zde se zobrazí neznámé a chybějící soubory", @@ -1107,6 +1134,7 @@ "search_people": "Vyhledat lidi", "search_places": "Vyhledat místa", "search_state": "Vyhledat stát...", + "search_tags": "Vyhledávat značky...", "search_timezone": "Vyhledat časové pásmo...", "search_type": "Typ vyhledávání", "search_your_photos": "Vyhledávejte svoje fotky", @@ -1148,6 +1176,7 @@ "shared_by_user": "Sdíleno uživatelem {user}", "shared_by_you": "Sdíleli jste", "shared_from_partner": "Fotky od {partner}", + "shared_link_options": "Možnosti sdíleného odkazu", "shared_links": "Sdílené odkazy", "shared_photos_and_videos_count": "{assetCount, plural, one {# sdílená fotografie a video.} few {# sdílené fotografie a videa.} other {# sdílených fotografií a videí.}}", "shared_with_partner": "Sdíleno s {partner}", @@ -1174,6 +1203,8 @@ "show_supporter_badge": "Odznak podporovatele", "show_supporter_badge_description": "Zobrazit odznak podporovatele", "shuffle": "Náhodný výběr", + "sidebar": "Postranní panel", + "sidebar_display_description": "Zobrazení odkazu na zobrazení v postranním panelu", "sign_out": "Odhlásit se", "sign_up": "Zaregistrovat se", "size": "Velikost", @@ -1210,6 +1241,13 @@ "sunrise_on_the_beach": "Východ slunce na pláži", "swap_merge_direction": "Obrátit směr sloučení", "sync": "Synchronizovat", + "tag": "Značka", + "tag_assets": "Přiřadit značku", + "tag_created": "Vytvořena značka: {tag}", + "tag_feature_description": "Procházení fotografií a videí seskupených podle témat logických značek", + "tag_updated": "Aktualizována značka: {tag}", + "tagged_assets": "Přiřazena značka {count, plural, one {# položce} other {# položkám}}", + "tags": "Značky", "template": "Šablona", "theme": "Motiv", "theme_selection": "Výběr motivu", @@ -1221,9 +1259,10 @@ "to_change_password": "Změnit heslo", "to_favorite": "Oblíbit", "to_login": "Přihlásit", + "to_root": "Přejít ke kořenu", "to_trash": "Vyhodit", "toggle_settings": "Přepnout nastavení", - "toggle_theme": "Přepnout motiv", + "toggle_theme": "Přepnout tmavý motiv", "toggle_visibility": "Přepnout viditelnost", "total_usage": "Celkové využití", "trash": "Koš", @@ -1245,6 +1284,7 @@ "unlink_oauth": "Zrušit OAuth propojení", "unlinked_oauth_account": "OAuth účet odpojen", "unnamed_album": "Nepojmenované album", + "unnamed_album_delete_confirmation": "Opravdu chcete toto album smazat?", "unnamed_share": "Nejmenované sdílení", "unsaved_change": "Neuložená změna", "unselect_all": "Zrušit výběr všech", @@ -1293,6 +1333,7 @@ "view_album": "Zobrazit album", "view_all": "Zobrazit vše", "view_all_users": "Zobrazit všechny uživatele", + "view_in_timeline": "Zobrazit na časové ose", "view_links": "Zobrazit odkazy", "view_next_asset": "Zobrazit další položku", "view_previous_asset": "Zobrazit předchozí položku", diff --git a/web/src/lib/i18n/da.json b/web/src/lib/i18n/da.json index 611a6e64720ae..d07549b463028 100644 --- a/web/src/lib/i18n/da.json +++ b/web/src/lib/i18n/da.json @@ -95,6 +95,7 @@ "logging_level_description": "Når slået til, hvilket logniveau, der skal bruges.", "logging_settings": "Logning", "machine_learning_clip_model": "CLIP-model", + "machine_learning_clip_model_description": "Navnet på CLIP-modellen på listen her. Bemærk at du skal genkøre \"Smart Søgning\"-jobbet for alle billeder, hvis du skifter model.", "machine_learning_duplicate_detection": "Dubletdetektion", "machine_learning_duplicate_detection_enabled": "Aktiver duplikatdetektion", "machine_learning_duplicate_detection_enabled_description": "Når slået fra, vil nøjagtigt identiske mediefiler blive de-duplikerede.", @@ -128,12 +129,13 @@ "map_enable_description": "Aktivér kortfunktioner", "map_gps_settings": "Kort- og GPS-indstillinger", "map_gps_settings_description": "Håndter indstillinger for Kort og GPS (Omvendt Geokodning)", + "map_implications": "Kortfunktionen afhænger af en ekstern felt-service (tiles.immich.cloud)", "map_light_style": "Lyst tema", "map_manage_reverse_geocoding_settings": "Håndtér indstillinger for Omvendt Geokoding", "map_reverse_geocoding": "Omvendt geokodning", "map_reverse_geocoding_enable_description": "Aktiver omvendt geokodning", "map_reverse_geocoding_settings": "Omvendt geokodningsindstillinger", - "map_settings": "Kortindstillinger", + "map_settings": "Kort", "map_settings_description": "Administrer kortindstillinger", "map_style_description": "URL til en style.json for et korttema", "metadata_extraction_job": "Udtræk metadata", @@ -154,6 +156,7 @@ "notification_email_port_description": "Emailserverens port (fx 25, 465 eller 587)", "notification_email_sent_test_email_button": "Send test-email og gem", "notification_email_setting_description": "Indstillinger for sending af emailnotifikationer", + "notification_email_test_email": "Send test-email", "notification_email_test_email_failed": "Fejl ved afsendelse af test-email, tjek dine værdier", "notification_email_test_email_sent": "En test-email er blevet sendt til {email}. Venligst tjek din inbox.", "notification_email_username_description": "Brugernavn til brug ved autentificering med e-mailserveren", @@ -172,9 +175,12 @@ "oauth_mobile_redirect_uri": "Mobilomdiregerings-URL", "oauth_mobile_redirect_uri_override": "Tilsidesættelse af mobil omdiregerings-URL", "oauth_mobile_redirect_uri_override_description": "Slå til når \"app.immich:/\" er en ugyldig omdiregerings-URL.", + "oauth_profile_signing_algorithm": "Log-ind-algoritme", + "oauth_profile_signing_algorithm_description": "Algoritme til signering af brugerprofilen.", "oauth_scope": "Omfang", "oauth_settings": "OAuth", "oauth_settings_description": "Administrer OAuth login-indstillinger", + "oauth_settings_more_details": "Læs flere detaljer om funktionen i dokumentationen.", "oauth_signing_algorithm": "Signeringsalgoritme", "oauth_storage_label_claim": "Lagringsmærkat fordring", "oauth_storage_label_claim_description": "Sæt automatisk brugerens lagringsmærkat til denne fordrings værdi.", @@ -190,6 +196,8 @@ "paths_validated_successfully": "Alle stier valideret med succes", "quota_size_gib": "Kvotestørrelse (GiB)", "refreshing_all_libraries": "Opdaterer alle biblioteker", + "registration": "Administratorregistrering", + "registration_description": "Da du er den første bruger i systemet, får du tildelt rollen som administrator og ansvar for administration og oprettelsen af nye brugere.", "removing_offline_files": "Fjerner offline-filer", "repair_all": "Reparér alle", "repair_matched_items": "Har parret {count, plural, one {# element} other {# elementer}}", @@ -210,10 +218,13 @@ "sidecar_job_description": "Opdag eller synkroniser medfølgende metadata fra filsystemet", "slideshow_duration_description": "Antal sekunder at vise hvert billede", "smart_search_job_description": "Kør maskinlæring på mediefiler for at understøtte smart søgning", + "storage_template_date_time_description": "Mediefilens oprettelsesregistrering er grundlag for dato-tid-informationen", + "storage_template_date_time_sample": "Eksempel tid {date}", "storage_template_enable_description": "Slå lagringsskabelonredskab til", - "storage_template_hash_verification_enabled": "Hash-verifikation slog fejl", + "storage_template_hash_verification_enabled": "Hash-verifikation slået til", "storage_template_hash_verification_enabled_description": "Slår hash-verifikation til, slå ikke dette fra med mindre du er sikker på dets konsekvenser", "storage_template_migration": "Lagringsskabelonmigration", + "storage_template_migration_description": "Anvend den nuværende {template} på tidligere uploadede mediefiler", "storage_template_migration_job": "Lagringsmigrationsopgave", "storage_template_settings": "Lagringsskabelon", "storage_template_settings_description": "Administrer mappestrukturen og filnavnet for den uploadede mediefil", diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index 36acb646aaefd..95b9850634fb6 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -174,7 +174,7 @@ "oauth_issuer_url": "Aussteller-URL", "oauth_mobile_redirect_uri": "Mobile Umleitungs-URI", "oauth_mobile_redirect_uri_override": "Mobile Umleitungs-URI überschreiben", - "oauth_mobile_redirect_uri_override_description": "Einschalten, wenn 'app.immich:/' ein ungültiger Redirect-URI ist.", + "oauth_mobile_redirect_uri_override_description": "Einschalten, wenn der OAuth-Provider keine mobile URI wie '{callback}' erlaubt", "oauth_profile_signing_algorithm": "Algorithmus zur Profilsignierung", "oauth_profile_signing_algorithm_description": "Dieser Algorithmus wird für die für die Signatur des Benutzerprofils verwendet.", "oauth_scope": "Umfang", @@ -189,7 +189,7 @@ "oauth_storage_quota_default": "Standard-Speicherplatzkontingent (GiB)", "oauth_storage_quota_default_description": "Kontingent in GiB, welcher verwendet werden kann, wenn kein Anspruch erhoben wurde (Gib 0 für einen unbegrenzten Speicherkontingent ein).", "offline_paths": "Offline-Pfade", - "offline_paths_description": "Die Ergebnisse könnten durch manuelles Löschen von Dateien, die nicht Teil einer externen Bibliothek sein, verursacht sein.", + "offline_paths_description": "Die Ergebnisse könnten durch manuelles Löschen von Dateien, die nicht Teil einer externen Bibliothek sind, verursacht sein.", "password_enable_description": "Login mit E-Mail und Passwort", "password_settings": "Passwort Login", "password_settings_description": "Passwort-Anmeldeeinstellungen verwalten", @@ -338,7 +338,8 @@ "album_added": "Album hinzugefügt", "album_added_notification_setting_description": "Erhalte eine E-Mail-Benachrichtigung, wenn du zu einem freigegebenen Album hinzugefügt wurdest", "album_cover_updated": "Album-Cover aktualisiert", - "album_delete_confirmation": "Bist du sicher, dass du das Album {album} löschen willst?\nWenn dieses Album geteilt wurde, können andere Benutzer nicht mehr darauf zugreifen.", + "album_delete_confirmation": "Bist du sicher, dass du das Album {album} löschen willst?", + "album_delete_confirmation_description": "Wenn dieses Album geteilt wurde, können andere Benutzer nicht mehr darauf zugreifen.", "album_info_updated": "Album-Infos aktualisiert", "album_leave": "Album verlassen?", "album_leave_confirmation": "Bist du sicher, dass du das Album {album} verlassen willst?", @@ -362,6 +363,7 @@ "allow_edits": "Bearbeiten erlauben", "allow_public_user_to_download": "Erlaube öffentlichen Benutzern, herunterzuladen", "allow_public_user_to_upload": "Erlaube öffentlichen Benutzern, hochzuladen", + "anti_clockwise": "Gegen den Uhrzeigersinn", "api_key": "API-Schlüssel", "api_key_description": "Dieser Wert wird nur einmal angezeigt. Bitte kopiere ihn, bevor du das Fenster schließt.", "api_key_empty": "Dein API-Schlüssel-Name darf nicht leer sein", @@ -443,9 +445,11 @@ "clear_all_recent_searches": "Alle letzten Suchvorgänge löschen", "clear_message": "Nachrichten leeren", "clear_value": "Wert leeren", + "clockwise": "Im Uhrzeigersinn", "close": "Schließen", "collapse": "Zusammenklappen", "collapse_all": "Alles aufklappen", + "color": "Farbe", "color_theme": "Farb-Theme", "comment_deleted": "Kommentar gelöscht", "comment_options": "Kommentar-Optionen", @@ -479,6 +483,8 @@ "create_new_person": "Neue Person anlegen", "create_new_person_hint": "Ausgewählte Dateien einer neuen Person zuweisen", "create_new_user": "Neuen Nutzer erstellen", + "create_tag": "Tag erstellen", + "create_tag_description": "Erstelle einen neuen Tag. Für verschachtelte Tags, gib den gesamten Pfad inklusive Slash an.", "create_user": "Nutzer erstellen", "created": "Erstellt", "current_device": "Aktuelles Gerät", @@ -502,6 +508,8 @@ "delete_library": "Bibliothek löschen", "delete_link": "Link löschen", "delete_shared_link": "geteilten Link löschen", + "delete_tag": "Tag löschen", + "delete_tag_confirmation_prompt": "Bist du sicher, dass der Tag {tagName} gelöscht werden soll?", "delete_user": "Nutzer löschen", "deleted_shared_link": "Geteilten Link gelöscht", "description": "Beschreibung", @@ -519,6 +527,8 @@ "do_not_show_again": "Diese Nachricht nicht erneut anzeigen", "done": "Erledigt", "download": "Download", + "download_include_embedded_motion_videos": "Eingebettete Videos", + "download_include_embedded_motion_videos_description": "Videos, die in Bewegungsfotos eingebettet sind, als separate Datei einfügen", "download_settings": "Download", "download_settings_description": "Verwaltung der Einstellungen für den Dateidownload", "downloading": "Downloaden", @@ -548,10 +558,15 @@ "edit_location": "Standort bearbeiten", "edit_name": "Name bearbeiten", "edit_people": "Personen bearbeiten", + "edit_tag": "Tag bearbeiten", "edit_title": "Titel bearbeiten", "edit_user": "Nutzer bearbeiten", "edited": "Bearbeitet", "editor": "Bearbeiter", + "editor_close_without_save_prompt": "Diese Änderungen werden nicht gespeichert", + "editor_close_without_save_title": "Editor schließen?", + "editor_crop_tool_h2_aspect_ratios": "Seitenverhältnisse", + "editor_crop_tool_h2_rotation": "Rotation", "email": "E-Mail", "empty": "Leer", "empty_album": "Leeres Album", @@ -701,6 +716,7 @@ "expired": "Verfallen", "expires_date": "Läuft am {date} ab", "explore": "Erkunden", + "explorer": "Explorer", "export": "Exportieren", "export_as_json": "Als JSON exportieren", "extension": "Erweiterung", @@ -714,6 +730,8 @@ "feature": "Funktion", "feature_photo_updated": "Profilbild aktualisiert", "featurecollection": "Funktionssammlung", + "features": "Funktionen", + "features_setting_description": "Verwalte die Funktionen der App", "file_name": "Dateiname", "file_name_or_extension": "Dateiname oder -erweiterung", "filename": "Dateiname", @@ -722,6 +740,8 @@ "filter_people": "Personen filtern", "find_them_fast": "Finde sie schneller mit der Suche nach Namen", "fix_incorrect_match": "Fehlerhafte Übereinstimmung beheben", + "folders": "Ordner", + "folders_feature_description": "Durchsuchen der Ordneransicht für Fotos und Videos im Dateisystem", "force_re-scan_library_files": "Erzwingen des erneuten Scannens aller Bibliotheksdateien", "forward": "Weiterleiten", "general": "Allgemein", @@ -914,7 +934,7 @@ "ok": "Ok", "oldest_first": "Älteste zuerst", "onboarding": "Einstieg", - "onboarding_privacy_description": "Die folgenden (optionalen) Funktionen basieren auf externen Services und könnem jederzeit in den Administrationseinstellungen deaktiviert werden.", + "onboarding_privacy_description": "Die folgenden (optionalen) Funktionen hängen von externen Diensten ab und können jederzeit in den Administrationseinstellungen deaktiviert werden.", "onboarding_theme_description": "Wähle ein Farbschema für deine Instanz aus. Du kannst dies später in deinen Einstellungen ändern.", "onboarding_welcome_description": "Lass uns deine Instanz mit einigen allgemeinen Einstellungen konfigurieren.", "onboarding_welcome_user": "Willkommen, {user}", @@ -956,6 +976,7 @@ "pending": "Ausstehend", "people": "Personen", "people_edits_count": "{count, plural, one {# Person} other {# Personen}} bearbeitet", + "people_feature_description": "Durchsuchen von Fotos und Videos nach Personen gruppiert", "people_sidebar_description": "Eine Verknüpfung zu Personen in der Seitenleiste anzeigen", "perform_library_tasks": "", "permanent_deletion_warning": "Warnung vor endgültiger Löschung", @@ -1027,7 +1048,9 @@ "purchase_settings_server_activated": "Der Server Produktschlüssel wird durch den Administrator verwaltet", "range": "Reichweite", "rating": "Bewertung", - "rating_description": "Stellt die Exif-Bewertung im Informationsbereich dar", + "rating_clear": "Bewertung löschen", + "rating_count": "{count, plural, one {# Stern} other {# Sterne}}", + "rating_description": "Stellt die EXIF-Bewertung im Informationsbereich dar", "raw": "RAW", "reaction_options": "Reaktionsmöglichkeiten", "read_changelog": "Changelog lesen", @@ -1060,6 +1083,7 @@ "removed_from_archive": "Aus dem Archiv entfernt", "removed_from_favorites": "Von Favoriten entfernt", "removed_from_favorites_count": "{count, plural, other {#}} von Favoriten entfernt", + "removed_tagged_assets": "Tag von {count, plural, one {# Datei} other {# Dateien}} entfernt", "rename": "Umbenennen", "repair": "Reparatur", "repair_no_results_message": "Nicht auffindbare und fehlende Dateien werden hier angezeigt", @@ -1109,6 +1133,7 @@ "search_people": "Suche nach Personen", "search_places": "Suche nach Orten", "search_state": "Suche nach Bundesland / Provinz...", + "search_tags": "Sache nach Tags...", "search_timezone": "Suche nach Zeitzone...", "search_type": "Suche nach Typ", "search_your_photos": "Durchsuche deine Fotos", @@ -1177,6 +1202,8 @@ "show_supporter_badge": "Unterstützerabzeichen", "show_supporter_badge_description": "Zeige Unterstützerabzeichen", "shuffle": "Durchmischen", + "sidebar": "Seitenleiste", + "sidebar_display_description": "Zeigt einen Link zu der Ansicht in der Seitenleiste an", "sign_out": "Abmelden", "sign_up": "Registrieren", "size": "Größe", @@ -1213,6 +1240,13 @@ "sunrise_on_the_beach": "Sonnenaufgang am Strand", "swap_merge_direction": "Vertauschen der Zusammenführungsrichtung", "sync": "Synchronisieren", + "tag": "Tag", + "tag_assets": "Dateien taggen", + "tag_created": "Tag erstellt: {tag}", + "tag_feature_description": "Durchsuchen von Fotos und Videos, gruppiert nach logischen Tag-Themen", + "tag_updated": "Tag aktualisiert: {tag}", + "tagged_assets": "{count, plural, one {# Datei} other {# Dateien}} getagged", + "tags": "Tags", "template": "Vorlage", "theme": "Theme", "theme_selection": "Themenauswahl", @@ -1224,9 +1258,10 @@ "to_change_password": "Passwort ändern", "to_favorite": "Zu Favoriten hinzufügen", "to_login": "Anmelden", + "to_root": "Zur Wurzel", "to_trash": "Zum Papierkorb verschieben", "toggle_settings": "Einstellungen umschalten", - "toggle_theme": "Theme umschalten", + "toggle_theme": "Dunkles Theme umschalten", "toggle_visibility": "Sichtbarkeit umschalten", "total_usage": "Gesamtnutzung", "trash": "Papierkorb", @@ -1248,6 +1283,7 @@ "unlink_oauth": "OAuth entfernen", "unlinked_oauth_account": "Nicht verknüpftes OAuth-Konto", "unnamed_album": "Unbenanntes Album", + "unnamed_album_delete_confirmation": "Bist du sicher, dass du dieses Album löschen willst?", "unnamed_share": "Unbenannte Teilung", "unsaved_change": "Ungespeicherte Änderung", "unselect_all": "Alles abwählen", @@ -1296,6 +1332,7 @@ "view_album": "Album anzeigen", "view_all": "Alles anzeigen", "view_all_users": "Alle Nutzer anzeigen", + "view_in_timeline": "In Zeitleiste anzeigen", "view_links": "Links anzeigen", "view_next_asset": "Nächste Datei anzeigen", "view_previous_asset": "Vorherige Datei anzeigen", diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 2b97cb6e24fd8..25f3b6ea2faab 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -172,7 +172,7 @@ "oauth_issuer_url": "Issuer URL", "oauth_mobile_redirect_uri": "Mobile redirect URI", "oauth_mobile_redirect_uri_override": "Mobile redirect URI override", - "oauth_mobile_redirect_uri_override_description": "Enable when 'app.immich:/' is an invalid redirect URI.", + "oauth_mobile_redirect_uri_override_description": "Enable when OAuth provider does not allow a mobile URI, like '{callback}'", "oauth_profile_signing_algorithm": "Profile signing algorithm", "oauth_profile_signing_algorithm_description": "Algorithm used to sign the user profile.", "oauth_scope": "Scope", @@ -335,7 +335,8 @@ "album_added": "Album added", "album_added_notification_setting_description": "Receive an email notification when you are added to a shared album", "album_cover_updated": "Album cover updated", - "album_delete_confirmation": "Are you sure you want to delete the album {album}?\nIf this album is shared, other users will not be able to access it anymore.", + "album_delete_confirmation": "Are you sure you want to delete the album {album}?", + "album_delete_confirmation_description": "If this album is shared, other users will not be able to access it anymore.", "album_info_updated": "Album info updated", "album_leave": "Leave album?", "album_leave_confirmation": "Are you sure you want to leave {album}?", @@ -439,6 +440,7 @@ "close": "Close", "collapse": "Collapse", "collapse_all": "Collapse all", + "color": "Color", "color_theme": "Color theme", "comment_deleted": "Comment deleted", "comment_options": "Comment options", @@ -472,6 +474,8 @@ "create_new_person": "Create new person", "create_new_person_hint": "Assign selected assets to a new person", "create_new_user": "Create new user", + "create_tag": "Create tag", + "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.", "create_user": "Create user", "created": "Created", "current_device": "Current device", @@ -495,6 +499,8 @@ "delete_library": "Delete library", "delete_link": "Delete link", "delete_shared_link": "Delete shared link", + "delete_tag": "Delete tag", + "delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?", "delete_user": "Delete user", "deleted_shared_link": "Deleted shared link", "description": "Description", @@ -536,6 +542,7 @@ "edit_location": "Edit location", "edit_name": "Edit name", "edit_people": "Edit people", + "edit_tag": "Edit tag", "edit_title": "Edit Title", "edit_user": "Edit user", "edited": "Edited", @@ -683,6 +690,7 @@ "expired": "Expired", "expires_date": "Expires {date}", "explore": "Explore", + "explorer": "Explorer", "export": "Export", "export_as_json": "Export as JSON", "extension": "Extension", @@ -693,6 +701,8 @@ "favorite_or_unfavorite_photo": "Favorite or unfavorite photo", "favorites": "Favorites", "feature_photo_updated": "Feature photo updated", + "features": "Features", + "features_setting_description": "Manage the app features", "file_name": "File name", "file_name_or_extension": "File name or extension", "filename": "Filename", @@ -700,6 +710,8 @@ "filter_people": "Filter people", "find_them_fast": "Find them fast by name with search", "fix_incorrect_match": "Fix incorrect match", + "folders": "Folders", + "folders_feature_description": "Browsing the folder view for the photos and videos on the file system", "force_re-scan_library_files": "Force Re-scan All Library Files", "forward": "Forward", "general": "General", @@ -861,7 +873,7 @@ "ok": "Ok", "oldest_first": "Oldest first", "onboarding": "Onboarding", - "onboarding_privacy_description": "The following (optional) features rely on external services, and can by disabled at any time in the administration settings.", + "onboarding_privacy_description": "The following (optional) features rely on external services, and can be disabled at any time in the administration settings.", "onboarding_theme_description": "Choose a color theme for your instance. You can change this later in your settings.", "onboarding_welcome_description": "Let's get your instance set up with some common settings.", "onboarding_welcome_user": "Welcome, {user}", @@ -903,6 +915,7 @@ "pending": "Pending", "people": "People", "people_edits_count": "Edited {count, plural, one {# person} other {# people}}", + "people_feature_description": "Browsing photos and videos grouped by people", "people_sidebar_description": "Display a link to People in the sidebar", "permanent_deletion_warning": "Permanent deletion warning", "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets", @@ -970,7 +983,9 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "The server product key is managed by the admin", "rating": "Star rating", - "rating_description": "Display the exif rating in the info panel", + "rating_clear": "Clear rating", + "rating_count": "{count, plural, one {# star} other {# stars}}", + "rating_description": "Display the EXIF rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", "reassign": "Reassign", @@ -1002,6 +1017,7 @@ "removed_from_archive": "Removed from archive", "removed_from_favorites": "Removed from favorites", "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites", + "removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}", "rename": "Rename", "repair": "Repair", "repair_no_results_message": "Untracked and missing files will show up here", @@ -1050,6 +1066,7 @@ "search_people": "Search people", "search_places": "Search places", "search_state": "Search state...", + "search_tags": "Search tags...", "search_timezone": "Search timezone...", "search_type": "Search type", "search_your_photos": "Search your photos", @@ -1117,6 +1134,8 @@ "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Show a supporter badge", "shuffle": "Shuffle", + "sidebar": "Sidebar", + "sidebar_display_description": "Display a link to the view in the sidebar", "sign_out": "Sign Out", "sign_up": "Sign up", "size": "Size", @@ -1153,6 +1172,14 @@ "sunrise_on_the_beach": "Sunrise on the beach", "swap_merge_direction": "Swap merge direction", "sync": "Sync", + "tag": "Tag", + "tag_assets": "Tag assets", + "tag_created": "Created tag: {tag}", + "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", + "tag_not_found_question": "Cannot find a tag? Create one here", + "tag_updated": "Updated tag: {tag}", + "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", + "tags": "Tags", "template": "Template", "theme": "Theme", "theme_selection": "Theme selection", @@ -1164,9 +1191,10 @@ "to_change_password": "Change password", "to_favorite": "Favorite", "to_login": "Login", + "to_parent": "Go to parent", "to_trash": "Trash", "toggle_settings": "Toggle settings", - "toggle_theme": "Toggle theme", + "toggle_theme": "Toggle dark theme", "total_usage": "Total usage", "trash": "Trash", "trash_all": "Trash All", @@ -1185,6 +1213,7 @@ "unlink_oauth": "Unlink OAuth", "unlinked_oauth_account": "Unlinked OAuth account", "unnamed_album": "Unnamed Album", + "unnamed_album_delete_confirmation": "Are you sure you want to delete this album?", "unnamed_share": "Unnamed Share", "unsaved_change": "Unsaved change", "unselect_all": "Unselect all", @@ -1231,6 +1260,7 @@ "view_album": "View Album", "view_all": "View All", "view_all_users": "View all users", + "view_in_timeline": "View in timeline", "view_links": "View links", "view_next_asset": "View next asset", "view_previous_asset": "View previous asset", diff --git a/web/src/lib/i18n/es.json b/web/src/lib/i18n/es.json index 1b4aebe8042b4..e538c3024fe8b 100644 --- a/web/src/lib/i18n/es.json +++ b/web/src/lib/i18n/es.json @@ -129,12 +129,13 @@ "map_enable_description": "Habilitar características del mapa", "map_gps_settings": "Configuración de mapas y GPS", "map_gps_settings_description": "Administrar la configuración de mapas y GPS (geocodificación inversa)", + "map_implications": "La función de mapa depende de un servicio externo de mosaicos (tiles.immich.cloud)", "map_light_style": "Estilo claro", "map_manage_reverse_geocoding_settings": "Gestionar los ajustes de la geocodificación inversa", "map_reverse_geocoding": "Geocodificación inversa", "map_reverse_geocoding_enable_description": "Activar geocodificación inversa", "map_reverse_geocoding_settings": "Ajustes Geocodificación Inversa", - "map_settings": "Configuración del mapa", + "map_settings": "Mapa", "map_settings_description": "Administrar la configuración del mapa", "map_style_description": "Dirección URL a un tema de mapa (style.json)", "metadata_extraction_job": "Extracción de metadatos", @@ -173,7 +174,7 @@ "oauth_issuer_url": "URL del emisor", "oauth_mobile_redirect_uri": "URI de redireccionamiento móvil", "oauth_mobile_redirect_uri_override": "Sobreescribir URI de redirección móvil", - "oauth_mobile_redirect_uri_override_description": "Habilítelo cuando 'app.immich:/' sea un URI de redireccionamiento no válido.", + "oauth_mobile_redirect_uri_override_description": "Habilitar cuando el proveedor de OAuth no permite una URI móvil, como '{callback}'", "oauth_profile_signing_algorithm": "Algoritmo de firma de perfiles", "oauth_profile_signing_algorithm_description": "Algoritmo utilizado para firmar el perfil del usuario.", "oauth_scope": "Ámbito", @@ -320,7 +321,8 @@ "user_settings": "Ajustes de usuario", "user_settings_description": "Administrar la configuración del usuario", "user_successfully_removed": "El usuario {email} ha sido eliminado exitosamente.", - "version_check_enabled_description": "Habilite las comprobaciones periódicas a GitHub para verificar nuevas versiones", + "version_check_enabled_description": "Activar la comprobación de la versión", + "version_check_implications": "La función de comprobación de versiones depende de la comunicación periódica con github.com", "version_check_settings": "Verificar Versión", "version_check_settings_description": "Activar/desactivar la notificación de nueva versión", "video_conversion_job": "Transcodificar vídeos", @@ -336,7 +338,8 @@ "album_added": "Álbum añadido", "album_added_notification_setting_description": "Reciba una notificación por correo electrónico cuando lo agreguen a un álbum compartido", "album_cover_updated": "Portada del álbum actualizada", - "album_delete_confirmation": "¿Estás seguro de que deseas eliminar el álbum {album}?\nSi se comparte este álbum, otros usuarios ya no podrán acceder a él.", + "album_delete_confirmation": "¿Estás seguro de que deseas eliminar el álbum {album}?", + "album_delete_confirmation_description": "Si este álbum se comparte, otros usuarios ya no podrán acceder a él.", "album_info_updated": "Información del álbum actualizada", "album_leave": "¿Abandonar el álbum?", "album_leave_confirmation": "¿Estás seguro de que quieres dejar {album}?", @@ -360,6 +363,7 @@ "allow_edits": "Permitir edición", "allow_public_user_to_download": "Permitir descargar al usuario público", "allow_public_user_to_upload": "Permitir cargar al usuario publico", + "anti_clockwise": "En sentido antihorario", "api_key": "Clave API", "api_key_description": "Este valor sólo se mostrará una vez. Asegúrese de copiarlo antes de cerrar la ventana.", "api_key_empty": "El nombre de su clave API no debe estar vacío", @@ -368,7 +372,7 @@ "appears_in": "Aparece en", "archive": "Archivo", "archive_or_unarchive_photo": "Archivar o restaurar foto", - "archive_size": "Tamaño de archivo", + "archive_size": "Tamaño del archivo", "archive_size_description": "Configure el tamaño del archivo para descargas (en GB)", "archived": "Archivado", "archived_count": "{count, plural, one {# archivado} other {# archivados}}", @@ -441,9 +445,11 @@ "clear_all_recent_searches": "Borrar búsquedas recientes", "clear_message": "Limpiar mensaje", "clear_value": "Limpiar valor", + "clockwise": "En el sentido de las agujas del reloj", "close": "Cerrar", "collapse": "Agrupar", "collapse_all": "Desplegar todo", + "color": "Color", "color_theme": "Color del tema", "comment_deleted": "Comentario borrado", "comment_options": "Opciones de comentarios", @@ -477,6 +483,8 @@ "create_new_person": "Crear nueva persona", "create_new_person_hint": "Asignar los archivos seleccionados a una nueva persona", "create_new_user": "Crear nuevo usuario", + "create_tag": "Crear etiqueta", + "create_tag_description": "Crear una nueva etiqueta. Para las etiquetas anidadas, ingresa la ruta completa de la etiqueta, incluidas las barras diagonales.", "create_user": "Crear usuario", "created": "Creado", "current_device": "Dispositivo actual", @@ -500,6 +508,8 @@ "delete_library": "Eliminar biblioteca", "delete_link": "Eliminar enlace", "delete_shared_link": "Eliminar enlace compartido", + "delete_tag": "Eliminar etiqueta", + "delete_tag_confirmation_prompt": "¿Estás seguro de que deseas eliminar la etiqueta {tagName} ?", "delete_user": "Eliminar usuario", "deleted_shared_link": "Enlace compartido eliminado", "description": "Descripción", @@ -517,6 +527,8 @@ "do_not_show_again": "No volver a mostrar este mensaje otra vez", "done": "Hecho", "download": "Descargar", + "download_include_embedded_motion_videos": "Vídeos incrustados", + "download_include_embedded_motion_videos_description": "Incluir vídeos incrustados en fotografías en movimiento como un archivo separado", "download_settings": "Descargar", "download_settings_description": "Administrar configuraciones relacionadas con la descarga de archivos", "downloading": "Descargando", @@ -546,10 +558,15 @@ "edit_location": "Editar ubicación", "edit_name": "Cambiar nombre", "edit_people": "Editar persona", + "edit_tag": "Editar etiqueta", "edit_title": "Editar Titulo", "edit_user": "Editar usuario", "edited": "Editado", "editor": "Editor", + "editor_close_without_save_prompt": "No se guardarán los cambios", + "editor_close_without_save_title": "¿Cerrar el editor?", + "editor_crop_tool_h2_aspect_ratios": "Proporciones del aspecto", + "editor_crop_tool_h2_rotation": "Rotación", "email": "Correo", "empty": "", "empty_album": "Álbum vacío", @@ -692,13 +709,14 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", - "exif": "Exif", + "exif": "EXIF", "exit_slideshow": "Salir de la presentación", "expand_all": "Expandir todo", "expire_after": "Expirar después de", "expired": "Caducado", "expires_date": "Expira el {date}", "explore": "Explorar", + "explorer": "Explorador", "export": "Exportar", "export_as_json": "Exportar a JSON", "extension": "Extension", @@ -712,6 +730,8 @@ "feature": "", "feature_photo_updated": "Foto destacada actualizada", "featurecollection": "", + "features": "Características", + "features_setting_description": "Administrar las funciones de la aplicación", "file_name": "Nombre de archivo", "file_name_or_extension": "Nombre del archivo o extensión", "filename": "Nombre del archivo", @@ -720,6 +740,8 @@ "filter_people": "Filtrar personas", "find_them_fast": "Encuéntrelos rápidamente por nombre con la búsqueda", "fix_incorrect_match": "Corregir coincidencia incorrecta", + "folders": "Carpetas", + "folders_feature_description": "Explorar la vista de carpetas para las fotos y los videos en el sistema de archivos", "force_re-scan_library_files": "Forzar reescaneo de todos los archivos de la biblioteca", "forward": "Reenviar", "general": "General", @@ -912,6 +934,7 @@ "ok": "Sí", "oldest_first": "Los más antiguos primero", "onboarding": "Incorporando", + "onboarding_privacy_description": "Las siguientes funciones (opcionales) dependen de servicios externos y pueden desactivarse en cualquier momento en los ajustes.", "onboarding_theme_description": "Elija un color de tema para su instancia. Puedes cambiar esto más tarde en tu configuración.", "onboarding_welcome_description": "Configuremos su instancia con algunas configuraciones comunes.", "onboarding_welcome_user": "Bienvenido, {user}", @@ -953,6 +976,7 @@ "pending": "Pendiente", "people": "Personas", "people_edits_count": "Editado {count, plural, one {# person} other {# people}}", + "people_feature_description": "Explorar fotos y vídeos agrupados por personas", "people_sidebar_description": "Mostrar un enlace a Personas en la barra lateral", "perform_library_tasks": "", "permanent_deletion_warning": "Advertencia de eliminación permanente", @@ -985,6 +1009,7 @@ "previous_memory": "Recuerdo anterior", "previous_or_next_photo": "Foto anterior o siguiente", "primary": "Básico", + "privacy": "Privacidad", "profile_image_of_user": "Foto de perfil de {user}", "profile_picture_set": "Conjunto de imágenes de perfil.", "public_album": "Álbum público", @@ -1022,6 +1047,10 @@ "purchase_server_title": "Servidor", "purchase_settings_server_activated": "La clave del producto del servidor la administra el administrador", "range": "", + "rating": "Valoración", + "rating_clear": "Borrar calificación", + "rating_count": "{count, plural, one {# estrella} other {# estrellas}}", + "rating_description": "Mostrar la clasificación exif en el panel de información", "raw": "", "reaction_options": "Opciones de reacción", "read_changelog": "Leer registro de cambios", @@ -1054,6 +1083,7 @@ "removed_from_archive": "Eliminado del archivo", "removed_from_favorites": "Eliminado de favoritos", "removed_from_favorites_count": "{count, plural, other {Removed #}} de favoritos", + "removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# activo} other {# activos}}", "rename": "Renombrar", "repair": "Reparar", "repair_no_results_message": "Los archivos perdidos y sin seguimiento aparecerán aquí", @@ -1103,6 +1133,7 @@ "search_people": "Buscar personas", "search_places": "Buscar lugar", "search_state": "Buscar región/estado...", + "search_tags": "Buscando etiquetas...", "search_timezone": "Buscar zona horaria...", "search_type": "Tipo de búsqueda", "search_your_photos": "Busca tus fotos", @@ -1144,6 +1175,7 @@ "shared_by_user": "Compartido por {user}", "shared_by_you": "Compartido por ti", "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opciones de enlaces compartidos", "shared_links": "Enlaces compartidos", "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos y vídeos compartidos.}}", "shared_with_partner": "Compartido con {partner}", @@ -1170,6 +1202,8 @@ "show_supporter_badge": "Insignia de colaborador", "show_supporter_badge_description": "Mostrar una insignia de colaborador", "shuffle": "Modo aleatorio", + "sidebar": "Barra lateral", + "sidebar_display_description": "Muestra un enlace a la vista en la barra lateral", "sign_out": "Salir", "sign_up": "Registrarse", "size": "Tamaño", @@ -1206,6 +1240,13 @@ "sunrise_on_the_beach": "Amanecer en la playa", "swap_merge_direction": "Alternar dirección de mezcla", "sync": "Sincronizar", + "tag": "Etiqueta", + "tag_assets": "Etiquetar activos", + "tag_created": "Etiqueta creada: {tag}", + "tag_feature_description": "Explore fotos y videos agrupados por temas de etiquetas lógicas", + "tag_updated": "Etiqueta actualizada: {tag}", + "tagged_assets": "Etiquetado(s) {count, plural, one {# activo} other {# activos}}", + "tags": "Etiquetas", "template": "Plantilla", "theme": "Tema", "theme_selection": "Selección de tema", @@ -1217,9 +1258,10 @@ "to_change_password": "Cambiar contraseña", "to_favorite": "A los favoritos", "to_login": "Iniciar Sesión", + "to_root": "Para root", "to_trash": "Papelera", "toggle_settings": "Alternar ajustes", - "toggle_theme": "Alternar tema", + "toggle_theme": "Alternar tema oscuro", "toggle_visibility": "Alternar visibilidad", "total_usage": "Uso total", "trash": "Papelera", @@ -1241,6 +1283,7 @@ "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Cuenta OAuth desconectada", "unnamed_album": "Album sin nombre", + "unnamed_album_delete_confirmation": "¿Seguro que quieres borrar este álbum?", "unnamed_share": "Compartido sin nombre", "unsaved_change": "Cambio no guardado", "unselect_all": "Limpiar selección", @@ -1289,6 +1332,7 @@ "view_album": "Ver Álbum", "view_all": "Ver todas", "view_all_users": "Mostrar todos los usuarios", + "view_in_timeline": "Mostrar en la línea de tiempo", "view_links": "Mostrar enlaces", "view_next_asset": "Mostrar siguiente elemento", "view_previous_asset": "Mostrar elemento anterior", diff --git a/web/src/lib/i18n/et.json b/web/src/lib/i18n/et.json index 0967ef424bce6..e37ea8acd1db9 100644 --- a/web/src/lib/i18n/et.json +++ b/web/src/lib/i18n/et.json @@ -1 +1,767 @@ -{} +{ + "about": "Teave", + "account": "Konto", + "account_settings": "Konto seaded", + "acknowledge": "Sain aru", + "action": "Tegevus", + "actions": "Tegevused", + "active": "Aktiivne", + "activity": "Aktiivsus", + "activity_changed": "Aktiivsus on {enabled, select, true {lubatud} other {keelatud}}", + "add": "Lisa", + "add_a_description": "Lisa kirjeldus", + "add_a_location": "Lisa asukoht", + "add_a_name": "Lisa nimi", + "add_a_title": "Lisa pealkiri", + "add_exclusion_pattern": "Lisa välistamismuster", + "add_import_path": "Lisa imporditee", + "add_location": "Lisa asukoht", + "add_more_users": "Lisa rohkem kasutajaid", + "add_partner": "Lisa partner", + "add_path": "Lisa tee", + "add_photos": "Lisa fotosid", + "add_to": "Lisa kohta...", + "add_to_album": "Lisa albumisse", + "add_to_shared_album": "Lisa jagatud albumisse", + "added_to_archive": "Lisatud arhiivi", + "added_to_favorites": "Lisatud lemmikutesse", + "added_to_favorites_count": "{count, number} pilti lisatud lemmikutesse", + "admin": { + "add_exclusion_pattern_description": "Lisa välistamismustreid. Toetatud on metamärgid *, ** ja ?. Kõikide kataloogis nimega \"Raw\" olevate failide ignoreerimiseks kasuta \"**/Raw/**\". Kõikide .tif failide ignoreerimiseks kasuta \"**/*.tif\". Absouutse tee ignoreerimiseks kasuta \"/path/to/ignore/**\".", + "authentication_settings": "Autentimise seaded", + "authentication_settings_description": "Halda parooli, OAuth ja muid autentimise seadeid", + "authentication_settings_disable_all": "Kas oled kindel, et soovid kõik sisselogimismeetodid välja lülitada? Sisselogimine lülitatakse täielikult välja.", + "authentication_settings_reenable": "Et taas lubada, kasuta serveri käsku.", + "background_task_job": "Taustaülesanded", + "check_all": "Märgi kõik", + "cleared_jobs": "Ülesanded eemaldatud: {job}", + "config_set_by_file": "Konfiguratsioon on määratud konfifaili abil", + "confirm_delete_library": "Kas oled kindel, et soovid kustutada {library} kogu?", + "confirm_delete_library_assets": "Kas oled kindel, et soovid selle kogu kustutada? Sellega kustutatakse {count, plural, one {# sisalduv üksus} other {kõik # sisalduvat üksust}} Immich'ist ning seda ei saa tagasi võtta. Failid jäävad kettale alles.", + "confirm_email_below": "Kinnitamiseks sisesta allpool \"{email}\"", + "confirm_reprocess_all_faces": "Kas oled kindel, et soovid kõik näod uuesti töödelda? See eemaldab kõik nimega isikud.", + "confirm_user_password_reset": "Kas oled kindel, et soovid kasutaja {user} parooli lähtestada?", + "disable_login": "Keela sisselogimine", + "exclusion_pattern_description": "Välistamismustrid võimaldavad ignoreerida faile ja kaustu kogu skaneerimisel. See on kasulik, kui sul on kaustu, mis sisaldavad faile, mida sa ei soovi importida, nagu RAW failid.", + "external_library_created_at": "Väline kogu (lisatud {date})", + "external_library_management": "Väliste kogude haldus", + "face_detection": "Näotuvastus", + "face_detection_description": "Otsi üksustest nägusid masinõppe abil. Videote puhul kasutatakse ainult pisipilti. \"Kõik\" töötleb kõik üksused uuesti. \"Puuduvad\" võtab ette üksused, mida pole veel töödeldud. Leitud näod suunatakse näotuvastusse, et grupeerida nad olemasolevateks või uuteks isikuteks.", + "facial_recognition_job_description": "Grupeeri leitud näod inimesteks. See samm käivitub siis, kui näotuvastus on lõppenud. \"Kõik\" grupeerib kõik näod uuesti. \"Puuduvad\" võtab ette näod, mida pole isikuga seostatud.", + "force_delete_user_warning": "HOIATUS: See kustutab koheselt kasutaja ja kõik üksused. Seda ei saa tagasi võtta ja faile ei saa taastada.", + "image_format_description": "WebP failid on väiksemad kui JPEG, aga kodeerimine on aeglasem.", + "image_prefer_embedded_preview_setting_description": "Kasuta pilditöötluse sisendina võimalusel RAW fotodesse manustatud eelvaateid. See võib mõnede piltide puhul anda tulemuseks täpsemad värvid, aga eelvaate kvaliteet sõltub konkreetsest kaamerast ning pildis võib olla rohkem tihendusmüra.", + "image_prefer_wide_gamut_setting_description": "Kasuta pisipiltide jaoks Display P3. See säilitab paremini laia värviruumiga piltide erksuse, aga vanematel seadmetel ja vanemate brauseritega võivad pildid teistsugused välja näha. sRGB pildid säilitatakse värvinihete vältimiseks.", + "image_preview_format": "Eelvaate formaat", + "image_preview_resolution": "Eelvaate resolutsioon", + "image_preview_resolution_description": "Kasutusel üksiku foto vaatamisel ja masinõppe jaoks. Kõrgem resolutsioon säilitab rohkem detaile, aga kodeerimine võtab rohkem aega, tekitab suurema faili ning võib mõjutada rakenduse töökiirust.", + "image_quality_description": "Pildikvaliteet vahemikus 1-100. Kõrgem väärtus tähendab paremat kvaliteeti ja suuremaid faile. See valik mõjutab eelvaateid ja pisipilte.", + "image_thumbnail_format": "Pisipildi formaat", + "image_thumbnail_resolution": "Pisipildi resolutsioon", + "image_thumbnail_resolution_description": "Kasutusel fotode mitmekaupa vaatamisel (ajajoon, albumi vaade, jne). Kõrgem resolutsioon säilitab rohkem detaile, aga kodeerimine võtab rohkem aega, tekitab suurema faili ning võib mõjutada rakenduse töökiirust.", + "library_created": "Lisatud kogu: {library}", + "library_cron_expression": "Cron avaldis", + "library_cron_expression_description": "Sea skaneerimise intervall cron formaadis. Rohkema info jaoks vaata nt. Crontab Guru", + "library_cron_expression_presets": "Eelseadistatud cron avaldised", + "library_deleted": "Kogu kustutatud", + "library_import_path_description": "Määra kaust, mida importida. Sellest kaustast ning alamkaustadest otsitakse pilte ja videosid.", + "library_scanning": "Perioodiline skaneerimine", + "library_scanning_description": "Seadista kogu perioodiline skaneerimine", + "library_scanning_enable_description": "Luba kogu perioodiline skaneerimine", + "library_settings": "Väline kogu", + "library_settings_description": "Halda välise kogu seadeid", + "library_watching_enable_description": "Jälgi välises kogus failide muudatusi", + "library_watching_settings": "Kogu jälgimine (EKSPERIMENTAALNE)", + "library_watching_settings_description": "Jälgi automaatselt muutunud faile", + "machine_learning_duplicate_detection_enabled_description": "Kui keelatud, dedubleeritakse siiski täpselt identsed üksused.", + "machine_learning_facial_recognition_description": "Otsi, tuvasta ja grupeeri piltidel näod", + "machine_learning_facial_recognition_model_description": "Mudelid on järjestatud suuruse järgi kahanevalt. Suuremad mudelid on aeglasemad ja kasutavad rohkem mälu, kuid annavad parema tulemuse. Mudeli muutmisel tuleb kõik pildid uuesti näotuvastusest läbi lasta.", + "machine_learning_max_recognition_distance_description": "Maksimaalne kaugus kahe näo vahel, mida tuleks lugeda samaks isikuks, vahemikus 0-2. Selle vähendamine aitab vältida erinevate inimeste samaks isikuks märkimist ja tõstmine aitab vältida sama inimese kaheks erinevaks isikuks märkimist. Pane tähele, et kaht isikut ühendada on lihtsam kui üht isikut kaheks eraldada, seega võimalusel kasuta madalamat lävendit.", + "machine_learning_min_detection_score_description": "Minimaalne usaldusskoor näo tuvastamiseks, vahemikus 0-1. Madalamad väärtused leiavad rohkem nägusid, kuid võib esineda valepositiivseid.", + "machine_learning_min_recognized_faces": "Minimaalne leitud nägude arv", + "machine_learning_min_recognized_faces_description": "Minimaalne leitud nägude arv, mida saab isikuks grupeerida. Selle suurendamine teeb näotuvastuse täpsemaks, kuid suureneb tõenäosus, et nägu ei seostata ühegi isikuga.", + "machine_learning_settings": "Masinõppe seaded", + "machine_learning_url_description": "Masinõppe serveri URL", + "map_dark_style": "Tume stiil", + "map_gps_settings": "Kaardi ja GPS-i seaded", + "map_light_style": "Hele stiil", + "map_settings": "Kaart", + "map_settings_description": "Halda kaardi seadeid", + "metadata_extraction_job_description": "Eralda igast üksusest metaandmed, nagu GPS-koordinaadid ja resolutsioon", + "migration_job_description": "Migreeri üksuste ja nägude pisipildid uusimale kaustastruktuurile", + "note_cannot_be_changed_later": "MÄRKUS: Seda ei saa hiljem muuta!", + "notification_email_from_address": "Saatja aadress", + "notification_email_from_address_description": "Saatja e-posti aadress, näiteks: \"Immich Photo Server \"", + "notification_email_host_description": "E-posti serveri host (nt. smtp.immich.app)", + "notification_email_ignore_certificate_errors": "Ignoreeri sertifikaadi vigu", + "notification_email_ignore_certificate_errors_description": "Ignoreeri TLS sertifikaadi valideerimise vigu (mittesoovituslik)", + "notification_email_password_description": "Parool e-posti serveriga autentimiseks", + "notification_email_port_description": "E-posti serveri port (nt. 25, 465 või 587)", + "notification_email_sent_test_email_button": "Saada test e-kiri ja salvesta", + "notification_email_test_email": "Saada test e-kiri", + "notification_email_test_email_sent": "Test e-kiri saadeti aadressile {email}. Kontrolli oma kirjakasti.", + "notification_email_username_description": "Kasutajanimi e-posti serveriga autentimiseks", + "oauth_enable_description": "Sisene OAuth abil", + "oauth_settings_description": "Halda OAuth sisselogimise seadeid", + "password_enable_description": "Logi sisse e-posti aadressi ja parooliga", + "password_settings": "Parooliga sisselogimine", + "password_settings_description": "Halda parooliga sisselogimise seadeid", + "quota_size_gib": "Kvoot (GiB)", + "registration_description": "Kuna sa oled süsteemis esimene kasutaja, määratakse sind administraatoriks, ning sa saad lisada täiendavaid kasutajaid.", + "require_password_change_on_login": "Nõua kasutajalt esmakordsel sisenemisel parooli muutmist", + "reset_settings_to_default": "Lähtesta seaded", + "reset_settings_to_recent_saved": "Taasta hiljuti salvestatud seaded", + "server_external_domain_settings": "Väline domeen", + "server_external_domain_settings_description": "Domeen avalikult jagatud linkide jaoks, k.a. http(s)://", + "server_settings": "Serveri seaded", + "server_settings_description": "Halda serveri seadeid", + "server_welcome_message": "Tervitusteade", + "server_welcome_message_description": "Teade, mida kuvatakse sisselogimise lehel.", + "slideshow_duration_description": "Mitu sekundit igat pilti kuvada", + "storage_template_settings_description": "Halda üleslaaditud üksuse kaustastruktuuri ja failinime", + "system_settings": "Süsteemi seaded", + "theme_settings_description": "Halda Immich'i veebiliidese kohandamist", + "thumbnail_generation_job": "Genereeri pisipildid", + "thumbnail_generation_job_description": "Genereeri iga üksuse kohta suur, väike ja udustatud pisipilt ning iga isiku kohta pisipilt", + "transcoding_acceleration_nvenc": "NVENC (vajab NVIDIA GPU-d)", + "transcoding_acceleration_qsv": "Quick Sync (vajab Inteli 7. põlvkonna või uuemat CPU-d)", + "transcoding_acceleration_rkmpp": "RKMPP (ainult Rockchip SOC-d)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Lubatud audiokoodekid", + "transcoding_accepted_audio_codecs_description": "Vali, millised audiokoodekid ei vaja transkodeerimist. Kasutusel ainult teatud transkodeerimisreeglite puhul.", + "transcoding_accepted_containers": "Lubatud konteinerid", + "transcoding_accepted_containers_description": "Vali, millised konteineriformaadid ei vaja MP4-ks teisendamist. Kasutusel ainult teatud transkodeerimisreeglite puhul.", + "transcoding_accepted_video_codecs": "Lubatud videokoodekid", + "transcoding_accepted_video_codecs_description": "Vali, millised videokoodekid ei vaja transkodeerimist. Kasutusel ainult teatud transkodeerimisreeglite puhul.", + "transcoding_advanced_options_description": "Valikud, mida enamik kasutajaid ei pea muutma", + "transcoding_audio_codec": "Audiokoodek", + "transcoding_audio_codec_description": "Opus on kõrgeima kvaliteediga valik, aga on vähem ühilduv vanade seadmete või tarkvaraga.", + "transcoding_bitrate_description": "Kõrgema kui lubatud bitisagedusega või mittelubatud formaadis videod", + "transcoding_codecs_learn_more": "Siin kasutatud terminoloogia kohta rohkem teada saamiseks loe FFmpeg-i dokumentatsiooni H.264, HEVC ja VP9 koodekite kohta.", + "transcoding_constant_quality_mode": "Püsiva kvaliteedi režiim", + "transcoding_constant_quality_mode_description": "ICQ on parem kui CQP, aga mõned riistvaralise kiirenduse seadmed ei toeta seda režiimi. Selle valiku seadmisel eelistatakse kvaliteedipõhise kodeerimise puhul valitud režiimi. NVENC puhul valikut ignoreeritakse, kuna see ei toeta ICQ-d.", + "transcoding_constant_rate_factor_description": "Video kvaliteeditase. Tüüpilised väärtused on 23 (H.264), 28 (HEVC), 31 (VP9) ning 35 (AV1). Madal on parem, aga tulemuseks on suuremad failid.", + "transcoding_disabled_description": "Ära transkodeeri videosid. Võib takistada taasesitamist mõnedes seadmetes", + "transcoding_hardware_acceleration": "Riistvaraline kiirendus", + "transcoding_hardware_acceleration_description": "Eksperimentaalne; palju kiirem, aga sama bitisageduse juures madalam kvaliteet", + "transcoding_hardware_decoding": "Riistvaraline dekodeerimine", + "transcoding_hardware_decoding_setting_description": "Rakendub ainult NVENC, QSV ja RKMPP puhul. Võimaldab protsessi läbivalt kiirendada, mitte ainult kodeerimist. Ei pruugi kõigi videote puhul töötada.", + "transcoding_hevc_codec": "HEVC koodek", + "transcoding_max_bitrate": "Maksimaalne bitisagedus", + "transcoding_max_bitrate_description": "Maksimaalse bitisageduse määramine teeb failisuurused ennustatavamaks, väikese kvaliteedikao hinnaga. 720p resolutsiooni puhul on tüüpilised väärtused 2600k (VP9 ja HEVC) või 4500k (H.264). Väärtus 0 eemaldab piirangu.", + "transcoding_max_keyframe_interval": "Maksimaalne võtmekaadri intervall", + "transcoding_max_keyframe_interval_description": "Määrab maksimaalse kauguse võtmekaadrite vahel. Madalamad väärtused vähendavad pakkimise efektiivsust, aga parandavad otsimiskiirust ning võivad tõsta kiire liikumisega stseenide kvaliteeti. 0 määrab väärtuse automaatselt.", + "transcoding_optimal_description": "Kõrgema kui lubatud resolutsiooniga või mittelubatud formaadis videod", + "transcoding_preferred_hardware_device": "Eelistatud riistvaraseade", + "transcoding_preferred_hardware_device_description": "Rakendub ainult VAAPI ja QSV puhul. Määrab dri seadme, mida kasutatakse riistvaraliseks transkodeerimiseks.", + "transcoding_preset_preset": "Eelseadistus (-preset)", + "transcoding_preset_preset_description": "Pakkimiskiirus. Aeglasemad eelseadistused tekitavad väiksemaid faile ja annavad sama bitisageduse juures parema kvaliteedi. VP9 ignoreerib kiiruseid üle 'faster' taseme.", + "transcoding_required_description": "Ainult mittelubatud formaadis videod", + "transcoding_settings": "Video transkodeerimise seaded", + "transcoding_settings_description": "Halda videofailide resolutsiooni ja kodeerimist", + "transcoding_target_resolution": "Sihtresolutsioon", + "transcoding_target_resolution_description": "Kõrgemad resolutsioonid säilitavad rohkem detaile, aga kodeerimine võtab kauem aega, tekitab suuremaid faile ning võib mõjutada rakenduse töökiirust.", + "transcoding_temporal_aq": "Temporal AQ", + "transcoding_temporal_aq_description": "Rakendub NVENC puhul. Parandab paljude detailide, aga vähese liikumisega stseenide kvaliteeti. Ei pruugi ühilduda vanemate seadmetega.", + "transcoding_threads": "Lõimed", + "transcoding_threads_description": "Kõrgem väärtus tähendab kiiremat kodeerimist, aga jätab serverile muude tegevuste jaoks vähem ressursse. See väärtus ei tohiks olla suurem kui protsessori tuumade arv. Väärtus 0 tähendab maksimaalset kasutust.", + "transcoding_tone_mapping": "Toonivastendus", + "transcoding_tone_mapping_description": "Üritab säilitada HDR videote kvaliteeti SDR-iks teisendamisel. Iga algoritm teeb värvi, detailide ja ereduse osas erinevaid kompromisse. Hable säilitab detaile, Mobius säilitab värve ning Reinhard säilitab eredust.", + "transcoding_tone_mapping_npl": "Toonivastendus NPL", + "transcoding_tone_mapping_npl_description": "Muudab värve, et need paistaksid sellise eredusega ekraanil normaalsed. Madalamad väärtused suurendavad video eredust ja vastupidi, kuna see kompenseerib ekraani eredust. 0 määrab väärtuse automaatselt.", + "transcoding_transcode_policy": "Transkodeerimise reegel", + "transcoding_transcode_policy_description": "Reegel, millal tuleks videot transkodeerida. HDR-videosid transkodeeritakse alati (v.a. kui transkodeerimine on keelatud).", + "transcoding_two_pass_encoding": "Kahekäiguline kodeerimine", + "transcoding_two_pass_encoding_setting_description": "Transkodeeri kahes osas, et parandada kodeeritud videote kvaliteeti. Maksimaalse bitisageduse puhul (mis on vajalik H.264 ja HEVC jaoks) kasutab see režiim bitisageduse vahemikku ja ignoreerib CRF-i. VP9 puhul saab kasutada CRF-i, kui maksimaalset bitisagedust pole määratud.", + "transcoding_video_codec": "Videokoodek", + "transcoding_video_codec_description": "VP9 on võimekas ja veebiga ühilduv, aga transkodeerimine võtab kauem aega. HEVC on sarnase jõudluse, aga mitte nii hea veebiga ühilduvusega. H.264 on laialt ühilduv ja transkodeerimine on kiire, aga tulemuseks on suuremad failid. AV1 on kõige võimekam koodek, aga pole vanematel seadmetel toetatud.", + "trash_number_of_days": "Päevade arv", + "trash_number_of_days_description": "Päevade arv, kui kaua hoida üksusi prügikastis enne nende lõplikku kustutamist", + "user_delete_delay": "Kasutaja {user} konto ja üksuste lõplik kustutamine on planeeritud {delay, plural, one {# päeva} other {# päeva}} pärast.", + "user_delete_immediately": "Kasutaja {user} konto ja üksused suunatakse koheselt jäädavale kustutamisele.", + "user_delete_immediately_checkbox": "Suuna kasutaja ja üksused jäädavale kustutamisele", + "user_password_has_been_reset": "Kasutaja parool on lähtestatud:", + "user_password_reset_description": "Sisesta kasutajale ajutine parool ja teavita teda, et järgmisel sisselogimisel tuleb parool ära muuta.", + "user_restore_description": "Kasutaja {user} konto taastatakse.", + "user_successfully_removed": "Kasutaja {email} on eemaldatud.", + "version_check_enabled_description": "Luba versioonikontroll", + "version_check_implications": "Versioonikontroll vajab perioodilist ühendumist github.com-iga", + "version_check_settings": "Versioonikontroll", + "version_check_settings_description": "Luba/keela uue versiooni teavitus", + "video_conversion_job": "Transkodeeri videod", + "video_conversion_job_description": "Transkodeeri videod suurema brauserite ja seadmetega ühilduvuse nimel" + }, + "admin_email": "Administraatori e-post", + "admin_password": "Administraatori parool", + "age_months": "Vanus {months, plural, one {# kuu} other {# kuud}}", + "age_year_months": "Vanus 1 aasta, {months, plural, one {# kuu} other {# kuud}}", + "age_years": "{years, plural, other {Vanus #}}", + "album_added": "Album lisatud", + "album_added_notification_setting_description": "Saa teavitus e-posti teel, kui sind lisatakse jagatud albumisse", + "album_cover_updated": "Albumi kaanepilt muudetud", + "album_delete_confirmation": "Kas oled kindel, et soovid albumi {album} kustutada?", + "album_delete_confirmation_description": "Kui see album on jagatud, ei pääse teised kasutajad sellele enam ligi.", + "album_info_updated": "Albumi info muudetud", + "album_leave": "Lahku albumist?", + "album_leave_confirmation": "Kas oled kindel, et soovid albumist {album} lahkuda?", + "album_name": "Albumi nimi", + "album_options": "Albumi valikud", + "album_remove_user": "Eemalda kasutaja?", + "album_remove_user_confirmation": "Kas oled kindel, et soovid kasutaja {user} eemaldada?", + "album_updated": "Album muudetud", + "album_updated_setting_description": "Saa e-posti teel teavitus, kui jagatud albumis on uusi üksuseid", + "albums": "Albumid", + "albums_count": "{count, plural, one {{count, number} album} other {{count, number} albumit}}", + "all_albums": "Kõik albumid", + "all_videos": "Kõik videod", + "api_key_description": "Seda väärtust kuvatakse ainult üks kord. Kopeeri see enne akna sulgemist.", + "archive": "Arhiiv", + "archive_or_unarchive_photo": "Arhiveeri või taasta foto", + "archive_size": "Arhiivi suurus", + "archive_size_description": "Seadista arhiivi suurus allalaadimiseks (GiB)", + "are_you_sure_to_do_this": "Kas oled kindel, et soovid seda teha?", + "asset_added_to_album": "Lisatud albumisse", + "asset_adding_to_album": "Albumisse lisamine...", + "asset_description_updated": "Üksuse kirjeldus on muudetud", + "asset_filename_is_offline": "Üksus {filename} ei ole kättesaadav", + "asset_has_unassigned_faces": "Üksusel on seostamata nägusid", + "asset_hashing": "Räsimine...", + "asset_offline": "Üksus pole kättesaadav", + "asset_offline_description": "See üksus pole kättesaadav. Immich ei saa selle asukohale ligi. Palun tee üksus kättesaadavaks ja siis skaneeri kogu uuesti.", + "asset_skipped": "Vahele jäetud", + "asset_uploaded": "Üleslaaditud", + "asset_uploading": "Üleslaadimine...", + "assets": "Üksused", + "assets_added_count": "{count, plural, one {# üksus} other {# üksust}} lisatud", + "assets_added_to_album_count": "{count, plural, one {# üksus} other {# üksust}} albumisse lisatud", + "assets_added_to_name_count": "{count, plural, one {# üksus} other {# üksust}} lisatud {hasName, select, true {albumisse {name}} other {uude albumisse}}", + "assets_count": "{count, plural, one {# üksus} other {# üksust}}", + "assets_moved_to_trash_count": "{count, plural, one {# üksus} other {# üksust}} liigutatud prügikasti", + "assets_permanently_deleted_count": "{count, plural, one {# üksus} other {# üksust}} jäädavalt kustutatud", + "assets_removed_count": "{count, plural, one {# üksus} other {# üksust}} eemaldatud", + "assets_restore_confirmation": "Kas oled kindel, et soovid oma kustutatud üksused taastada? Seda ei saa tagasi võtta!", + "assets_restored_count": "{count, plural, one {# üksus} other {# üksust}} taastatud", + "assets_trashed_count": "{count, plural, one {# üksus} other {# üksust}} kustutatud", + "assets_were_part_of_album_count": "{count, plural, one {Üksus oli} other {Üksused olid}} juba osa albumist", + "authorized_devices": "Autoriseeritud seadmed", + "birthdate_saved": "Sünnikuupäev salvestatud", + "birthdate_set_description": "Sünnikuupäeva kasutatakse isiku vanuse arvutamiseks foto tegemise hetkel.", + "bulk_delete_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} masskustutada? Sellega jäetakse alles iga grupi suurim üksus ning duplikaadid kustutatakse jäädavalt. Seda tegevust ei saa tagasi võtta!", + "bulk_keep_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} alles jätta? Sellega märgitakse kõik duplikaadigrupid lahendatuks ilma midagi kustutamata.", + "bulk_trash_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} masskustutada? Sellega jäetakse alles iga grupi suurim üksus ning duplikaadid liigutatakse prügikasti.", + "camera": "Kaamera", + "camera_brand": "Kaamera mark", + "camera_model": "Kaamera mudel", + "cancel": "Katkesta", + "cannot_undo_this_action": "Sa ei saa seda tagasi võtta!", + "change_password": "Parooli muutmine", + "change_password_description": "See on su esimene kord süsteemi siseneda, või on tehtud taotlus parooli muutmiseks. Palun sisesta allpool uus parool.", + "change_your_password": "Muuda oma parooli", + "changed_visibility_successfully": "Nähtavus muudetud", + "check_all": "Märgi kõik", + "city": "Linn", + "clockwise": "Päripäeva", + "close": "Sulge", + "color": "Värv", + "color_theme": "Värviteema", + "comment_deleted": "Kommentaar kustutatud", + "comment_options": "Kommentaari valikud", + "comments_and_likes": "Kommentaarid ja meeldimised", + "comments_are_disabled": "Kommentaarid on keelatud", + "confirm": "Kinnita", + "confirm_admin_password": "Kinnita administraatori parool", + "confirm_delete_shared_link": "Kas oled kindel, et soovid selle jagatud lingi kustutada?", + "confirm_password": "Kinnita parool", + "context": "Kontekst", + "copied_image_to_clipboard": "Pilt kopeeritud lõikelauale.", + "copied_to_clipboard": "Kopeeritud lõikelauale!", + "copy_error": "Kopeeri viga", + "copy_file_path": "Kopeeri failitee", + "copy_image": "Kopeeri pilt", + "copy_link": "Kopeeri link", + "copy_link_to_clipboard": "Kopeeri link lõikelauale", + "copy_password": "Kopeeri parool", + "copy_to_clipboard": "Kopeeri lõikelauale", + "country": "Riik", + "create_album": "Lisa album", + "create_library": "Lisa kogu", + "create_new_person_hint": "Seosta valitud üksused uue isikuga", + "create_tag": "Lisa silt", + "current_device": "Praegune seade", + "custom_locale": "Kohandatud lokaat", + "custom_locale_description": "Vorminda kuupäevad ja arvud vastavalt keelele ja regioonile", + "date_of_birth_saved": "Sünnikuupäev salvestatud", + "day": "Päev", + "default_locale": "Vaikimisi lokaat", + "default_locale_description": "Vorminda kuupäevad ja numbrid vastavalt brauseri lokaadile", + "delete_album": "Kustuta album", + "delete_api_key_prompt": "Kas oled kindel, et soovid selle API võtme kustutada?", + "delete_duplicates_confirmation": "Kas oled kindel, et soovid need duplikaadid jäädavalt kustutada?", + "delete_library": "Kustuta kogu", + "delete_link": "Kustuta link", + "delete_shared_link": "Kustuta jagatud link", + "delete_tag": "Kustuta silt", + "delete_tag_confirmation_prompt": "Kas oled kindel, et soovid sildi {tagName} kustutada?", + "delete_user": "Kustuta kasutaja", + "deleted_shared_link": "Jagatud link kustutatud", + "description": "Kirjeldus", + "direction": "Suund", + "display_original_photos_setting_description": "Eelista üksuse vaatamisel pisipildile algset fotot, kui see on veebiga ühilduv. See võib mõjutada fotode kuvamise kiirust.", + "done": "Tehtud", + "download": "Laadi alla", + "download_settings": "Allalaadimine", + "download_settings_description": "Halda üksuste allalaadimise seadeid", + "downloading_asset_filename": "Üksuse {filename} allalaadimine", + "duplicates": "Duplikaadid", + "duplicates_description": "Lahenda iga grupp, valides duplikaadid, kui neid on", + "duration": "Kestus", + "edit": "Muuda", + "edit_album": "Muuda albumit", + "edit_avatar": "Muuda avatari", + "edit_date": "Muuda kuupäeva", + "edit_date_and_time": "Muuda kuupäeva ja kellaaega", + "edit_exclusion_pattern": "Muuda välistamismustrit", + "edit_faces": "Muuda nägusid", + "edit_import_path": "Muuda imporditeed", + "edit_key": "Muuda võtit", + "edit_link": "Muuda linki", + "edit_location": "Muuda asukohta", + "edit_name": "Muuda nime", + "edit_people": "Muuda isikuid", + "edit_tag": "Muuda silti", + "edit_title": "Muuda pealkirja", + "edit_user": "Muuda kasutajat", + "edited": "Muudetud", + "editor_close_without_save_prompt": "Muudatusi ei salvestata", + "email": "E-post", + "empty_trash": "Tühjenda prügikast", + "empty_trash_confirmation": "Kas oled kindel, et soovid prügikasti tühjendada? See eemaldab kõik seal olevad üksused Immich'ist jäädavalt.\nSeda tegevust ei saa tagasi võtta!", + "end_date": "Lõppkuupäev", + "error": "Viga", + "error_loading_image": "Viga pildi laadimisel", + "error_title": "Viga - midagi läks valesti", + "errors": { + "cannot_navigate_next_asset": "Järgmise üksuse juurde liikumine ebaõnnestus", + "cannot_navigate_previous_asset": "Eelmise üksuse juurde liikumine ebaõnnestus", + "cant_apply_changes": "Muudatusi ei õnnestunud rakendada", + "cant_change_asset_favorite": "Üksuse lemmiku staatust ei õnnestunud muuta", + "cant_change_metadata_assets_count": "{count, plural, one {# üksuse} other {# üksuse}} metaandmeid ei õnnestunud muuta", + "cant_get_faces": "Nägusid ei õnnestunud kätte saada", + "cant_get_number_of_comments": "Kommentaare ei õnnestunud leida", + "cant_search_people": "Isikuid ei õnnestunud otsida", + "cant_search_places": "Kohti ei õnnestunud otsida", + "error_adding_assets_to_album": "Viga üksuste albumisse lisamisel", + "error_adding_users_to_album": "Viga kasutajate albumisse lisamisel", + "error_deleting_shared_user": "Viga jagatud kasutaja kustutamisel", + "error_downloading": "Viga faili {filename} allalaadimisel", + "error_hiding_buy_button": "Viga ostmise nupu peitmisel", + "error_removing_assets_from_album": "Viga üksuste albumist eemaldamisel, rohkem infot leiad konsoolilt", + "error_selecting_all_assets": "Viga kõigi üksuste valimisel", + "exclusion_pattern_already_exists": "See välistamismuster on juba olemas.", + "failed_to_create_album": "Albumi lisamine ebaõnnestus", + "failed_to_create_shared_link": "Jagatud lingi lisamine ebaõnnestus", + "failed_to_edit_shared_link": "Jagatud lingi muutmine ebaõnnestus", + "failed_to_load_asset": "Üksuse laadimine ebaõnnestus", + "failed_to_load_assets": "Üksuste laadimine ebaõnnestus", + "failed_to_remove_product_key": "Tootevõtme eemaldamine ebaõnnestus", + "failed_to_stack_assets": "Üksuste virnastamine ebaõnnestus", + "import_path_already_exists": "See imporditee on juba olemas.", + "incorrect_email_or_password": "Vale e-posti aadress või parool", + "profile_picture_transparent_pixels": "Profiilipildis ei tohi olla läbipaistvaid piksleid. Palun suumi sisse ja/või liiguta pilti.", + "unable_to_add_album_users": "Kasutajate lisamine albumisse ebaõnnestus", + "unable_to_add_assets_to_shared_link": "Üksuste jagatud lingile lisamine ebaõnnestus", + "unable_to_add_comment": "Kommentaari lisamine ebaõnnestus", + "unable_to_add_exclusion_pattern": "Välistamismustri lisamine ebaõnnestus", + "unable_to_add_import_path": "Imporditee lisamine ebaõnnestus", + "unable_to_add_partners": "Partnerite lisamine ebaõnnestus", + "unable_to_add_remove_archive": "{archived, select, true {Üksuse arhiivist taastamine} other {Üksuse arhiveerimine}} ebaõnnestus", + "unable_to_add_remove_favorites": "Üksuse {favorite, select, true {lemmikuks lisamine} other {lemmikutest eemaldamine}} ebaõnnestus", + "unable_to_archive_unarchive": "{archived, select, true {Arhiveerimine} other {Arhiivist taastamine}} ebaõnnestus", + "unable_to_change_date": "Kuupäeva muutmine ebaõnnestus", + "unable_to_change_favorite": "Üksuse lemmiku staatuse muutmine ebaõnnestus", + "unable_to_change_location": "Asukoha muutmine ebaõnnestus", + "unable_to_change_password": "Parooli muutmine ebaõnnestus", + "unable_to_change_visibility": "{count, plural, one {# isiku} other {# isiku}} nähtavuse muutmine ebaõnnestus", + "unable_to_complete_oauth_login": "OAuth sisselogimine ebaõnnestus", + "unable_to_connect": "Ühendumine ebaõnnestus", + "unable_to_connect_to_server": "Serveriga ühendumine ebaõnnestus", + "unable_to_copy_to_clipboard": "Ei saanud kopeerida lõikelauale, kontrolli, kas kasutad lehte üle https-i", + "unable_to_create_admin_account": "Administraatori konto loomine ebaõnnestus", + "unable_to_create_api_key": "Uue API võtme lisamine ebaõnnestus", + "unable_to_create_library": "Kogu lisamine ebaõnnestus", + "unable_to_create_user": "Kasutaja lisamine ebaõnnestus", + "unable_to_delete_album": "Albumi kustutamine ebaõnnestus", + "unable_to_delete_asset": "Üksuse kustutamine ebaõnnestus", + "unable_to_delete_assets": "Viga üksuste kustutamisel", + "unable_to_delete_exclusion_pattern": "Välistamismustri kustutamine ebaõnnestus", + "unable_to_delete_import_path": "Imporditee kustutamine ebaõnnestus", + "unable_to_delete_shared_link": "Jagatud lingi kustutamine ebaõnnestus", + "unable_to_delete_user": "Kasutaja kustutamine ebaõnnestus", + "unable_to_download_files": "Failide allalaadimine ebaõnnestus", + "unable_to_edit_exclusion_pattern": "Välistamismustri muutmine ebaõnnestus", + "unable_to_edit_import_path": "Imporditee muutmine ebaõnnestus", + "unable_to_empty_trash": "Prügikasti tühjendamine ebaõnnestus", + "unable_to_enter_fullscreen": "Täisekraanile lülitamine ebaõnnestus", + "unable_to_exit_fullscreen": "Täisekraanilt väljumine ebaõnnestus", + "unable_to_get_comments_number": "Kommentaaride arvu leidmine ebaõnnestus", + "unable_to_hide_person": "Isiku peitmine ebaõnnestus", + "unable_to_link_oauth_account": "OAuth konto ühendamine ebaõnnestus", + "unable_to_load_album": "Albumi laadimine ebaõnnestus", + "unable_to_load_asset_activity": "Üksuse aktiivsuse laadimine ebaõnnestus", + "unable_to_log_out_all_devices": "Kõigist seadmetest väljalogimine ebaõnnestus", + "unable_to_log_out_device": "Seadmest väljalogimine ebaõnnestus", + "unable_to_login_with_oauth": "OAuth abil sisselogimine ebaõnnestus", + "unable_to_play_video": "Video esitamine ebaõnnestus", + "unable_to_reassign_assets_existing_person": "Üksuste {name, select, null {olemasoleva isikuga} other {isikuga {name}}} seostamine ebaõnnestus", + "unable_to_reassign_assets_new_person": "Üksuste uue isikuga seostamine ebaõnnestus", + "unable_to_refresh_user": "Kasutaja värskendamine ebaõnnestus", + "unable_to_remove_album_users": "Kasutajate albumist eemaldamine ebaõnnestus", + "unable_to_remove_api_key": "API võtme eemaldamine ebaõnnestus", + "unable_to_remove_assets_from_shared_link": "Üksuste jagatud lingilt eemaldamine ebaõnnestus", + "unable_to_remove_library": "Kogu eemaldamine ebaõnnestus", + "unable_to_remove_partner": "Partneri eemaldamine ebaõnnestus", + "unable_to_remove_reaction": "Reaktsiooni eemaldamine ebaõnnestus", + "unable_to_reset_password": "Parooli lähtestamine ebaõnnestus", + "unable_to_resolve_duplicate": "Duplikaadi lahendamine ebaõnnestus", + "unable_to_restore_assets": "Üksuste taastamine ebaõnnestus", + "unable_to_restore_trash": "Prügikastist taastamine ebaõnnestus", + "unable_to_restore_user": "Kasutaja taastamine ebaõnnestus", + "unable_to_save_album": "Albumi salvestamine ebaõnnestus", + "unable_to_save_api_key": "API võtme salvestamine ebaõnnestus", + "unable_to_save_date_of_birth": "Sünnikuupäeva salvestamine ebaõnnestus", + "unable_to_save_name": "Nime salvestamine ebaõnnestus", + "unable_to_save_profile": "Profiili salvestamine ebaõnnestus", + "unable_to_save_settings": "Seadete salvestamine ebaõnnestus", + "unable_to_scan_libraries": "Kogude skaneerimine ebaõnnestus", + "unable_to_scan_library": "Kogu skaneerimine ebaõnnestus", + "unable_to_set_feature_photo": "Esiletõstetud foto seadmine ebaõnnestus", + "unable_to_set_profile_picture": "Profiilipildi seadmine ebaõnnestus", + "unable_to_trash_asset": "Üksuse kustutamine ebaõnnestus", + "unable_to_update_album_cover": "Albumi kaanepildi muutmine ebaõnnestus", + "unable_to_update_album_info": "Albumi info muutmine ebaõnnestus", + "unable_to_update_library": "Kogu uuendamine ebaõnnestus", + "unable_to_update_location": "Asukoha muutmine ebaõnnestus", + "unable_to_update_settings": "Seadete muutmine ebaõnnestus", + "unable_to_update_user": "Kasutaja muutmine ebaõnnestus", + "unable_to_upload_file": "Faili üleslaadimine ebaõnnestus" + }, + "exif": "Exif", + "expire_after": "Aegub pärast", + "expired": "Aegunud", + "expires_date": "Aegub {date}", + "explore": "Avasta", + "export_as_json": "Ekspordi JSON-formaati", + "extension": "Laiend", + "face_unassigned": "Seostamata", + "favorite": "Lemmik", + "favorites": "Lemmikud", + "feature_photo_updated": "Esiletõstetud foto muudetud", + "file_name": "Failinimi", + "file_name_or_extension": "Failinimi või -laiend", + "filename": "Failinimi", + "filetype": "Failitüüp", + "folders": "Kaustad", + "forward": "Edasi", + "general": "Üldine", + "go_back": "Tagasi", + "group_albums_by": "Grupeeri albumid...", + "group_no": "Ära grupeeri", + "group_owner": "Grupeeri omaniku kaupa", + "group_year": "Grupeeri aasta kaupa", + "hi_user": "Tere {name} ({email})", + "hide_all_people": "Peida kõik isikud", + "hide_gallery": "Peida galerii", + "hide_named_person": "Peida isik {name}", + "hide_password": "Peida parool", + "hide_person": "Peida isik", + "hide_unnamed_people": "Peida nimetud isikud", + "host": "Host", + "hour": "Tund", + "image": "Pilt", + "image_alt_text_date": "{isVideo, select, true {Video} other {Pilt}} tehtud {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} koos isikuga {person1}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} koos isikutega {person1} ja {person2}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} koos isikutega {person1}, {person2} ja {person3}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} koos {person1}, {person2} ja veel {additionalCount, number} isikuga", + "immich_logo": "Immich'i logo", + "immich_web_interface": "Immich'i veebiliides", + "import_from_json": "Impordi JSON-formaadist", + "in_archive": "Arhiivis", + "info": "Info", + "interval": { + "day_at_onepm": "Iga päev kell 13", + "hours": "Iga {hours, plural, one {tunni} other {{hours, number} tunni}} tagant", + "night_at_midnight": "Iga päev keskööl", + "night_at_twoam": "Iga öö kell 2" + }, + "invite_people": "Kutsu inimesi", + "invite_to_album": "Kutsu albumisse", + "keep": "Jäta alles", + "keep_all": "Jäta kõik alles", + "keyboard_shortcuts": "Kiirklahvid", + "language": "Keel", + "language_setting_description": "Vali oma eelistatud keel", + "last_seen": "Viimati nähtud", + "latest_version": "Uusim versioon", + "latitude": "Laiuskraad", + "leave": "Lahku", + "library": "Kogu", + "library_options": "Kogu seaded", + "loading_search_results_failed": "Otsitulemuste laadimine ebaõnnestus", + "log_out": "Logi välja", + "log_out_all_devices": "Logi kõigist seadmetest välja", + "logged_out_all_devices": "Kõigist seadmetest välja logitud", + "logged_out_device": "Seadmest välja logitud", + "login_has_been_disabled": "Sisselogimine on keelatud.", + "logout_all_device_confirmation": "Kas oled kindel, et soovid kõigist seadmetest välja logida?", + "logout_this_device_confirmation": "Kas oled kindel, et soovid sellest seadmest välja logida?", + "longitude": "Pikkuskraad", + "make": "Mark", + "manage_shared_links": "Halda jagatud linke", + "manage_sharing_with_partners": "Halda partneritega jagamist", + "manage_your_account": "Halda oma kontot", + "manage_your_api_keys": "Halda oma API võtmeid", + "manage_your_devices": "Halda oma autenditud seadmeid", + "map": "Kaart", + "map_settings": "Kaardi seaded", + "memories": "Mälestused", + "memory": "Mälestus", + "menu": "Menüü", + "merge": "Ühenda", + "merge_people": "Ühenda isikud", + "merge_people_limit": "Korraga saab ühendada kuni 5 nägu", + "merge_people_prompt": "Kas soovid need isikud ühendada? Seda tegevust ei saa tagasi võtta.", + "merge_people_successfully": "Isikud ühendatud", + "merged_people_count": "Ühendatud {count, plural, one {# isik} other {# isikut}}", + "minute": "Minut", + "model": "Mudel", + "month": "Kuu", + "more": "Rohkem", + "my_albums": "Minu albumid", + "name": "Nimi", + "name_or_nickname": "Nimi või hüüdnimi", + "new_album": "Uus album", + "new_api_key": "Uus API võti", + "new_password": "Uus parool", + "new_person": "Uus isik", + "new_user_created": "Uus isik lisatud", + "new_version_available": "UUS VERSIOON SAADAVAL", + "newest_first": "Uuemad eespool", + "next": "Järgmine", + "next_memory": "Järgmine mälestus", + "no": "Ei", + "no_albums_message": "Lisa album fotode ja videote organiseerimiseks", + "no_archived_assets_message": "Arhiveeri fotod ja videod, et neid Fotod vaatest peita", + "no_assets_message": "KLIKI ESIMESE FOTO ÜLESLAADIMISEKS", + "no_duplicates_found": "Ühtegi duplikaati ei leitud.", + "no_exif_info_available": "Exif info pole saadaval", + "no_favorites_message": "Lisa lemmikud, et oma parimaid fotosid ja videosid kiiresti leida", + "no_libraries_message": "Lisa väline kogu oma fotode ja videote vaatamiseks", + "notifications": "Teavitused", + "notifications_setting_description": "Halda teavitusi", + "oauth": "OAuth", + "oldest_first": "Vanemad eespool", + "onboarding_theme_description": "Vali oma serverile värviteema. Saad seda hiljem seadetes muuta.", + "onboarding_welcome_user": "Tere tulemast, {user}", + "only_favorites": "Ainult lemmikud", + "open_in_map_view": "Ava kaardi vaates", + "open_in_openstreetmap": "Ava OpenStreetMap", + "options": "Valikud", + "or": "või", + "organize_your_library": "Korrasta oma kogu", + "original": "originaal", + "other_devices": "Muud seadmed", + "owner": "Omanik", + "partner": "Partner", + "partner_can_access": "{partner} pääseb ligi", + "partner_can_access_assets": "Kõik su fotod ja videod, välja arvatud arhiveeritud ja kustutatud", + "partner_can_access_location": "Asukohad, kus su fotod tehti", + "partner_sharing": "Partneriga jagamine", + "partners": "Partnerid", + "password": "Parool", + "password_does_not_match": "Parool ei klapi", + "password_required": "Parool on nõutud", + "password_reset_success": "Parooli lähtestamine õnnestus", + "past_durations": { + "days": "{days, plural, one {Viimane päev} other {Viimased # päeva}}", + "hours": "{hours, plural, one {Viimane tund} other {Viimased # tundi}}", + "years": "{years, plural, one {Viimane aasta} other {Viimased # aastat}}" + }, + "people": "Isikud", + "people_edits_count": "{count, plural, one {# isik} other {# isikut}} muudetud", + "people_sidebar_description": "Kuva külgmenüüs Isikute link", + "permanent_deletion_warning": "Jäädavalt kustutamise hoiatus", + "permanent_deletion_warning_setting_description": "Kuva hoiatust üksuste jäädaval kustutamisel", + "permanently_delete": "Kustuta jäädavalt", + "permanently_delete_assets_count": "Kustuta {count, plural, one {üksus} other {üksused}} jäädavalt", + "permanently_delete_assets_prompt": "Kas oled kindel, et soovid {count, plural, one {selle üksuse} other {need # üksust}} jäädavalt kustutada? Sellega eemaldatakse {count, plural, one {see} other {need}} ka oma albumi(te)st.", + "permanently_deleted_asset": "Üksus jäädavalt kustutatud", + "permanently_deleted_assets_count": "{count, plural, one {# üksus} other {# üksust}} jäädavalt kustutatud", + "person": "Isik", + "photos": "Fotod", + "photos_and_videos": "Fotod ja videod", + "photos_count": "{count, plural, one {{count, number} foto} other {{count, number} fotot}}", + "photos_from_previous_years": "Fotod varasematest aastatest", + "pick_a_location": "Vali asukoht", + "place": "Asukoht", + "places": "Kohad", + "port": "Port", + "preset": "Eelseadistus", + "preview": "Eelvaade", + "previous": "Eelmine", + "previous_memory": "Eelmine mälestus", + "previous_or_next_photo": "Eelmine või järgmine foto", + "privacy": "Privaatsus", + "profile_image_of_user": "Kasutaja {user} profiilipilt", + "profile_picture_set": "Profiilipilt määratud.", + "public_album": "Avalik album", + "purchase_account_info": "Toetaja", + "purchase_activated_subtitle": "Aitäh, et toetad Immich'it ja avatud lähtekoodiga tarkvara", + "purchase_activated_time": "Aktiveeritud {date, date}", + "purchase_activated_title": "Sinu võtme aktiveerimine õnnestus", + "purchase_button_activate": "Aktiveeri", + "purchase_button_buy": "Osta", + "purchase_button_buy_immich": "Osta Immich", + "purchase_button_never_show_again": "Ära näita enam", + "purchase_button_reminder": "Tuleta mulle 30 päeva pärast meelde", + "purchase_button_remove_key": "Eemalda võti", + "purchase_button_select": "Vali", + "purchase_failed_activation": "Aktiveerimine ebaõnnestus! Kontrolli oma kirjakastist õiget tootevõtit!", + "purchase_individual_description_1": "Üksikisikule", + "purchase_individual_description_2": "Toetaja staatus", + "purchase_input_suggestion": "Sul on juba tootevõti? Sisesta see allpool", + "purchase_license_subtitle": "Osta Immich, et toetada selle jätkuvat arendust", + "purchase_lifetime_description": "Eluaegne ost", + "purchase_option_title": "OSTMISE VALIKUD", + "purchase_panel_info_2": "Kuna oleme otsustanud maksumüüre mitte lisada, ei anna see ost sulle Immich'is lisavõimalusi. Me loodame Immich'i jätkuvaks arenduseks sinusuguste kasutajate toetusele.", + "purchase_panel_title": "Toeta projekti", + "purchase_remove_product_key": "Eemalda tootevõti", + "purchase_remove_product_key_prompt": "Kas oled kindel, et soovid tootevõtme eemaldada?", + "purchase_remove_server_product_key": "Eemalda serveri tootevõti", + "purchase_remove_server_product_key_prompt": "Kas oled kindel, et soovid serveri tootevõtme eemaldada?", + "purchase_server_description_1": "Kogu serveri jaoks", + "purchase_server_description_2": "Toetaja staatus", + "purchase_server_title": "Server", + "purchase_settings_server_activated": "Serveri tootevõtit haldab administraator", + "reassigned_assets_to_existing_person": "{count, plural, one {# üksus} other {# üksust}} seostatud {name, select, null {olemasoleva isikuga} other {isikuga {name}}}", + "reassigned_assets_to_new_person": "{count, plural, one {# üksus} other {# üksust}} seostatud uue isikuga", + "reassing_hint": "Seosta valitud üksused olemasoleva isikuga", + "refresh": "Värskenda", + "refresh_encoded_videos": "Värskenda kodeeritud videod", + "refresh_metadata": "Värskenda metaandmed", + "refresh_thumbnails": "Värskenda pisipildid", + "refreshed": "Värskendatud", + "refreshing_encoded_video": "Kodeeritud videote värskendamine", + "regenerating_thumbnails": "Pisipiltide uuesti genereerimine", + "remove": "Eemalda", + "remove_assets_album_confirmation": "Kas oled kindel, et soovid {count, plural, one {# üksuse} other {# üksust}} albumist eemaldada?", + "remove_assets_shared_link_confirmation": "Kas oled kindel, et soovid eemaldada {count, plural, one {# üksuse} other {# üksust}} sellelt jagatud lingilt?", + "remove_assets_title": "Eemalda üksused?", + "remove_from_album": "Eemalda albumist", + "remove_from_favorites": "Eemalda lemmikutest", + "remove_user": "Eemalda kasutaja", + "removed_api_key": "API võti eemaldatud: {name}", + "removed_from_archive": "Arhiivist eemaldatud", + "removed_tagged_assets": "Silt eemaldatud {count, plural, one {# üksuselt} other {# üksuselt}}", + "require_password": "Nõua parooli", + "require_user_to_change_password_on_first_login": "Nõua kasutajalt esmakordsel sisenemisel parooli muutmist", + "reset": "Lähtesta", + "reset_password": "Lähtesta parool", + "reset_to_default": "Lähtesta", + "restore": "Taasta", + "restore_all": "Taasta kõik", + "restore_user": "Taasta kasutaja", + "restored_asset": "Üksus taastatud", + "resume": "Jätka", + "role": "Roll", + "save": "Salvesta", + "saved_api_key": "API võti salvestatud", + "saved_profile": "Profiil salvestatud", + "saved_settings": "Seaded salvestatud", + "say_something": "Ütle midagi", + "search": "Otsi", + "search_by_filename": "Otsi failinime või -laiendi järgi", + "search_by_filename_example": "st. IMG_1234.JPG või PNG", + "search_camera_make": "Otsi kaamera marki...", + "search_camera_model": "Otsi kaamera mudelit...", + "search_city": "Otsi linna...", + "search_country": "Otsi riiki...", + "search_state": "Otsi osariiki...", + "search_tags": "Otsi silte...", + "search_timezone": "Otsi ajavööndit...", + "search_your_photos": "Otsi oma fotosid", + "second": "Sekund", + "select_album_cover": "Vali albumi kaanepilt", + "select_all": "Vali kõik", + "select_all_duplicates": "Vali kõik duplikaadid", + "select_avatar_color": "Vali avatari värv", + "select_face": "Vali nägu", + "select_featured_photo": "Vali esiletõstetud foto", + "select_library_owner": "Vali kogu omanik", + "select_new_face": "Vali uus nägu", + "select_photos": "Vali fotod", + "server_stats": "Serveri statistika", + "server_version": "Serveri versioon", + "set_as_album_cover": "Sea albumi kaanepildiks", + "shared": "Jagatud", + "shared_by_user": "Jagas {user}", + "shared_by_you": "Jagasid sina", + "shared_from_partner": "Fotod partnerilt {partner}", + "shared_link_options": "Jagatud lingi valikud", + "shared_links": "Jagatud lingid", + "shared_photos_and_videos_count": "{assetCount, plural, other {# jagatud fotot ja videot.}}", + "shared_with_partner": "Jagatud partneriga {partner}", + "sharing_enter_password": "Palun sisesta selle lehe vaatamiseks salasõna.", + "shift_to_permanent_delete": "vajuta ⇧, et üksus jäädavalt kustutada", + "show_in_timeline_setting_description": "Kuva oma ajajoonel selle kasutaja fotosid ja videosid", + "show_password": "Kuva parooli", + "show_supporter_badge": "Toetaja märk", + "show_supporter_badge_description": "Kuva toetaja märki", + "sort_albums_by": "Järjesta albumid...", + "sort_oldest": "Vanim foto", + "sort_recent": "Kõige hiljutisem foto", + "stack": "Virn", + "stack_select_one_photo": "Vali virnale kaanefoto", + "stack_selected_photos": "Virnasta valitud fotod", + "stacked_assets_count": "{count, plural, one {# üksus} other {# üksust}} virnastatud", + "state": "Osariik", + "stop_photo_sharing": "Lõpeta oma fotode jagamine?", + "stop_photo_sharing_description": "{partner} ei pääse rohkem su fotodele ligi.", + "stop_sharing_photos_with_user": "Lõpeta oma fotode selle kasutajaga jagamine", + "tag": "Silt", + "tag_assets": "Sildista üksuseid", + "tag_created": "Lisatud silt: {tag}", + "tag_updated": "Muudetud silt: {tag}", + "tagged_assets": "{count, plural, one {# üksus} other {# üksust}} sildistatud", + "tags": "Sildid", + "timezone": "Ajavöönd", + "to_archive": "Arhiivi", + "to_change_password": "Muuda parool", + "trash_delete_asset": "Kustuta üksus", + "unarchive": "Taasta arhiivist", + "unnamed_album": "Nimetu album", + "unnamed_album_delete_confirmation": "Kas oled kindel, et soovid selle albumi kustutada?", + "updated_password": "Parool muudetud", + "upload_errors": "Üleslaadimine lõpetatud {count, plural, one {# veaga} other {# veaga}}, uute üksuste nägemiseks värskenda lehte.", + "upload_skipped_duplicates": "{count, plural, one {# dubleeritud üksus} other {# dubleeritud üksust}} vahele jäetud", + "upload_success": "Üleslaadimine õnnestus, uute üksuste nägemiseks värskenda lehte.", + "user_liked": "Kasutajale {user} meeldis {type, select, photo {see foto} video {see video} asset {see üksus} other {see}}", + "username": "Kasutajanimi", + "users": "Kasutajad", + "utilities": "Tööriistad", + "variables": "Muutujad", + "version": "Versioon", + "version_announcement_closing": "Sinu sõber, Alex", + "video": "Video", + "video_hover_setting": "Esita hõljutamisel video eelvaade", + "video_hover_setting_description": "Esita video eelvaade, kui hiirt selle kohal hõljutada. Isegi kui keelatud, saab taasesituse alustada taasesitusnupu kohal hõljutades.", + "videos": "Videod", + "videos_count": "{count, plural, one {# video} other {# videot}}", + "view_album": "Vaata albumit", + "view_next_asset": "Vaata järgmist üksust", + "view_previous_asset": "Vaata eelmist üksust", + "week": "Nädal", + "year": "Aasta", + "years_ago": "{years, plural, one {# aasta} other {# aastat}} tagasi", + "you_dont_have_any_shared_links": "Sul pole ühtegi jagatud linki" +} diff --git a/web/src/lib/i18n/fa.json b/web/src/lib/i18n/fa.json index 2c297ce36eb28..0eb6b7b014f19 100644 --- a/web/src/lib/i18n/fa.json +++ b/web/src/lib/i18n/fa.json @@ -213,113 +213,120 @@ "sidecar_job": "اطلاعات جانبی", "sidecar_job_description": "یافتن یا همگام‌سازی اطلاعات جانبی از فایل سیستم", "slideshow_duration_description": "زمان ( به ثانیه ) نشان دادن هر عکس", - "smart_search_job_description": "اجرای یادگیری ماشین بر روی عکسها برای پشتیبانی از جستجوی هوشمند", + "smart_search_job_description": "اجرای یادگیری ماشینی بر روی دارایی‌ها برای پشتیبانی از جستجوی هوشمند", + "storage_template_date_time_description": "زمان‌بندی ایجاد دارایی برای اطلاعات تاریخ و زمان استفاده می‌شود", + "storage_template_date_time_sample": "نمونه زمان {date}", "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration": "", - "storage_template_migration_description": "", - "storage_template_migration_info": "", - "storage_template_migration_job": "", - "storage_template_more_details": "", - "storage_template_onboarding_description": "", - "storage_template_path_length": "", - "storage_template_settings": "", - "storage_template_settings_description": "", - "storage_template_user_label": "", - "system_settings": "", - "theme_custom_css_settings": "", - "theme_custom_css_settings_description": "", - "theme_settings": "", - "theme_settings_description": "", - "these_files_matched_by_checksum": "", - "thumbnail_generation_job": "", - "thumbnail_generation_job_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", - "transcoding_acceleration_qsv": "", - "transcoding_acceleration_rkmpp": "", - "transcoding_acceleration_vaapi": "", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", - "transcoding_audio_codec": "", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_codecs_learn_more": "", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", + "storage_template_hash_verification_enabled": "تأیید هَش فعال شد", + "storage_template_hash_verification_enabled_description": "تأیید هَش را فعال می‌کند؛ این گزینه را غیرفعال نکنید مگر اینکه از عواقب آن مطمئن باشید", + "storage_template_migration": "انتقال الگوی ذخیره سازی", + "storage_template_migration_description": "قالب فعلی {template} را به دارایی‌های بارگذاری شده قبلی اعمال کنید", + "storage_template_migration_info": "تغییرات قالب فقط به دارایی‌های جدید اعمال خواهد شد. برای اعمال قالب به دارایی‌های بارگذاری شده قبلی، باید {job} را اجرا کنید.", + "storage_template_migration_job": "وظیفه مهاجرت الگوی ذخیره‌سازی", + "storage_template_more_details": "برای جزئیات بیشتر درباره این ویژگی، به قالب ذخیره‌سازی و مفاهیم آن مراجعه کنید", + "storage_template_onboarding_description": "زمانی که این ویژگی فعال شود، فایل‌ها به‌طور خودکار بر اساس یک قالب تعریف‌شده توسط کاربر سازماندهی می‌شوند. به دلیل مشکلات پایداری، این ویژگی به‌طور پیش‌فرض غیرفعال است. برای اطلاعات بیشتر، لطفاً به مستندات مراجعه کنید.", + "storage_template_path_length": "حداکثر طول مسیر تقریبی: {length, number}/{limit, number}", + "storage_template_settings": "قالب ذخیره‌سازی", + "storage_template_settings_description": "مدیریت ساختار پوشه و نام فایل دارایی بارگذاری شده", + "storage_template_user_label": "{label} برچسب ذخیره‌سازی کاربر است", + "system_settings": "تنظیمات سیستم", + "theme_custom_css_settings": "CSS سفارشی", + "theme_custom_css_settings_description": "برگه‌های سبک آبشاری (CSS) امکان سفارشی‌سازی طراحی Immich را فراهم می‌کنند.", + "theme_settings": "تنظیمات پوسته", + "theme_settings_description": "مدیریت سفارشی‌سازی رابط کاربری وب Immich", + "these_files_matched_by_checksum": "این فایل‌ها با استفاده از چک‌سام‌هایشان مطابقت دارند", + "thumbnail_generation_job": "ایجاد تصاویر بندانگشتی", + "thumbnail_generation_job_description": "ایجاد تصاویر بندانگشتی بزرگ، کوچک و تار برای هر دارایی، همچنین تصاویر بندانگشتی برای هر فرد", + "transcoding_acceleration_api": "API شتاب‌دهنده", + "transcoding_acceleration_api_description": "API که با دستگاه شما تعامل خواهد داشت تا فرایند تبدیل (ترنسکودینگ) را تسریع کند. این تنظیم به‌صورت «بهترین تلاش» عمل می‌کند: در صورت شکست، به تبدیل نرم‌افزاری بازمی‌گردد. عملکرد VP9 بسته به سخت‌افزار شما ممکن است کار کند یا نکند.", + "transcoding_acceleration_nvenc": "NVENC ( کارت گرافیک NVIDIA لازم است )", + "transcoding_acceleration_qsv": "همگام سازی سریع (نیاز به پردازنده اینتل نسل هفتم یا بالاتر)", + "transcoding_acceleration_rkmpp": "RKMPP (فقط بر روی Rockchip SOCs)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "کدک‌های صوتی پذیرفته شده", + "transcoding_accepted_audio_codecs_description": "انتخاب کدک‌های صوتی که نیازی به تبدیل (ترنسکود) ندارند. فقط برای برخی سیاست‌های رمزگشایی استفاده می‌شود.", + "transcoding_accepted_containers": "کانتینرهای پذیرفته شده", + "transcoding_accepted_containers_description": "انتخاب قالب‌های محتوایی که نیازی به تغییر به MP4 ندارند. فقط برای برخی سیاست‌های رمزگشایی استفاده می‌شود.", + "transcoding_accepted_video_codecs": "کدک‌های ویدیویی پذیرفته شده", + "transcoding_accepted_video_codecs_description": "انتخاب کدک‌های ویدیویی که نیازی به تبدیل (ترنسکود) ندارند. فقط برای برخی سیاست‌های رمزگشایی استفاده می‌شود.", + "transcoding_advanced_options_description": "گزینه‌هایی که بیشتر کاربران نیازی به تغییر آن‌ها ندارند", + "transcoding_audio_codec": "کدک صوتی", + "transcoding_audio_codec_description": "OPUS بهترین گزینه از نظر کیفیت است، اما با دستگاه‌ها یا نرم‌افزارهای قدیمی‌تر سازگاری کمتری دارد.", + "transcoding_bitrate_description": "ویدیوهایی که بالاتر از حداکثر بیت‌ریت هستند یا در فرمت پذیرفته‌ شده نیستند", + "transcoding_codecs_learn_more": "برای آشنایی بیشتر با اصطلاحات استفاده شده در اینجا، به مستندات FFmpeg برای کدک‌های H.264، HEVC و VP9 مراجعه کنید.", + "transcoding_constant_quality_mode": "حالت کیفیت ثابت", + "transcoding_constant_quality_mode_description": "ICQ بهتر از CQP است، اما برخی از دستگاه‌های تسریع سخت‌افزاری از این حالت پشتیبانی نمی‌کنند. تنظیم این گزینه باعث می‌شود که حالت مشخص شده در هنگام استفاده از کدگذاری مبتنی بر کیفیت ترجیح داده شود. این گزینه توسط NVENC نادیده گرفته می‌شود زیرا از ICQ پشتیبانی نمی‌کند.", + "transcoding_constant_rate_factor": "ضریب نرخ ثابت ( crf- )", + "transcoding_constant_rate_factor_description": "سطح کیفیت ویدیو. هرچه عدد کمتر باشد، کیفیت بهتر است، اما فایل‌های بزرگ‌تری تولید می‌کند. مقادیر معمول عبارتند از: (23 <-- H.264) - (28 --> HEVC) - (31 --> VP9) - (35 --> AV1).", + "transcoding_disabled_description": "هیچ ویدیویی را تبدیل فرمت نکنید، زیرا ممکن است پخش در برخی از کلاینت‌ها را مختل کند", + "transcoding_hardware_acceleration": "شتاب دهنده سخت افزاری", + "transcoding_hardware_acceleration_description": "آزمایشی؛ بسیار سریع‌تر است، اما در همان بیت‌ریت کیفیت کمتری خواهد داشت", + "transcoding_hardware_decoding": "رمزگشایی سخت افزاری", "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", + "transcoding_hevc_codec": "کدک HEVC", + "transcoding_max_b_frames": "بیشترین B-frames", + "transcoding_max_b_frames_description": "مقادیر بالاتر کارایی فشرده سازی را بهبود می‌بخشند، اما کدگذاری را کند می‌کنند. ممکن است با شتاب دهی سخت‌افزاری در دستگاه‌های قدیمی سازگار نباشد. مقدار( 0 ) B-frames را غیرفعال می‌کند، در حالی که مقدار ( 1 ) این مقدار را به صورت خودکار تنظیم می‌کند.", + "transcoding_max_bitrate": "بیشترین بیت ریت", + "transcoding_max_bitrate_description": "تنظیم حداکثر بیت‌ریت می‌تواند اندازه فایل‌ها را در حدی قابل پیش‌بینی‌تر کند، هرچند که هزینه کمی برای کیفیت دارد. در وضوح 720p، مقادیر معمول 2600k برای VP9 یا HEVC و 4500k برای H.264 است. اگر به 0 تنظیم شود، غیرفعال می‌شود.", + "transcoding_max_keyframe_interval": "حداکثر فاصله کلید فریم", + "transcoding_max_keyframe_interval_description": "حداکثر فاصله فریم بین کلیدفریم‌ها را تنظیم می‌کند. مقادیر پایین‌تر کارایی فشرده‌سازی را کاهش می‌دهند، اما زمان جستجو را بهبود می‌بخشند و ممکن است کیفیت را در صحنه‌های با حرکت سریع بهبود دهند. مقدار 0 این مقدار را به‌طور خودکار تنظیم می‌کند.", + "transcoding_optimal_description": "ویدیوهایی که از رزولوشن هدف بالاتر هستند یا در قالب پذیرفته شده نیستند", + "transcoding_preferred_hardware_device": "دستگاه سخت‌افزاری ترجیحی", + "transcoding_preferred_hardware_device_description": "این گزینه فقط به VAAPI و QSV اعمال می‌شود. DRI node مورد استفاده برای تبدیل فرمت سخت‌افزاری را تنظیم می‌کند.", + "transcoding_preset_preset": "پیش‌تنظیم (preset-)", + "transcoding_preset_preset_description": "سرعت فشرده‌سازی. پیش‌تنظیم‌های کندتر فایل‌های کوچک‌تری تولید می‌کنند و کیفیت را هنگام هدف‌گذاری بر روی یک بیت‌ریت خاص افزایش می‌دهند. VP9 سرعت‌های بالاتر از 'faster' را نادیده می‌گیرد.", + "transcoding_reference_frames": "فریم‌های مرجع", + "transcoding_reference_frames_description": "تعداد فریم‌هایی که هنگام فشرده‌سازی یک فریم مشخص به آن‌ها ارجاع داده می‌شود. مقادیر بالاتر کارایی فشرده‌سازی را بهبود می‌بخشند، اما کدگذاری را کندتر می‌کنند. مقدار 0 این مقدار را به‌طور خودکار تنظیم می‌کند.", + "transcoding_required_description": "فقط ویدیوهایی که در فرمت پذیرفته‌شده نیستند", + "transcoding_settings": "تنظیمات تبدیل ویدیو", + "transcoding_settings_description": "مدیریت وضوح و اطلاعات کدگذاری فایل‌های ویدئویی", + "transcoding_target_resolution": "وضوح هدف", + "transcoding_target_resolution_description": "وضوح‌های بالاتر می‌توانند جزئیات بیشتری را حفظ کنند، اما زمان بیشتری برای کدگذاری نیاز دارند، اندازه فایل‌های بزرگ‌تری دارند و ممکن است باعث کاهش پاسخگویی برنامه شوند.", + "transcoding_temporal_aq": "AQ موقتی", + "transcoding_temporal_aq_description": "این مورد فقط برای NVENC اعمال می شود. افزایش کیفیت در صحنه های با جزئیات بالا و حرکت کم. ممکن است با دستگاه های قدیمی تر سازگار نباشد.", + "transcoding_threads": "رشته ها ( موضوعات )", + "transcoding_threads_description": "مقادیر بالاتر منجر به رمزگذاری سریع تر می شود، اما فضای کمتری برای پردازش سایر وظایف سرور در حین فعالیت باقی می گذارد. این مقدار نباید بیشتر از تعداد هسته های CPU باشد. اگر روی 0 تنظیم شود، بیشترین استفاده را خواهد داشت.", "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", + "transcoding_tone_mapping_description": "تلاش برای حفظ ظاهر ویدیوهای HDR هنگام تبدیل به SDR. هر الگوریتم تعادل های متفاوتی را برای رنگ، جزئیات و روشنایی ایجاد می کند. Hable جزئیات را حفظ می کند، Mobius رنگ را حفظ می کند و Reinhard روشنایی را حفظ می کند.", "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_transcode_policy_description": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", - "untracked_files": "", - "untracked_files_description": "", - "user_delete_delay": "", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", - "user_delete_immediately": "", - "user_management": "", - "user_password_has_been_reset": "", - "user_password_reset_description": "", - "user_restore_description": "", - "user_settings": "", - "user_settings_description": "", - "user_successfully_removed": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job": "", - "video_conversion_job_description": "" + "transcoding_tone_mapping_npl_description": "رنگ ها برای ظاهر طبیعی در یک نمایشگر با این روشنایی تنظیم خواهند شد. برخلاف انتظار، مقادیر پایین تر باعث افزایش روشنایی ویدیو و برعکس می شوند، زیرا آن را برای روشنایی نمایشگر جبران می کند. مقدار 0 این مقدار را به طور خودکار تنظیم می کند.", + "transcoding_transcode_policy": "سیاست رمزگذاری", + "transcoding_transcode_policy_description": "سیاست برای زمانی که ویدیویی باید مجددا تبدیل (رمزگذاری) شود. ویدیوهای HDR همیشه تبدیل (رمزگذاری) مجدد خواهند شد (مگر رمزگذاری مجدد غیرفعال باشد).", + "transcoding_two_pass_encoding": "تبدیل (رمزگذاری) دو مرحله ای", + "transcoding_two_pass_encoding_setting_description": "تبدیل (رمزگذاری) ویدیو در دو مرحله برای تولید ویدیوهای رمزگذاری شده بهتر. وقتی حداکثر نرخ بیت فعال باشد (برای کار با H.264 و HEVC لازم است)، این حالت از یک محدوده نرخ بیت بر اساس حداکثر نرخ بیت استفاده می کند و CRF را نادیده می گیرد. برای VP9، اگر حداکثر نرخ بیت غیرفعال باشد، می توان از CRF استفاده کرد.", + "transcoding_video_codec": "کدک ویدیویی", + "transcoding_video_codec_description": "VP9 کارایی بالا و سازگاری وب را دارد، اما تبدیل (رمزگذاری) مجدد آن زمان بیشتری می گیرد. HEVC عملکرد مشابهی دارد، اما سازگاری وب کمتری دارد. H.264 سازگاری گسترده و رمزگذاری سریع دارد، اما فایل های بزرگتری تولید می کند. AV1 کدک کارآمدترین است، اما از پشتیبانی در دستگاه های قدیمی تر برخوردار نیست.", + "trash_enabled_description": "فعال سازی ویژگی های سطل بازیافت (سطل زباله)", + "trash_number_of_days": "تعداد روزها", + "trash_number_of_days_description": "تعداد روزهایی که دارایی ها(عکسها و فیملها) در زباله دان(سطل بازیافت) قبل از حذف دائمی نگهداری میشوند", + "trash_settings": "تنظیمات سطل بازیافت (سطل زباله)", + "trash_settings_description": "مدیریت تنظیمات سطل بازیافت (سطل زباله)", + "untracked_files": "فایل های ردیابی نشده", + "untracked_files_description": "این فایل ها توسط برنامه ردیابی نمی شوند. می توانند نتیجه انتقال ناموفق، بارگذاری متوقف شده یا به دلیل یک باگ باقی مانده باشند", + "user_delete_delay": "{user}'s حساب کاربری و دارایی ها(عکس و فیلم) برای حذف دائمی در {delay, plural, one {# روز} other {# روز}} برنامه ریزی خواهند شد.", + "user_delete_delay_settings": "تأخیر در حذف", + "user_delete_delay_settings_description": "تعداد روزهایی که پس از حذف، حساب کاربری و دارایی های(عکس و فیلم) کاربر به طور دائمی حذف می شوند. کار حذف کاربر در نیمه شب اجرا می شود تا کاربرانی که آماده حذف هستند را بررسی کند. تغییرات در این تنظیم در اجرای بعدی ارزیابی خواهند شد.", + "user_delete_immediately": "{user}'s حساب کاربری و دارایی ها (عکس و فیلم) فوراً برای حذف دائمی در صف قرار خواهند گرفت.", + "user_delete_immediately_checkbox": "کاربر و دارایی ها (عکس و فیلم) را برای حذف فوری در صف قرار بده", + "user_management": "مدیریت کاربر", + "user_password_has_been_reset": "رمز عبور کاربر بازنشانی شد:", + "user_password_reset_description": "لطفاً رمز عبور موقت را به کاربر ارائه دهید و به او اطلاع دهید که باید در ورود بعدی رمز عبور خود را تغییر دهد.", + "user_restore_description": "{user}'s حساب کاربری بازیابی خواهد شد.", + "user_restore_scheduled_removal": "بازیابی کاربر - حذف برنامه ریزی شده در {date, date, long}", + "user_settings": "تنظیمات کاربر", + "user_settings_description": "مدیریت تنظیمات کاربر", + "user_successfully_removed": "کاربر {email} با موفقیت حذف شد.", + "version_check_enabled_description": "فعال‌سازی بررسی نسخه", + "version_check_implications": "ویژگی بررسی نسخه به ارتباط دوره ای با github.com متکی است", + "version_check_settings": "بررسی نسخه", + "version_check_settings_description": "فعال یا غیرفعال کردن اعلان نسخه جدید", + "video_conversion_job": "تبدیل (رمزگذاری) ویدیوها", + "video_conversion_job_description": "تبدیل (رمزگذاری)ویدیوها برای سازگاری بیشتر با مرورگرها و دستگاه‌ها" }, - "admin_email": "", - "admin_password": "", - "administration": "", - "advanced": "", + "admin_email": "ایمیل مدیر", + "admin_password": "رمز عبور مدیر", + "administration": "مدیریت", + "advanced": "پیشرفته", "album_added": "", "album_added_notification_setting_description": "", "album_cover_updated": "", diff --git a/web/src/lib/i18n/fi.json b/web/src/lib/i18n/fi.json index f87e2eed4ee65..da9a71379cfe5 100644 --- a/web/src/lib/i18n/fi.json +++ b/web/src/lib/i18n/fi.json @@ -129,6 +129,7 @@ "map_enable_description": "Ota käyttöön karttatoiminnot", "map_gps_settings": "Kartta & GPS- asetukset", "map_gps_settings_description": "Hallitse Kartan & GPS (Käänteinen Geokoodaus) Asetuksia", + "map_implications": "Kartta -ominaisuus käyttää ulkoista karttapalvelua", "map_light_style": "Vaalea teema", "map_manage_reverse_geocoding_settings": "Hallitse käänteisen geokoodauksen asetuksia", "map_reverse_geocoding": "Käänteinen Geokoodaus", @@ -333,8 +334,11 @@ "album_cover_updated": "Albumin kansikuva päivitetty", "album_delete_confirmation": "Haluatko varmasti poistaa albumin {album}?\nJos albumi on jaettu, muut eivät pääse siihen enää.", "album_info_updated": "Albumin tiedot päivitetty", + "album_leave": "Poistu albumista?", "album_name": "Albumin nimi", "album_options": "Albumin asetukset", + "album_remove_user": "Poista käyttäjä?", + "album_remove_user_confirmation": "Oletko varma että haluat poistaa {user}?", "album_share_no_users": "Näyttää että olet jakanut tämän albumin kaikkien kanssa, tai sinulla ei ole käyttäjiä joille jakaa.", "album_updated": "Albumi päivitetty", "album_updated_setting_description": "Saa sähköpostia kun jaetussa albumissa on uutta sisältöä", diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index 0aaa160729184..925ac23133b99 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -129,12 +129,13 @@ "map_enable_description": "Activer la carte", "map_gps_settings": "Paramètres de la carte et GPS", "map_gps_settings_description": "Gérer les paramètres de la Carte & GPS", + "map_implications": "La carte repose sur un service de tuiles externe (tiles.immich.cloud)", "map_light_style": "Thème clair", "map_manage_reverse_geocoding_settings": "Gérer les paramètres de géocodage inversé", "map_reverse_geocoding": "Géocodage inversé", "map_reverse_geocoding_enable_description": "Activer le géocodage inversé", "map_reverse_geocoding_settings": "Paramètres de géocodage inversé", - "map_settings": "Paramètres de la carte", + "map_settings": "Carte", "map_settings_description": "Gérer les paramètres de la carte", "map_style_description": "URL vers un thème de carte au format style.json", "metadata_extraction_job": "Extraction des métadonnées", @@ -173,7 +174,7 @@ "oauth_issuer_url": "URL de l'émetteur", "oauth_mobile_redirect_uri": "URI de redirection mobile", "oauth_mobile_redirect_uri_override": "Remplacer l'URI de redirection mobile", - "oauth_mobile_redirect_uri_override_description": "Activer lorsque 'app.immich:/' est une URI de redirection non valide.", + "oauth_mobile_redirect_uri_override_description": "Activer quand le fournisseur d'OAuth ne permet pas un URI mobile, comme '{callback} '", "oauth_profile_signing_algorithm": "Algorithme de signature de profil", "oauth_profile_signing_algorithm_description": "Algorithme utilisé pour signer le profil utilisateur.", "oauth_scope": "Portée", @@ -320,7 +321,8 @@ "user_settings": "Paramètres utilisateur", "user_settings_description": "Gérer les paramètres utilisateur", "user_successfully_removed": "L'utilisateur {email} a bien été supprimé.", - "version_check_enabled_description": "Activer la vérification périodique de nouvelle version sur GitHub", + "version_check_enabled_description": "Activer la vérification périodique de nouvelle version", + "version_check_implications": "Le contrôle de version repose sur une communication périodique avec github.com", "version_check_settings": "Vérification de la version", "version_check_settings_description": "Gérer la vérification de nouvelle version d'Immich", "video_conversion_job": "Transcodage des vidéos", @@ -336,7 +338,8 @@ "album_added": "Album ajouté", "album_added_notification_setting_description": "Recevoir une notification par courriel lorsque vous êtes ajouté(e) à un album partagé", "album_cover_updated": "Couverture de l'album mise à jour", - "album_delete_confirmation": "Êtes-vous sûr de vouloir supprimer l'album {album} ?\nSi cet album est partagé, les autres utilisateurs ne pourront plus y accéder.", + "album_delete_confirmation": "Êtes-vous sûr de vouloir supprimer l'album {album} ?", + "album_delete_confirmation_description": "Si cet album est partagé, d'autres utilisateurs ne pourront plus y accéder.", "album_info_updated": "Détails de l'album mis à jour", "album_leave": "Quitter l'album ?", "album_leave_confirmation": "Êtes-vous sûr de vouloir quitter l'album {album} ?", @@ -360,6 +363,7 @@ "allow_edits": "Autoriser les modifications", "allow_public_user_to_download": "Permettre aux utilisateurs non connectés de télécharger", "allow_public_user_to_upload": "Permettre aux utilisateurs non connectés de téléverser", + "anti_clockwise": "Sens anti-horaire", "api_key": "Clé API", "api_key_description": "Cette valeur ne sera affichée qu'une seule fois. Assurez-vous de la copier avant de fermer la fenêtre.", "api_key_empty": "Le nom de votre clé API ne doit pas être vide", @@ -441,13 +445,15 @@ "clear_all_recent_searches": "Supprimer les recherches récentes", "clear_message": "Effacer le message", "clear_value": "Effacer la valeur", + "clockwise": "Sens horaire", "close": "Fermer", "collapse": "Réduire", "collapse_all": "Tout réduire", - "color_theme": "Thème coloré", + "color": "Couleur", + "color_theme": "Thème de couleur", "comment_deleted": "Commentaire supprimé", "comment_options": "Options des commentaires", - "comments_and_likes": "Commentaires et réactions « j'aime »", + "comments_and_likes": "Commentaires et \"j'aime\"", "comments_are_disabled": "Les commentaires sont désactivés", "confirm": "Confirmer", "confirm_admin_password": "Confirmer le mot de passe Admin", @@ -477,6 +483,8 @@ "create_new_person": "Créer une nouvelle personne", "create_new_person_hint": "Attribuer les médias sélectionnés à une nouvelle personne", "create_new_user": "Créer un nouvel utilisateur", + "create_tag": "Créer un tag", + "create_tag_description": "Créer un nouveau tag. Pour les tags imbriqués, veuillez entrer le chemin complet du tag, y compris les \"/\" avant.", "create_user": "Créer un utilisateur", "created": "Créé", "current_device": "Appareil actuel", @@ -500,6 +508,8 @@ "delete_library": "Supprimer la bibliothèque", "delete_link": "Supprimer le lien", "delete_shared_link": "Supprimer le lien partagé", + "delete_tag": "Supprimer le tag", + "delete_tag_confirmation_prompt": "Êtes-vous sûr de vouloir supprimer le tag {tagName} ?", "delete_user": "Supprimer l'utilisateur", "deleted_shared_link": "Lien partagé supprimé", "description": "Description", @@ -517,6 +527,8 @@ "do_not_show_again": "Ne plus afficher ce message", "done": "Terminé", "download": "Télécharger", + "download_include_embedded_motion_videos": "Vidéos embarquées", + "download_include_embedded_motion_videos_description": "Inclure des vidéos intégrées dans les photos de mouvement comme un fichier séparé", "download_settings": "Télécharger", "download_settings_description": "Gérer les paramètres de téléchargement des médias", "downloading": "Téléchargement", @@ -546,10 +558,15 @@ "edit_location": "Modifier la localisation", "edit_name": "Modifier le nom", "edit_people": "Modifier les personnes", + "edit_tag": "Modifier le tag", "edit_title": "Modifier le title", "edit_user": "Modifier l'utilisateur", "edited": "Modifié", "editor": "Editeur", + "editor_close_without_save_prompt": "Les changements ne seront pas enregistrés", + "editor_close_without_save_title": "Fermer l'éditeur ?", + "editor_crop_tool_h2_aspect_ratios": "Rapports hauteur/largeur", + "editor_crop_tool_h2_rotation": "Rotation", "email": "Courriel", "empty": "", "empty_album": "Album vide", @@ -699,6 +716,7 @@ "expired": "Expiré", "expires_date": "Expire le {date}", "explore": "Explorer", + "explorer": "Explorateur", "export": "Exporter", "export_as_json": "Exporter en JSON", "extension": "Extension", @@ -712,6 +730,8 @@ "feature": "", "feature_photo_updated": "Photo de la personne mise à jour", "featurecollection": "", + "features": "Fonctionnalités", + "features_setting_description": "Gérer les fonctionnalités de l'application", "file_name": "Nom du fichier", "file_name_or_extension": "Nom du fichier ou extension", "filename": "Nom du fichier", @@ -720,6 +740,8 @@ "filter_people": "Filtrer les personnes", "find_them_fast": "Pour les retrouver rapidement par leur nom", "fix_incorrect_match": "Corriger une association incorrecte", + "folders": "Dossiers", + "folders_feature_description": "Parcourir l'affichage par dossiers pour les photos et les vidéos sur le système de fichiers", "force_re-scan_library_files": "Forcer la réactualisation de tous les fichiers de la bibliothèque", "forward": "Avant", "general": "Général", @@ -912,6 +934,7 @@ "ok": "Ok", "oldest_first": "Anciens en premier", "onboarding": "Accueil", + "onboarding_privacy_description": "Les fonctions suivantes (optionnelles) dépendent de services externes et peuvent être désactivées à tout moment dans les paramètres d'administration.", "onboarding_theme_description": "Choisissez un thème de couleur pour votre instance. Vous pouvez le changer plus tard dans vos paramètres.", "onboarding_welcome_description": "Mettons votre instance en place avec quelques paramètres communs.", "onboarding_welcome_user": "Bienvenue {user}", @@ -953,6 +976,7 @@ "pending": "En attente", "people": "Personnes", "people_edits_count": "{count, plural, one {# personne éditée} other {# personnes éditées}}", + "people_feature_description": "Parcourir les photos et vidéos groupées par personnes", "people_sidebar_description": "Afficher le menu Personnes dans la barre latérale", "perform_library_tasks": "", "permanent_deletion_warning": "Avertissement avant suppression définitive", @@ -985,6 +1009,7 @@ "previous_memory": "Souvenir précédent", "previous_or_next_photo": "Photo précédente ou suivante", "primary": "Primaire", + "privacy": "Vie privée", "profile_image_of_user": "Image de profil de {user}", "profile_picture_set": "Photo de profil définie.", "public_album": "Album public", @@ -1023,7 +1048,9 @@ "purchase_settings_server_activated": "La clé du produit pour le Serveur est gérée par l'administrateur", "range": "", "rating": "Étoile d'évaluation", - "rating_description": "Afficher l'évaluation d'exif dans le panneau d'information", + "rating_clear": "Effacer l'évaluation", + "rating_count": "{count, plural, one {# étoile} other {# étoiles}}", + "rating_description": "Afficher l'évaluation EXIF dans le panneau d'information", "raw": "", "reaction_options": "Options de réaction", "read_changelog": "Lire les changements", @@ -1056,6 +1083,7 @@ "removed_from_archive": "Supprimé de l'archive", "removed_from_favorites": "Supprimé des favoris", "removed_from_favorites_count": "{count, plural, one {# supprimé} other {# supprimés}} des favoris", + "removed_tagged_assets": "Tag supprimé de {count, plural, one {# média} other {# médias}}", "rename": "Renommer", "repair": "Réparer", "repair_no_results_message": "Les fichiers non importés ou absents s'afficheront ici", @@ -1105,6 +1133,7 @@ "search_people": "Rechercher une personne", "search_places": "Rechercher un lieu", "search_state": "Rechercher par état/région...", + "search_tags": "Recherche de tags...", "search_timezone": "Rechercher par fuseau horaire...", "search_type": "Rechercher par type", "search_your_photos": "Rechercher vos photos", @@ -1146,6 +1175,7 @@ "shared_by_user": "Partagé par {user}", "shared_by_you": "Partagé par vous", "shared_from_partner": "Photos de {partner}", + "shared_link_options": "Options de lien partagé", "shared_links": "Liens partagés", "shared_photos_and_videos_count": "{assetCount, plural, other {# photos et vidéos partagées.}}", "shared_with_partner": "Partagé avec {partner}", @@ -1172,6 +1202,8 @@ "show_supporter_badge": "Badge de contributeur", "show_supporter_badge_description": "Afficher le badge de contributeur", "shuffle": "Mélanger", + "sidebar": "Bar latérale", + "sidebar_display_description": "Afficher un lien vers la vue dans la barre latérale", "sign_out": "Déconnexion", "sign_up": "S'enregistrer", "size": "Taille", @@ -1208,6 +1240,13 @@ "sunrise_on_the_beach": "Aurore sur la plage", "swap_merge_direction": "Changer la direction de fusion", "sync": "Synchroniser", + "tag": "Tag", + "tag_assets": "Taguer les médias", + "tag_created": "Tag créé : {tag}", + "tag_feature_description": "Parcourir les photos et vidéos groupées par thèmes logiques", + "tag_updated": "Tag mis à jour : {tag}", + "tagged_assets": "Tag ajouté à {count, plural, one {# média} other {# médias}}", + "tags": "Tags", "template": "Modèle", "theme": "Thème", "theme_selection": "Sélection du thème", @@ -1219,9 +1258,10 @@ "to_change_password": "Modifier le mot de passe", "to_favorite": "Ajouter aux favoris", "to_login": "Se connecter", + "to_root": "Vers la racine", "to_trash": "Corbeille", "toggle_settings": "Inverser les paramètres", - "toggle_theme": "Changer le thème", + "toggle_theme": "Inverser le thème sombre", "toggle_visibility": "Modifier la visibilité", "total_usage": "Utilisation globale", "trash": "Corbeille", @@ -1243,6 +1283,7 @@ "unlink_oauth": "Déconnecter OAuth", "unlinked_oauth_account": "Compte OAuth non connecté", "unnamed_album": "Album sans nom", + "unnamed_album_delete_confirmation": "Êtes-vous sûr de vouloir supprimer cet album ?", "unnamed_share": "Partage sans nom", "unsaved_change": "Modification non enregistrée", "unselect_all": "Annuler la sélection", @@ -1291,6 +1332,7 @@ "view_album": "Afficher l'album", "view_all": "Voir tout", "view_all_users": "Voir tous les utilisateurs", + "view_in_timeline": "Voir dans la timeline", "view_links": "Voir les liens", "view_next_asset": "Voir le média suivant", "view_previous_asset": "Voir le média précédent", diff --git a/web/src/lib/i18n/he.json b/web/src/lib/i18n/he.json index d73b06ad7004b..1679ecbc4d866 100644 --- a/web/src/lib/i18n/he.json +++ b/web/src/lib/i18n/he.json @@ -129,12 +129,13 @@ "map_enable_description": "אפשר תכונות מפה", "map_gps_settings": "הגדרות מפה & GPS", "map_gps_settings_description": "נהל הגדרות מפה & GPS (קידוד גאוגרפי הפוך)", + "map_implications": "תכונת המפה מסתמכת על שירות אריח חיצוני (tiles.immich.cloud)", "map_light_style": "עיצוב בהיר", "map_manage_reverse_geocoding_settings": "נהל הגדרות קידוד גאוגרפי הפוך", "map_reverse_geocoding": "קידוד גיאוגרפי הפוך", "map_reverse_geocoding_enable_description": "אפשר קידוד גיאוגרפי הפוך", "map_reverse_geocoding_settings": "הגדרות קידוד גיאוגרפי הפוך", - "map_settings": "הגדרות מפה", + "map_settings": "מפה", "map_settings_description": "נהל הגדרות מפה", "map_style_description": "כתובת אתר לערכת נושא של מפה style.json", "metadata_extraction_job": "חלץ מטא-נתונים", @@ -320,7 +321,8 @@ "user_settings": "הגדרות משתמש", "user_settings_description": "נהל הגדרות משתמש", "user_successfully_removed": "המשתמש {email} הוסר בהצלחה.", - "version_check_enabled_description": "אפשר בקשות רשת תקופתיות ל-GitHub כדי לבדוק אם יש מהדורות חדשות", + "version_check_enabled_description": "אפשר בדיקת גרסה", + "version_check_implications": "תכונת בדיקת הגרסה מסתמכת על תקשורת תקופתית עם github.com", "version_check_settings": "בדיקת גרסה", "version_check_settings_description": "הפעל/השבת את ההתראה על גרסה חדשה", "video_conversion_job": "המרת קידוד סרטונים", @@ -360,6 +362,7 @@ "allow_edits": "אפשר עריכות", "allow_public_user_to_download": "אפשר למשתמש ציבורי להוריד", "allow_public_user_to_upload": "אפשר למשתמש ציבורי להעלות", + "anti_clockwise": "נגד כיוון השעון", "api_key": "מפתח API", "api_key_description": "הערך הזה יוצג רק פעם אחת. נא לוודא שהעתקת אותו לפני סגירת החלון.", "api_key_empty": "מפתח ה-API שלך לא אמור להיות ריק", @@ -441,6 +444,7 @@ "clear_all_recent_searches": "נקה את כל החיפושים האחרונים", "clear_message": "נקה הודעה", "clear_value": "נקה ערך", + "clockwise": "עם כיוון השעון", "close": "סגור", "collapse": "כווץ", "collapse_all": "כווץ הכל", @@ -517,6 +521,8 @@ "do_not_show_again": "אל תציג את ההודעה הזאת שוב", "done": "סיום", "download": "הורדה", + "download_include_embedded_motion_videos": "סרטונים מוטמעים", + "download_include_embedded_motion_videos_description": "כלול סרטונים מוטעמים בתמונות עם תנועה כקובץ נפרד", "download_settings": "הורדה", "download_settings_description": "נהל הגדרות הקשורות להורדת נכסים", "downloading": "מוריד", @@ -550,6 +556,10 @@ "edit_user": "ערוך משתמש", "edited": "נערך", "editor": "עורך", + "editor_close_without_save_prompt": "השינויים לא יישמרו", + "editor_close_without_save_title": "לסגור את העורך?", + "editor_crop_tool_h2_aspect_ratios": "יחסי רוחב גובה", + "editor_crop_tool_h2_rotation": "סיבוב", "email": "דוא\"ל", "empty": "", "empty_album": "אלבום ריק", @@ -912,6 +922,7 @@ "ok": "בסדר", "oldest_first": "הישן ביותר ראשון", "onboarding": "היכרות", + "onboarding_privacy_description": "התכונות (האופציונליות) הבאות מסתמכות על שירותים חיצוניים, וניתנות לביטול בכל עת בהגדרות הניהול.", "onboarding_theme_description": "בחר/י את צבע ערכת הנושא עבור ההתקנה שלך. את/ה יכול/ה לשנות את זה מאוחר יותר בהגדרות שלך.", "onboarding_welcome_description": "בואו נכין את ההתקנה שלכם עם כמה הגדרות נפוצות.", "onboarding_welcome_user": "ברוכ/ה הבא/ה, {user}", @@ -985,6 +996,7 @@ "previous_memory": "זיכרון קודם", "previous_or_next_photo": "התמונה הקודמת או הבאה", "primary": "ראשי", + "privacy": "פרטיות", "profile_image_of_user": "תמונת פרופיל של {user}", "profile_picture_set": "תמונת פרופיל נבחרה.", "public_album": "אלבום ציבורי", @@ -995,7 +1007,7 @@ "purchase_activated_title": "המפתח שלך הופעל בהצלחה", "purchase_button_activate": "הפעל", "purchase_button_buy": "קנה", - "purchase_button_buy_immich": "קנה Immich", + "purchase_button_buy_immich": "קנה את Immich", "purchase_button_never_show_again": "לעולם אל תראה שוב", "purchase_button_reminder": "הזכר לי בעוד 30 יום", "purchase_button_remove_key": "הסר מפתח", @@ -1146,6 +1158,7 @@ "shared_by_user": "משותף על ידי {user}", "shared_by_you": "משותף על ידך", "shared_from_partner": "תמונות מאת {partner}", + "shared_link_options": "אפשרויות קישור משותף", "shared_links": "קישורים משותפים", "shared_photos_and_videos_count": "{assetCount, plural, other {# תמונות וסרטונים משותפים.}}", "shared_with_partner": "משותף עם {partner}", diff --git a/web/src/lib/i18n/hr.json b/web/src/lib/i18n/hr.json index 6b07d5af6d9e0..8882d80e252c4 100644 --- a/web/src/lib/i18n/hr.json +++ b/web/src/lib/i18n/hr.json @@ -1,5 +1,5 @@ { - "about": "Oko", + "about": "O", "account": "Račun", "account_settings": "Postavke računa", "acknowledge": "Potvrdi", @@ -25,7 +25,7 @@ "add_to_shared_album": "Dodaj u dijeljeni album", "added_to_archive": "Dodano u arhivu", "added_to_favorites": "Dodano u omiljeno", - "added_to_favorites_count": "Dodano {count} u omiljeno", + "added_to_favorites_count": "Dodano {count, number} u omiljeno", "admin": { "add_exclusion_pattern_description": "", "authentication_settings": "Postavke autentikacije", @@ -33,6 +33,7 @@ "authentication_settings_disable_all": "Jeste li sigurni da želite onemogućenit sve načine prijave? Prijava će biti potpuno onemogućena.", "background_task_job": "Pozadinski zadaci", "check_all": "Provjeri sve", + "cleared_jobs": "Izbrisani poslovi za: {job}", "config_set_by_file": "Konfiguracija je trenutno postavljena konfiguracijskom datotekom", "confirm_delete_library": "Jeste li sigurni da želite izbrisati biblioteku {library}?", "confirm_delete_library_assets": "Jeste li sigurni da želite izbrisati ovu biblioteku? Time će se izbrisati sva {count} sadržana sredstva iz Immicha i ne može se poništiti. Datoteke će ostati na disku.", @@ -48,6 +49,7 @@ "face_detection": "Detekcija lica", "face_detection_description": "Prepoznajte lica u sredstvima pomoću strojnog učenja. Za videozapise u obzir se uzima samo minijaturni prikaz. \"Sve\" (ponovno) obrađuje svu imovinu. \"Nedostaje\" stavlja u red čekanja sredstva koja još nisu obrađena. Otkrivena lica bit će stavljena u red čekanja za prepoznavanje lica nakon dovršetka prepoznavanja lica, grupirajući ih u postojeće ili nove osobe.", "facial_recognition_job_description": "Grupirajte otkrivena lica u osobe. Ovaj se korak pokreće nakon dovršetka prepoznavanja lica. \"Sve\" (ponovno) grupira sva lica. \"Nedostajuća\" lica u redovima kojima nije dodijeljena osoba.", + "failed_job_command": "Naredba {command} nije uspjela za posao: {job}", "force_delete_user_warning": "UPOZORENJE: Ovo će odmah ukloniti korisnika i sve pripadajuće podatke. Ovo se ne može poništiti i datoteke se ne mogu vratiti.", "forcing_refresh_library_files": "Prisilno osvježavanje svih datoteka knjižnice", "image_format_description": "WebP proizvodi manje datoteke od JPEG-a, ali se sporije kodira.", @@ -67,30 +69,33 @@ "image_thumbnail_resolution_description": "Koristi se prilikom pregledavanja grupa fotografija (glavna vremenska traka, prikaz albuma itd.). Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odaziv aplikacije.", "job_concurrency": "{job} istovremenost", "job_not_concurrency_safe": "Ovaj posao nije siguran za istovremenost.", - "job_settings": "", - "job_settings_description": "", - "job_status": "", + "job_settings": "Postavke posla", + "job_settings_description": "Upravljajte istovremenošću poslova", + "job_status": "Status posla", "jobs_delayed": "", "jobs_failed": "", - "library_created": "", - "library_cron_expression": "", - "library_cron_expression_presets": "", - "library_deleted": "", - "library_import_path_description": "", - "library_scanning": "", - "library_scanning_description": "", - "library_scanning_enable_description": "", - "library_settings": "", - "library_settings_description": "", - "library_tasks_description": "", - "library_watching_enable_description": "", - "library_watching_settings": "", - "library_watching_settings_description": "", - "logging_enable_description": "", - "logging_level_description": "", - "logging_settings": "", - "machine_learning_clip_model": "", - "machine_learning_duplicate_detection": "", + "library_created": "Stvorena biblioteka: {library}", + "library_cron_expression": "Cron izraz", + "library_cron_expression_description": "Postavite interval skeniranja koristeći cron format. Za više informacija pogledajte npr. Crontab Guru", + "library_cron_expression_presets": "Unaprijed postavljene cron izraze", + "library_deleted": "Biblioteka izbrisana", + "library_import_path_description": "Navedite mapu za uvoz. Ova će se mapa, uključujući podmape, skenirati u potrazi za slikama i videozapisima.", + "library_scanning": "Periodično Skeniranje", + "library_scanning_description": "Konfigurirajte periodično skeniranje biblioteke", + "library_scanning_enable_description": "Omogući periodično skeniranje biblioteke", + "library_settings": "Externa biblioteka", + "library_settings_description": "Upravljajte postavkama vanjske biblioteke", + "library_tasks_description": "Obavljati bibliotekne zadatke", + "library_watching_enable_description": "Pratite vanjske biblioteke za promjena datoteke", + "library_watching_settings": "Gledanje biblioteke (EKSPERIMENTALNO)", + "library_watching_settings_description": "Automatsko praćenje promijenjenih datoteke", + "logging_enable_description": "Omogući zapisivanje", + "logging_level_description": "Kada je omogućeno, koju razinu zapisavanje koristiti.", + "logging_settings": "Zapisavanje", + "machine_learning_clip_model": "CLIP model", + "machine_learning_clip_model_description": "Naziv CLIP modela navedenog ovdje. Imajte na umu da morate ponovno pokrenuti posao 'Pametno Pretraživanje' za sve slike nakon promjene modela.", + "machine_learning_duplicate_detection": "Detekcija Duplikata", + "machine_learning_duplicate_detection_enabled": "Omogući detekciju duplikata", "machine_learning_duplicate_detection_enabled_description": "", "machine_learning_duplicate_detection_setting_description": "", "machine_learning_enabled_description": "", @@ -111,53 +116,59 @@ "machine_learning_settings_description": "", "machine_learning_smart_search": "", "machine_learning_smart_search_description": "", - "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", - "manage_concurrency": "", - "manage_log_settings": "", - "map_dark_style": "", - "map_enable_description": "", - "map_light_style": "", - "map_reverse_geocoding": "", - "map_reverse_geocoding_enable_description": "", - "map_reverse_geocoding_settings": "", - "map_settings": "", - "map_settings_description": "", - "map_style_description": "", - "metadata_extraction_job": "", - "metadata_extraction_job_description": "", - "migration_job": "", - "migration_job_description": "", + "machine_learning_smart_search_enabled": "Omogući pametno pretraživanje", + "machine_learning_smart_search_enabled_description": "Ako je onemogućeno, slike neće biti kodirane za pametno pretraživanje.", + "machine_learning_url_description": "URL poslužitelja strojnog učenja", + "manage_concurrency": "Upravljanje Istovremenošću", + "manage_log_settings": "Upravljanje postavkama zapisivanje", + "map_dark_style": "Tamni stil", + "map_enable_description": "Omogući značajke karte", + "map_gps_settings": "Postavke Karte i GPS-a", + "map_gps_settings_description": "Upravljajte Postavkama Karte i GPS-a (Obrnuto Geokodiranje)", + "map_implications": "Značajka karte se oslanja na vanjsku uslugu pločica (tiles.immich.cloud)", + "map_light_style": "Svijetli stil", + "map_manage_reverse_geocoding_settings": "Upravljajte postavkama Obrnutog Geokodiranja", + "map_reverse_geocoding": "Obrnuto Geokodiranje", + "map_reverse_geocoding_enable_description": "Omogući obrnuto geokodiranje", + "map_reverse_geocoding_settings": "Postavke Obrnuto Geokodiranje", + "map_settings": "Karta", + "map_settings_description": "Upravljanje postavkama karte", + "map_style_description": "URL na style.json temu karte", + "metadata_extraction_job": "Izdvoj metapodatke", + "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS i rezolucija", + "migration_job": "Migracija", + "migration_job_description": "Premjestite minijature za sredstva i lica u najnoviju strukturu mapa", "no_paths_added": "", - "no_pattern_added": "", - "note_apply_storage_label_previous_assets": "", - "note_cannot_be_changed_later": "", - "note_unlimited_quota": "", - "notification_email_from_address": "", - "notification_email_from_address_description": "", - "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", - "notification_email_password_description": "", - "notification_email_port_description": "", - "notification_email_sent_test_email_button": "", - "notification_email_setting_description": "", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", - "notification_email_username_description": "", - "notification_enable_email_notifications": "", - "notification_settings": "", - "notification_settings_description": "", - "oauth_auto_launch": "", - "oauth_auto_launch_description": "", - "oauth_auto_register": "", - "oauth_auto_register_description": "", - "oauth_button_text": "", - "oauth_client_id": "", - "oauth_client_secret": "", - "oauth_enable_description": "", - "oauth_issuer_url": "", - "oauth_mobile_redirect_uri": "", + "no_pattern_added": "Nije dodan uzorak", + "note_apply_storage_label_previous_assets": "Napomena: da biste primijenili Oznaku Pohrane na prethodno prenesena sredstva, pokrenite", + "note_cannot_be_changed_later": "NAPOMENA: Ovo se ne može promijeniti kasnije!", + "note_unlimited_quota": "Napomena: Unesite 0 za neograničenu kvotu", + "notification_email_from_address": "Od adrese", + "notification_email_from_address_description": "E-mail adresa pošiljatelja, na primjer: \"Immich Photo Server \"", + "notification_email_host_description": "Poslužitelja e-pošte (npr. smtp.immich.app)", + "notification_email_ignore_certificate_errors": "Ignoriraj pogreške certifikata", + "notification_email_ignore_certificate_errors_description": "Ignoriraj pogreške provjere valjanosti TLS certifikata (nije preporučeno)", + "notification_email_password_description": "Lozinka za korištenje pri autentifikaciji s poslužiteljem e-pošte", + "notification_email_port_description": "Port poslužitelja e-pošte (npr. 25, 465, ili 587)", + "notification_email_sent_test_email_button": "Pošaljite probni e-mail i spremi", + "notification_email_setting_description": "Postavke za slanje e-mail obavijeste", + "notification_email_test_email": "Pošalji probni e-mail", + "notification_email_test_email_failed": "Slanje testne e-pošte nije uspjelo, provjerite svoje postavke", + "notification_email_test_email_sent": "Testna e-poruka poslana je na {email}. Provjerite svoju pristiglu poštu.", + "notification_email_username_description": "Korisničko ime koje se koristi pri autentifikaciji s poslužiteljem e-pošte", + "notification_enable_email_notifications": "Omogući obavijesti putem e-pošte", + "notification_settings": "Postavke Obavijesti", + "notification_settings_description": "Upravljanje postavkama obavijesti, uključujući e-poštu", + "oauth_auto_launch": "Automatsko pokretanje", + "oauth_auto_launch_description": "Automatski pokrenite OAuth prijavu nakon navigacije na stranicu za prijavu", + "oauth_auto_register": "Automatska registracija", + "oauth_auto_register_description": "Automatski registrirajte nove korisnike nakon prijave s OAuth", + "oauth_button_text": "Tekst gumba", + "oauth_client_id": "ID Klijenta", + "oauth_client_secret": "Tajna Klijenta", + "oauth_enable_description": "Prijavite se putem OAutha", + "oauth_issuer_url": "URL Izdavatelja", + "oauth_mobile_redirect_uri": "Mobilnog Preusmjeravanja URI", "oauth_mobile_redirect_uri_override": "", "oauth_mobile_redirect_uri_override_description": "", "oauth_scope": "", @@ -839,27 +850,31 @@ "storage_label": "", "storage_usage": "", "submit": "", - "suggestions": "", - "sunrise_on_the_beach": "", + "suggestions": "Prijedlozi", + "sunrise_on_the_beach": "Sunrise on the beach", "swap_merge_direction": "", - "sync": "", + "sync": "Sink.", "template": "", - "theme": "", - "theme_selection": "", - "theme_selection_description": "", - "time_based_memories": "", - "timezone": "", - "to_archive": "", - "to_favorite": "", - "toggle_settings": "", - "toggle_theme": "", + "theme": "Tema", + "theme_selection": "Izbor teme", + "theme_selection_description": "Automatski postavite temu na svijetlu ili tamnu ovisno o postavkama sustava vašeg preglednika", + "they_will_be_merged_together": "Oni ću biti spojeni zajedno", + "time_based_memories": "Uspomene temeljene na vremenu", + "timezone": "Vremenska zona", + "to_archive": "Arhivaj", + "to_change_password": "Promjeni lozinku", + "to_favorite": "Omiljeni", + "to_login": "Prijava", + "to_trash": "Smeće", + "toggle_settings": "Uključi/isključi postavke", + "toggle_theme": "Promjeni temu", "toggle_visibility": "", - "total_usage": "", - "trash": "", - "trash_all": "", - "trash_no_results_message": "", - "trashed_items_will_be_permanently_deleted_after": "", - "type": "", + "total_usage": "Ukupna upotreba", + "trash": "Smeće", + "trash_all": "Stavi sve u smeće", + "trash_no_results_message": "Ovdje će se prikazati bačene fotografije i videozapisi.", + "trashed_items_will_be_permanently_deleted_after": "Stavke bačene u smeće trajno će se izbrisati nakon {days, plural, one {# day} other {# days}}.", + "type": "Vrsta", "unarchive": "", "unarchived": "", "unfavorite": "", diff --git a/web/src/lib/i18n/hu.json b/web/src/lib/i18n/hu.json index c754035c7a049..7869e956c7b01 100644 --- a/web/src/lib/i18n/hu.json +++ b/web/src/lib/i18n/hu.json @@ -129,12 +129,13 @@ "map_enable_description": "Térkép funkciók engedélyezése", "map_gps_settings": "Térkép és GPS beállítások", "map_gps_settings_description": "A térkép és a GPS (fordított geokódolás) beállításainak kezelése", + "map_implications": "A térkép szolgáltatás egy külső csempeszolgáltatót használ (tiles.immich.cloud)", "map_light_style": "Világos stílus", "map_manage_reverse_geocoding_settings": "A fordított geokódolás beállításainak kezelése", "map_reverse_geocoding": "Fordított Geokódolás", "map_reverse_geocoding_enable_description": "Fordított geokódolás engedélyezése", "map_reverse_geocoding_settings": "Fordított Geokódolási Beállítások", - "map_settings": "Térkép beállítások", + "map_settings": "Térkép", "map_settings_description": "Térkép beállítások kezelése", "map_style_description": "Egy style.json térképstílusra mutató URL", "metadata_extraction_job": "Metaadatok feldolgozása", @@ -227,7 +228,7 @@ "storage_template_migration_info": "A megváltozott sablon csak az újonnan feltöltött fájlokra lesz alkalmazva. A fájlok visszamenőleges megváltoztatásához futtatni kell a megfelelő munkát: {job}.", "storage_template_migration_job": "Tárhely Sablon Migrációja", "storage_template_more_details": "További információért erről a szolgáltatásról lásd Tárolási Sablont és az implikációkat", - "storage_template_onboarding_description": "Engedélyezve, ez a funkció automatikusan rendszerezi a fájlokat egy felhasználó által megadott sablon alapján. Stabilitási problémák miatt a funkció alapértelmezés szerint ki van kapcsolva. További információkért tekintse meg a dokumentációt.", + "storage_template_onboarding_description": "Ez a funkció, ha be van kapcsolva, automatikusan rendszerezi a fájlokat egy felhasználó által megadott sablon alapján. Stabilitási problémák miatt a funkció alapértelmezés szerint ki van kapcsolva. További információkért tekintse meg a dokumentációt.", "storage_template_path_length": "Út hozzávetőleges maximális hossza: {length, number}{limit, number}", "storage_template_settings": "Tárolási sablon", "storage_template_settings_description": "Kezelje a feltöltött képi vagyontárgyak mappaszerkezetét és fájlnevét", @@ -260,7 +261,7 @@ "transcoding_codecs_learn_more": "Hogy többet tudjon meg az itt felhasznált kifejezésekről, látogassa meg az FFmpeg dokumentációt a H.264 kodekhez, a HEVC kodekhez és a VP9 kodekhez.", "transcoding_constant_quality_mode": "Állandó minőségi mód", "transcoding_constant_quality_mode_description": "Az ICQ jobb, mint a CQP, viszont nem minden hardver támogatja. A rendszer az itt beállított módszert részesíti előnyben. A NVENC ignorálja a beállítást, mivel nem támogatja az ICQ-t.", - "transcoding_constant_rate_factor": "Állandó ráta tényező (árt)", + "transcoding_constant_rate_factor": "Állandó ráta tényező (-crf)", "transcoding_constant_rate_factor_description": "Videó minőségi szint. Jellemző értékek kodekenként: H.264: 23, HEVC: 28, VP9: 31, AV1: 35. Minél alacsonyabb, annál jobb minőséget eredményez, viszont nagyobb fájlmérettel is jár.", "transcoding_disabled_description": "Ne transzkódoljon videót. Nem lejátszható videókhoz vezethet néhány kliensen", "transcoding_hardware_acceleration": "Hardveres Gyorsítás", @@ -278,7 +279,7 @@ "transcoding_preferred_hardware_device": "Átkódoláshoz preferált hardver eszköz", "transcoding_preferred_hardware_device_description": "Csak VAAPI vagy QSV esetén. Beállítja a hardveres transzkódoláshoz használt DRI node-ot.", "transcoding_preset_preset": "Beállítás (-preset)", - "transcoding_preset_preset_description": "Tömörítési gyorsaság. Lassabb beállítások esetén kisebb fájlokat generál, valamint növeli a minőséget megcélzott bitráta esetén. A VP9 kódolás figyelmen kívül hagyja a `faster`-nél gyorsabb beállításokat.", + "transcoding_preset_preset_description": "Tömörítési gyorsaság. Lassabb beállítások esetén kisebb fájlokat generál, valamint növeli a minőséget megcélzott bitráta esetén. A VP9 kódolás figyelmen kívül hagyja a 'faster (gyorsabb)'-nál gyorsabb beállításokat.", "transcoding_reference_frames": "Referencia képkockák", "transcoding_reference_frames_description": "Ennyi képkockára hivatkozzon egy képkocka tömörítéséhez. Magasabb értékek növelik a tömörítési hatékonyságot, de lelassítják a kódolási folyamatot. 0 esetén a szoftver magának beállítja az értéket.", "transcoding_required_description": "Csak az el nem fogadott formátumú videókat", @@ -320,7 +321,8 @@ "user_settings": "Felhasználó Beállítások", "user_settings_description": "Felhasználó beállítások kezelése", "user_successfully_removed": "{email} felhasználó sikeresen törlésre került.", - "version_check_enabled_description": "Engedélyezze rendszeres kérések küldését a GitHub szervereinek új verzió elérhetőségének ellenőrzésére", + "version_check_enabled_description": "Új verziók elérhetőségének ellenőrzése", + "version_check_implications": "Az új verziók ellenőrzése szolgáltatás időszakos kommunikációt igényel a github.com oldallal", "version_check_settings": "Verzió Ellenőrzés", "version_check_settings_description": "Az új verzióról való értesítés be- és kikapcsolása", "video_conversion_job": "Videók Átkódolása", @@ -332,6 +334,7 @@ "advanced": "Haladó", "age_months": "Kor {months, plural, one {# month} other {# months}}", "age_year_months": "Kor 1 év, {months, plural, one {# month} other {# months}}", + "age_years": "{years, plural, other {# éves}}", "album_added": "Albumhoz hozzáadva", "album_added_notification_setting_description": "Küldjön emailes értesítőt, amikor hozzáadnak egy megosztott albumhoz", "album_cover_updated": "Album borító frissítve", @@ -358,7 +361,8 @@ "allow_dark_mode": "Sötét stílus engedélyezése", "allow_edits": "Szerkesztések engedélyezése", "allow_public_user_to_download": "Engedélyezze publikus felhasználónak, hogy letöltse", - "allow_public_user_to_upload": "Engedélyezze publikus felhasználónak, hogy feltöltsön", + "allow_public_user_to_upload": "Engedélyezze a feltöltést publikus felhasználónak", + "anti_clockwise": "Óramutató járásával ellentétes irány", "api_key": "API kulcs", "api_key_description": "Ez az érték csak egyszer jelenik meg. Az ablak bezárása előtt feltétlenül másolja át.", "api_key_empty": "A te API Kulcs neved nem kéne üres legyen", @@ -385,8 +389,18 @@ "asset_uploaded": "Feltöltve", "asset_uploading": "Feltöltés...", "assets": "elemek", + "assets_added_count": "{count, plural, other {# elem}} hozzáadva", + "assets_added_to_album_count": "{count, plural, other {# elem}} hozzáadva az albumhoz", + "assets_added_to_name_count": "{count, plural, other {# elem}} hozzáadva a(z) {hasName, select, true {{name}} other {új}} albumba", + "assets_count": "{count, plural, other {# elem}}", "assets_moved_to_trash": "{count, plural, one {# fájl} other {# fájl}} a lomtárba mozgatva", + "assets_moved_to_trash_count": "{count, plural, other {# elem}} szemétbe mozgatva", + "assets_permanently_deleted_count": "{count, plural, other {# elem}} örökre törölve", + "assets_removed_count": "{count, plural, other {# elem}} eltávolítva", "assets_restore_confirmation": "Biztosan visszaállítja a lomtárbeli elemeket? Ez a művelet nem visszavonható!", + "assets_restored_count": "{count, plural, other {# elem}} visszaállítva", + "assets_trashed_count": "{count, plural, other {# elem}} kidobva", + "assets_were_part_of_album_count": "{count, plural, other {# elem}} már az album része volt", "authorized_devices": "Engedélyezett készülékek", "back": "Vissza", "back_close_deselect": "Vissza, bezárás, vagy kijelölés törlése", @@ -394,7 +408,10 @@ "birthdate_saved": "Születésnap elmentve", "birthdate_set_description": "A születés napját a rendszer annak kijelzésére használja, hogy a fénykép készítésének idejében az illető hány éves volt.", "blurred_background": "Homályos háttér", + "build": "Építés", + "build_image": "Kép építése", "bulk_delete_duplicates_confirmation": "Biztosan kitöröl {count, plural, one {# duplikált fájlt} other {# duplikált fájlt}}? A művelet során minden hasonló fájlcsoportból a legnagyobb méretű fájlt megtartja, minden másik duplikált fájlt kitörli. Ez a művelet nem visszavonható!", + "bulk_keep_duplicates_confirmation": "Biztosan meg szeretne tartani {count, plural, other {# egyező elemet}}? Ez felold minden duplikátum csoportot elemek törlése nélkül.", "bulk_trash_duplicates_confirmation": "Biztosan kitöröl {count, plural, one {# duplikált fájlt} other {# duplikált fájlt}}? Ez a művelet megtartja minden fájlcsoportból a legnagyobb méretű elemet, és kitörli minden másik duplikáltat.", "buy": "Immich megvásárlása", "camera": "Fényképezőgép", @@ -427,6 +444,7 @@ "clear_all_recent_searches": "Legutóbbi keresések törlése", "clear_message": "Üzenet törlése", "clear_value": "Érték törlése", + "clockwise": "Óramutató járásával megegyező irány", "close": "Bezárás", "collapse": "Összecsuk", "collapse_all": "Mindet összecsuk", @@ -503,12 +521,15 @@ "do_not_show_again": "Ne mutassa többet ezt az üzenetet", "done": "Kész", "download": "Letöltés", + "download_include_embedded_motion_videos": "Beágyazott videók", + "download_include_embedded_motion_videos_description": "Mozgó képekbe beágyazott videók mutatása külön fájlként", "download_settings": "Letöltés", "download_settings_description": "Képi vagyontárgyak letöltésére vonatkozó beállítások", "downloading": "Letöltés", "downloading_asset_filename": "Fájl letöltése {filename}", "drop_files_to_upload": "Húzza a fájlokat bárhova a feltöltéshez", "duplicates": "Duplikátumok", + "duplicates_description": "Oldja fel a csoportokat a (ha léteznek) duplukátumok megjelölésével", "duration": "Időtartam", "durations": { "days": "{days, plural, one {nap} other {{days, number} nap}}", @@ -535,6 +556,10 @@ "edit_user": "Felhasználó módosítása", "edited": "Módosítva", "editor": "Szerkesztő", + "editor_close_without_save_prompt": "A változtatások nem lesznek mentve", + "editor_close_without_save_title": "Szerkesztő bezárása?", + "editor_crop_tool_h2_aspect_ratios": "Oldalarányok", + "editor_crop_tool_h2_rotation": "Forgatás", "email": "Email", "empty": "", "empty_album": "Üres Album", @@ -542,7 +567,7 @@ "empty_trash_confirmation": "Biztosan kiüríti a lomtárat? Ezzel minden lomtárbeli fájlt véglegesen letöröl az Immich szolgáltatásból.\nEz a művelet nem visszavonható!", "enable": "Engedélyezés", "enabled": "Engedélyezve", - "end_date": "", + "end_date": "Vég dátum", "error": "Hiba", "error_loading_image": "Hiba a kép betöltése közben", "error_title": "Hiba - valami félresikerült", @@ -550,7 +575,9 @@ "cannot_navigate_next_asset": "Nem lehet a következő elemhez navigálni", "cannot_navigate_previous_asset": "Nem lehet az előző elemhez navigálni", "cant_apply_changes": "Nem lehet alkalmazni a változtatásokat", + "cant_change_activity": "Nem lehet {enabled, select, true {engedélyezni} other {kikapcsolni}} tevékenységet", "cant_change_asset_favorite": "Nem lehet a kedvenc állapotot megváltoztatni ehhez az elemhez", + "cant_change_metadata_assets_count": "Nem lehet {count, plural, other {# elem}} metaadatát megváltoztatni", "cant_get_faces": "Arcok lekérdezése sikertelen", "cant_get_number_of_comments": "Hozzászólások számának lekérdezése sikertelen", "cant_search_people": "Emberek keresése sikertelen", @@ -564,6 +591,7 @@ "error_removing_assets_from_album": "Hiba történt az elemek albumból való eltávolítása során, további információért ellenőrizze a logokat", "error_selecting_all_assets": "Minden elem kijelölése közben hiba lépett fel", "exclusion_pattern_already_exists": "Ez a kizárási minta már létezik.", + "failed_job_command": "Parancs {command} hibával zárult a {job} munkában", "failed_to_create_album": "Album készítése sikertelen", "failed_to_create_shared_link": "Megosztott link készítése sikertelen", "failed_to_edit_shared_link": "Megosztott link szerkesztése sikertelen", @@ -572,81 +600,121 @@ "failed_to_load_assets": "Elemek betöltése sikertelen", "failed_to_load_people": "Emberek betöltése sikertelen", "failed_to_remove_product_key": "Termékkulcs eltávolítása sikertelen", + "failed_to_stack_assets": "Elemek csoportosítása sikertelen", + "failed_to_unstack_assets": "Elemek szétszedése sikertelen", "import_path_already_exists": "Ez az importálási útvonal már létezik.", "incorrect_email_or_password": "Helytelen e-mail vagy jelszó", "paths_validation_failed": "Sikertelen érvényesítés {paths, plural, one {# elérési útvonalon} other {# elérési útvonalon}}", "profile_picture_transparent_pixels": "Profilképek nem tartalmazhatnak átlátszó pixeleket. Közelítsen rá és/vagy mozgassa a képet.", "quota_higher_than_disk_size": "Az elérhető háttértárnál nagyobb kvótát állított be", + "repair_unable_to_check_items": "Nem sikerült {count, select, one {element} other {elemeket}} ellenőrizni", "unable_to_add_album_users": "Felhasználók hozzáadása albumhoz sikertelen", "unable_to_add_assets_to_shared_link": "Felhasználók hozzáadása megosztott linkhez sikertelen", "unable_to_add_comment": "Hozzászólás sikertelen", "unable_to_add_exclusion_pattern": "Kivétel minta hozzáadása sikertelen", "unable_to_add_import_path": "Importálási útvonal hozzáadása sikertelen", "unable_to_add_partners": "Partnerek hozzáadása sikertelen", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", + "unable_to_add_remove_archive": "Elem {archived, select, true {eltávolítása archívumból} other {hozzáadása archívumba}} sikertelen", + "unable_to_add_remove_favorites": "Elem {favorite, select, true {eltávolítása kedvencekből} other {hozzáadása kedvencekhez}} sikertelen", + "unable_to_archive_unarchive": "Elem {archived, select, true {archiválása} other {kivétele archívumból}} sikertelen", + "unable_to_change_album_user_role": "Album tagjának szerepének megváltoztatása sikertelen", + "unable_to_change_date": "Dátum megváltoztatása sikertelen", + "unable_to_change_favorite": "Kedvenc állapot megváltoztatása sikertelen", + "unable_to_change_location": "Hely megváltoztatása sikertelen", + "unable_to_change_password": "Jelszó megváltoztatása sikertelen", + "unable_to_change_visibility": "{count, plural, other {# ember}} láthatóságának a megváltoztatása sikertelen", "unable_to_check_item": "", "unable_to_check_items": "", + "unable_to_complete_oauth_login": "OAuth bejelentkezés sikertelen", + "unable_to_connect": "Csatlakozás sikertelen", + "unable_to_connect_to_server": "Szerverhez való csatlakozás sikertelen", "unable_to_copy_to_clipboard": "Vágólapra másolás sikertelen. Ellenőrizze, hogy a kapcsolat https-en keresztül történik", - "unable_to_create_admin_account": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", + "unable_to_create_admin_account": "Admin felhasználó létrehozása sikertelen", + "unable_to_create_api_key": "Új API kulcs létrehozása sikertelen", + "unable_to_create_library": "Könyvtár létrehozása sikertelen", + "unable_to_create_user": "Felhasználó létrehozása sikertelen", + "unable_to_delete_album": "Album törlése sikertelen", + "unable_to_delete_asset": "Elem törlése sikertelen", + "unable_to_delete_assets": "Hiba történt az elemek törlésekor", + "unable_to_delete_exclusion_pattern": "Kizárási minta törlése sikertelen", + "unable_to_delete_import_path": "Import útvonal törlése sikertelen", + "unable_to_delete_shared_link": "Megosztott link törlése sikertelen", "unable_to_delete_user": "Nem sikerült törölni a felhasználót", + "unable_to_download_files": "Fájlok letöltése sikertelen", + "unable_to_edit_exclusion_pattern": "Kizárási minta szerkesztése sikertelen", + "unable_to_edit_import_path": "Import útvonal szerkesztése sikertelen", "unable_to_empty_trash": "Nem sikerült a lomtár ürítése", "unable_to_enter_fullscreen": "Nem lehet belépni a teljes képernyőre", "unable_to_exit_fullscreen": "Nem lehet kilépni a teljes képernyőről", - "unable_to_hide_person": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", + "unable_to_get_comments_number": "Hozzászólások számának lekérdezése sikertelen", + "unable_to_get_shared_link": "Megosztott link lekérdezése sikertelen", + "unable_to_hide_person": "Személy elrejtése sikertelen", + "unable_to_link_oauth_account": "OAuth felhasználó csatlakoztatása sikertelen", + "unable_to_load_album": "Album betöltése sikertelen", + "unable_to_load_asset_activity": "Elem aktivitásának betöltése sikertelen", + "unable_to_load_items": "Elemek betöltése sikertelen", + "unable_to_load_liked_status": "Tetszik állapot betöltése sikertelen", + "unable_to_log_out_all_devices": "Minden eszközből való kijelentkeztetés sikertelen", + "unable_to_log_out_device": "Sikertelen kijelentkezés", + "unable_to_login_with_oauth": "Sikertelen bejelentkezés OAuth-tal", + "unable_to_play_video": "Videó lejátszása sikertelen", + "unable_to_reassign_assets_existing_person": "Nem sikerült az elemeket áthelyezni {name, select, null {egy létező személyhez} other {hozzá: {name}}}", + "unable_to_reassign_assets_new_person": "Elemek áthelyezése új személyhez sikertelen", + "unable_to_refresh_user": "Felhasználó újratöltése sikertelen", + "unable_to_remove_album_users": "Felhasználó albumból való eltávolítása sikertelen", + "unable_to_remove_api_key": "API kulcs eltávolítása sikertelen", + "unable_to_remove_assets_from_shared_link": "Elemek eltávolítása megosztott linkből sikertelen", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_library": "Könyvtár törlése sikertelen", + "unable_to_remove_offline_files": "Offline fájlok törlése sikertelen", + "unable_to_remove_partner": "Partner eltávolítása sikertelen", + "unable_to_remove_reaction": "Reakció eltávolítása sikertelen", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", + "unable_to_repair_items": "Elemek javítása sikertelen", + "unable_to_reset_password": "Jelszó visszaállítása sikertelen", + "unable_to_resolve_duplicate": "Duplikátum feloldása sikertelen", + "unable_to_restore_assets": "Elemek szemeteskosárból való visszaállítása sikertelen", "unable_to_restore_trash": "Nem sikerült a lomtár visszaállítása", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", + "unable_to_restore_user": "Felhasználó visszaállítása sikertelen", + "unable_to_save_album": "Album mentése sikertelen", + "unable_to_save_api_key": "API kulcs mentése sikertelen", + "unable_to_save_date_of_birth": "Születési időpont mentése sikertelen", + "unable_to_save_name": "Név mentése sikertelen", + "unable_to_save_profile": "Profil mentése sikertelen", + "unable_to_save_settings": "Beállítások mentése sikertelen", + "unable_to_scan_libraries": "Könyvtárak ellenőrzése sikertelen", + "unable_to_scan_library": "Könyvtár ellenőrzése sikertelen", + "unable_to_set_feature_photo": "Kijelölt fénykép beállítása sikertelen", + "unable_to_set_profile_picture": "Profilkép beállítása sikertelen", "unable_to_submit_job": "Nem sikerült a profilt elmenteni", "unable_to_trash_asset": "Nem sikerült a fájl lomtárba mozgatása", "unable_to_unlink_account": "Nem sikerült a fiók lekapcsolása", + "unable_to_update_album_cover": "Albumborító beállítása sikertelen", + "unable_to_update_album_info": "Album információ frissítése sikertelen", "unable_to_update_library": "Nem sikerült a képtár módosítása", "unable_to_update_location": "Nem sikerült az elérés módosítása", "unable_to_update_settings": "Nem sikerült a beállítások módosítása", "unable_to_update_timeline_display_status": "Nem sikerült az idővonal kijelzési státuszának módosítása", - "unable_to_update_user": "Nem sikerült a felhasználó módosítása" + "unable_to_update_user": "Nem sikerült a felhasználó módosítása", + "unable_to_upload_file": "Fájlfeltöltés sikertelen" }, "every_day_at_onepm": "", "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", + "exif": "Exif", "exit_slideshow": "Kilépés a diavetítésből", - "expand_all": "", + "expand_all": "Minden kinyitása", "expire_after": "Lejárati idő", "expired": "Lejárt", + "expires_date": "Lejár {date}", "explore": "Felfedezés", "export": "Exportálás", "export_as_json": "Exportálás JSON formátumban", "extension": "Kiterjesztés", "external": "Külső", "external_libraries": "Külső Képtárak", + "face_unassigned": "Nincs hozzárendelve", "failed_to_get_people": "Személyek lekérése sikertelen", "favorite": "Kedvenc", "favorite_or_unfavorite_photo": "Fotó kedvencnek jelölése vagy annak visszavonása", @@ -671,15 +739,33 @@ "go_to_search": "Ugrás a kereséshez", "go_to_share_page": "Ugrás a megosztás oldalhoz", "group_albums_by": "Albumok csoportosítása...", - "has_quota": "", + "group_no": "Nincs csoportosítás", + "group_owner": "Csoportosítás tulajdonosonként", + "group_year": "Csoportosítás évenként", + "has_quota": "Van kvótája", + "hi_user": "Helló {name} ({email})", + "hide_all_people": "Minden személy elrejtése", "hide_gallery": "Galéria elrejtése", + "hide_named_person": "Személy {name} elrejtése", "hide_password": "Jelszó elrejtése", "hide_person": "Személy elrejtése", + "hide_unnamed_people": "Megnevezetlen emberek elrejtése", "host": "", "hour": "Óra", "image": "Kép", + "image_alt_text_date": "{isVideo, select, true {Videó} other {Kép}} készítési dátuma {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Videó} other {Kép}} vele: {person1} készítve {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Videó} other {Kép}} velük: {person1} és {person2}, ekkor: {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Videó} other {Kép}} velük: {person1}, {person2} és {person3} ekkor: {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Videó} other {Kép}} velük: {person1}, {person2} és {additionalCount, number} más ekkor: {date}", + "image_alt_text_date_place": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, ekkor: {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, vele: {person1}, ekkor: {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1} és {person2}, ekkor: {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1}, {person2} és {person3}, ekkor: {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1}, {person2} és {additionalCount, number} más, ekkor: {date}", "img": "", "immich_logo": "Immich Logó", + "immich_web_interface": "Immich web felület", "import_from_json": "Importálás JSON formátumból", "import_path": "Importálási útvonal", "in_albums": "{count, plural, one {# albumban} other {# albumban}}", @@ -691,12 +777,13 @@ "info": "Infó", "interval": { "day_at_onepm": "Minden nap 13 órakor", - "hours": "", + "hours": "{hours, plural, one {óránként} other {{hours, number} óránként}}", "night_at_midnight": "Minden éjjel éjfélkor", "night_at_twoam": "Minden éjjel 2 órakor" }, "invite_people": "Személyek Meghívása", "invite_to_album": "Meghívás az albumba", + "items_count": "{count, plural, other {# elem}}", "job_settings_description": "", "jobs": "Feladatok", "keep": "Megtartás", @@ -705,15 +792,18 @@ "language": "Nyelv", "language_setting_description": "Válassza ki preferált nyelvét", "last_seen": "Utoljára látva", + "latest_version": "Legfrissebb verzió", + "latitude": "Szélesség", "leave": "Elhagyás", "let_others_respond": "Engedd, hogy mások reagáljanak", "level": "Szint", "library": "Képtár", "library_options": "Képtár beállítások", "light": "Világos", + "like_deleted": "Tetszik törölve", "link_options": "Link beállítások", - "link_to_oauth": "", - "linked_oauth_account": "", + "link_to_oauth": "Csatlakoztatás OAuth-hoz", + "linked_oauth_account": "Csatlakoztatott OAuth felhasználó", "list": "Lista", "loading": "Betöltés", "loading_search_results_failed": "Keresési eredmények betöltése sikertelen", @@ -721,8 +811,12 @@ "log_out_all_devices": "Összes Eszköz Kijelentkeztetése", "logged_out_all_devices": "Az összes eszköz kijelentkeztetve", "logged_out_device": "Eszköz kijelentkeztetve", - "login_has_been_disabled": "", - "look": "", + "login": "Bejelentkezés", + "login_has_been_disabled": "Bejelentkezés le van tiltva.", + "logout_all_device_confirmation": "Biztos, hogy minden eszközből szeretne kijelentkezni?", + "logout_this_device_confirmation": "Biztos, hogy szeretne kijelentkezni ebből az eszközből?", + "longitude": "Hosszúság", + "look": "Kinézet", "loop_videos": "Videók ismétlése", "loop_videos_description": "Engedélyezi a videók folyamatosan ismételt lejátszását az elem megjelenítőben.", "make": "Gyártó", @@ -734,6 +828,7 @@ "manage_your_devices": "Engedélyezett készülékek kezelése", "manage_your_oauth_connection": "OAuth kapcsolat kezelése", "map": "Térkép", + "map_marker_for_images": "Térképjelölő a képekhez itt készült: {country}, {city}", "map_marker_with_image": "Térképjelölő képpel", "map_settings": "Térkép beállítások", "matches": "Megegyezések", @@ -741,13 +836,15 @@ "memories": "Emlékek", "memories_setting_description": "Emlékek tartalmának kezelése", "memory": "Emlék", + "memory_lane_title": "Emlékek {title}", "menu": "Menü", "merge": "Összevonás", "merge_people": "Személyek összevonása", "merge_people_limit": "Egyszerre legfeljebb 5 arcot vonhatsz össze", "merge_people_prompt": "Biztosan összevonod ezeket a személyeket? Ez a művelet nem visszavonható.", - "merge_people_successfully": "", - "minimize": "", + "merge_people_successfully": "Személyek sikeresen egyesítve", + "merged_people_count": "{count, plural, other {# személy}} egyesítve", + "minimize": "Lekicsinítés", "minute": "Perc", "missing": "Hiányzó", "model": "Modell", @@ -758,79 +855,110 @@ "name": "Név", "name_or_nickname": "Név vagy becenév", "never": "Soha", + "new_album": "Új album", "new_api_key": "Új API Kulcs", "new_password": "Új jelszó", "new_person": "Új személy", "new_user_created": "Új felhasználó létrehozva", + "new_version_available": "ÚJ VERZIÓ ELÉRHETŐ", "newest_first": "Legújabb először", "next": "Következő", "next_memory": "Következő emlék", "no": "Nem", "no_albums_message": "Hozzon létre új albumot a fotói és videói rendszerezéséhez", + "no_albums_with_name_yet": "Úgy tűnik, hogy nincs még ilyen névvel album.", + "no_albums_yet": "Úgy tűnik, hogy még nem lett album létrehozva.", "no_archived_assets_message": "Archiváljon fényképeket és videókat, hogy elrejtse azokat a Fényképek nézetből", "no_assets_message": "KATTINTSON AZ ELSŐ FÉNYKÉPE FELTÖLTÉSÉHEZ", - "no_exif_info_available": "", + "no_duplicates_found": "Duplikátumok nem találhatók.", + "no_exif_info_available": "Exif információ nem elérhető", "no_explore_results_message": "Töltsön fel több fényképet, hogy felfedezze a gyűjteményét.", "no_favorites_message": "Jelöljön meg kedvenceket, hogy gyorsan megtalálhassa legjobb fényképeit és videóit", "no_libraries_message": "Hozzon létre külső képtárat a fényképei és videói megtekintéséhez", "no_name": "Nincs Név", - "no_places": "", + "no_places": "Nincsenek helyek", "no_results": "Nincsenek eredmények", + "no_results_description": "Próbáljon egy szinonimát, vagy fogalmazzon általánosabban", "no_shared_albums_message": "Hozzon létre egy új albumot, hogy megoszthassa fényképeit és videóit másokkal", "not_in_any_album": "Nincs albumban", + "note_apply_storage_label_to_previously_uploaded assets": "Megjegyzés: hogy a Tárhelycímkézést végrehajtódjon a korábban feltöltött elemeken, futtassa a", + "note_unlimited_quota": "Megjegyzés: Írjon 0-t végtelen kvótához", "notes": "Jegyzetek", "notification_toggle_setting_description": "Emailes értesítések engedélyezése", "notifications": "Értesítések", "notifications_setting_description": "Értesítések kezelése", "oauth": "OAuth", "offline": "Offline", + "offline_paths": "Offline útvonalak", + "offline_paths_description": "Ezeket az eredményeket okozhatja a külső könyvtárhoz nem tartozó fájlok manuális törlése.", "ok": "Rendben", "oldest_first": "Legrégebbi először", + "onboarding": "Első lépések", + "onboarding_privacy_description": "Az alábbi (nem kötelező) szolgáltatások külső szolgáltatásokon alapulnak, és bármikor kikapcsolhatóak az adminisztrációs beállításokban.", + "onboarding_theme_description": "Válasszon egy színt az alkalmazásnak. Ezt bármikor megváltoztathatja a beállításokban.", + "onboarding_welcome_description": "Állítsunk be néhány gyakori beállítást.", + "onboarding_welcome_user": "Üdvözlöm, {user}", "online": "Online", "only_favorites": "Csak kedvencek", "only_refreshes_modified_files": "Csak a megváltoztatott fájlokat frissíti", - "open_the_search_filters": "", + "open_in_map_view": "Megnyitás térkép nézetben", + "open_in_openstreetmap": "Megnyitás OpenStreetMap-ben", + "open_the_search_filters": "Keresési szűrők megnyitása", "options": "Beállítások", + "or": "vagy", "organize_your_library": "Rendszerezze képtárát", + "original": "eredeti", "other": "Egyéb", "other_devices": "Egyéb eszközök", "other_variables": "Egyéb változók", "owned": "Tulajdonos", "owner": "Tulajdonos", + "partner": "Partner", + "partner_can_access": "{partner} hozzáférhet", + "partner_can_access_assets": "Minden fényképe és videója, kivéve amik archiválásra vagy törlésre kerültek", + "partner_can_access_location": "A fényképei készítési helye", "partner_sharing": "Társmegosztás", "partners": "Társak", "password": "Jelszó", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", + "password_does_not_match": "Jelszavak nem egyeznek", + "password_required": "Jelszó szükséges", + "password_reset_success": "Jelszóvisszaállítás sikeres", "past_durations": { - "days": "", - "hours": "", - "years": "" + "days": "{days, plural, one {Tegnap} other {Elmúlt # nap}}", + "hours": "{hours, plural, one {Előző óra} other {Elmúlt # óra}}", + "years": "{years, plural, one {Tavaly} other {Elmúlt # év}}" }, "path": "Útvonal", "pattern": "Minta", "pause": "Szüneteltetés", "pause_memories": "Emlékek szüneteltetése", "paused": "Szüneteltetve", - "pending": "", + "pending": "Folyamatban lévő", "people": "Személyek", - "people_sidebar_description": "", + "people_edits_count": "{count, plural, other {# személy}} szerkesztve", + "people_sidebar_description": "Jelenítsen meg linket a Személyek fülhöz oldalt", "perform_library_tasks": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", + "permanent_deletion_warning": "Figyelmeztetés végleges törlésről", + "permanent_deletion_warning_setting_description": "Figyelmeztessen fájlok végleges törlése előtt", "permanently_delete": "Végleges törlés", - "permanently_deleted_asset": "", + "permanently_delete_assets_count": "{count, plural, one {Elem} other {Elemek}} végleges törlése", + "permanently_delete_assets_prompt": "Biztos, hogy véglegesen törölni szeretné ezt {count, plural, one {az elemet?} other {a(z) # elemet?}} Ez el fogja távolítani az albumokból, amikben {count, plural, one {szerepel} other {szerepelnek}}.", + "permanently_deleted_asset": "Elem véglegesen törölve", + "permanently_deleted_assets_count": "{count, plural, other {# elem}} véglegesen törölve", + "person": "Személy", + "person_hidden": "{name}{hidden, select, true { (rejtett)} other {}}", + "photo_shared_all_users": "Mindenkivel megosztotta a fényképeit, vagy nincs senki, akivel meg tudná osztani.", "photos": "Képek", + "photos_and_videos": "Fényképek és videók", "photos_count": "{count, plural, one {{count, number} Fotó} other {{count, number} Fotó}}", "photos_from_previous_years": "Képek előző évekből", - "pick_a_location": "", + "pick_a_location": "Válasszon egy helyet", "place": "Hely", "places": "Helyek", "play": "Lejátszás", "play_memories": "Emlékek lejátszása", "play_motion_photo": "Mozgókép lejátszása", - "play_or_pause_video": "", + "play_or_pause_video": "Videó elindítása vagy megállítása", "point": "", "port": "Port", "preset": "Sablon", @@ -839,99 +967,193 @@ "previous_memory": "Előző emlék", "previous_or_next_photo": "Előző vagy következő fotó", "primary": "Elsődleges", - "profile_picture_set": "", + "privacy": "Magánszféra", + "profile_image_of_user": "{user} profilképe", + "profile_picture_set": "Profilkép beállítva.", + "public_album": "Publikus album", "public_share": "Nyilvános Megosztás", + "purchase_account_info": "Támogató", + "purchase_activated_subtitle": "Köszönjük, hogy támogatja az Immich-et és a nyílt forráskódú programokat", + "purchase_activated_time": "Aktiválva ekkor: {date, date}", + "purchase_activated_title": "Kulcs sikeresen aktiválva", + "purchase_button_activate": "Aktiválás", + "purchase_button_buy": "Vásárlás", + "purchase_button_buy_immich": "Vásárolja meg az Immich-et", + "purchase_button_never_show_again": "Soha többé ne mutassa", + "purchase_button_reminder": "Emlékeztessen 30 nap múlva", + "purchase_button_remove_key": "Kulcs eltávolítása", + "purchase_button_select": "Kiválasztás", + "purchase_failed_activation": "Aktiválás sikertelen! Ellenőrizze az e-mailjét a helyes termékkulcsért!", + "purchase_individual_description_1": "Magánszemélynek", + "purchase_individual_description_2": "Támogató állapot", + "purchase_individual_title": "Magánszemély", + "purchase_input_suggestion": "Van termékkulcsa? Adja meg a kulcsot alább", + "purchase_license_subtitle": "Vásárolja meg az Immich-et, hogy támogassa a szolgáltatás fejlesztését a jövőben is", + "purchase_lifetime_description": "Élethosszú vásárlás", + "purchase_option_title": "VÁSÁRLÁSI LEHETŐSÉGEK", + "purchase_panel_info_1": "Az Immich készítése sok időt és erőfeszítést igényel, és teljes munkaidőben foglalkoztatunk szoftvermérnököket hogy olyan jóvá tegyük, amennyire csak lehet. Küldetésünk, hogy a nyílt forráskódú szoftver és etikus üzleti gyakorlat fenntartható bevételi forrás legyen a fejlesztőinknek, és egy magánszférát tiszteletben tartó ökoszisztéma készítése, amely valódi alternatívát nyújt a felhasználókat kihasználó felhőszolgáltatásoknak.", + "purchase_panel_info_2": "Mivel elkötelezettek vagyunk, hogy nem zárunk fizetés mögé szolgáltatásokat, ez a vásárlás az Immich semmilyen új részét nem oldja fel. Olyan felhasználóktól, mint Öntől, függünk, hogy az Immich-et tudjuk fejleszteni.", + "purchase_panel_title": "Támogassa a projektet", + "purchase_per_server": "Szerverenként", + "purchase_per_user": "Felhasználónként", + "purchase_remove_product_key": "Termékkulcs eltávolítása", + "purchase_remove_product_key_prompt": "Biztosan el szeretné távolítani a termékkulcsot?", + "purchase_remove_server_product_key": "Szerver termékkulcs eltávolítása", + "purchase_remove_server_product_key_prompt": "Biztosan el szeretné távolítani a szerver termékkulcsot?", + "purchase_server_description_1": "Az egész szerverre", + "purchase_server_description_2": "Támogító állapot", + "purchase_server_title": "Szerver", + "purchase_settings_server_activated": "A szerver termékkulcsot az admin menedzseli", "range": "", + "rating": "Értékelés csillagokkal", + "rating_description": "Exif értékelés megjelenítése az infópanelben", "raw": "", - "reaction_options": "", - "read_changelog": "", + "reaction_options": "Reakció lehetőségek", + "read_changelog": "Változtatások olvasása", + "reassign": "Áthelyezés", + "reassigned_assets_to_existing_person": "{count, plural, other {# elem}} áthelyezve {name, select, null {egy létező személyhez} other {{name}}}", + "reassigned_assets_to_new_person": "{count, plural, other {# elem}} áthelyezve egy új személyhez", + "reassing_hint": "Kijelölt média hozzáadása létező emberhez", "recent": "Friss", "recent_searches": "Friss keresések", "refresh": "Frissítés", + "refresh_encoded_videos": "Elkódolt videók frissítése", + "refresh_metadata": "Metaadatok frissítése", + "refresh_thumbnails": "Előnézetek frissítése", "refreshed": "Frissítve", - "refreshes_every_file": "", + "refreshes_every_file": "Minden fájl frissítése", + "refreshing_encoded_video": "Elkódolt videók frissítése", + "refreshing_metadata": "Metaadatok frissítése", + "regenerating_thumbnails": "Előnézetek újragenerálása", "remove": "Eltávolítás", + "remove_assets_album_confirmation": "Biztosan szeretne eltávolítani {count, plural, one {# elemet} other {# elemet}} az albumból?", + "remove_assets_shared_link_confirmation": "Biztosan szeretne eltávolítani {count, plural, one {# elemet} other {# elemet}} ebből a megosztott linkből?", + "remove_assets_title": "Elemek eltávolítása?", + "remove_custom_date_range": "Szabadon megadott időintervallum eltávolítása", "remove_from_album": "Eltávolítás az albumból", "remove_from_favorites": "Eltávolítás a kedvencekből", "remove_from_shared_link": "Eltávolítás a megosztott linkből", "remove_offline_files": "Offline Fájlok Eltávolítása", + "remove_user": "Felhasználó eltávolítása", + "removed_api_key": "API Kulcs eltávolítva: {name}", + "removed_from_archive": "Archívumból eltávolítva", + "removed_from_favorites": "Kedvencekből eltávolítva", + "removed_from_favorites_count": "A kedvencekből el lett távolítva {count, plural, other {# elem}}", + "rename": "Átnevezés", "repair": "Javítás", - "repair_no_results_message": "", + "repair_no_results_message": "Nem megfigyelt és hiányzó fájlok itt jelennek meg", "replace_with_upload": "Csere feltöltéssel", - "require_password": "", + "repository": "Adattár", + "require_password": "Jelszó szükségessé tétele", + "require_user_to_change_password_on_first_login": "Felhasználó első bejelentkezéskor való jelszóváltoztatásának szükségessé tétele", "reset": "Visszaállítás", "reset_password": "Jelszó visszaállítása", - "reset_people_visibility": "", + "reset_people_visibility": "Emberek láthatóságának visszaállítása", "reset_settings_to_default": "", + "reset_to_default": "Visszaállítás alapállapotba", + "resolve_duplicates": "Duplikátumok feloldása", + "resolved_all_duplicates": "Minden duplikátum feloldása", "restore": "Visszaállít", + "restore_all": "Minden visszaállítása", "restore_user": "Felhasználó visszaállítása", - "retry_upload": "", - "review_duplicates": "", + "restored_asset": "Elem visszaállítása", + "resume": "Folytatás", + "retry_upload": "Feltöltés újrapróbálása", + "review_duplicates": "Megegyező elemek átnézése", "role": "Szerep", + "role_editor": "Szerkesztő", + "role_viewer": "Néző", "save": "Mentés", - "saved_profile": "", - "saved_settings": "", + "saved_api_key": "API Kulcs elmentve", + "saved_profile": "Profil elmentve", + "saved_settings": "Beállítások elmentve", "say_something": "Szólj hozzá", - "scan_all_libraries": "", - "scan_all_library_files": "", - "scan_new_library_files": "", - "scan_settings": "", + "scan_all_libraries": "Minden könyvtár átnézése", + "scan_all_library_files": "Minden könyvtárbeli elem újraellenőrzése", + "scan_new_library_files": "Ellenőrzés új könyvtárbeli elemekért", + "scan_settings": "Felfedezési beállítások", "search": "Keresés", "search_albums": "Albumok keresése", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", - "search_state": "", - "search_timezone": "", - "search_type": "", + "search_by_context": "Keresés kontextus alapján", + "search_by_filename": "Keresés fájlnév vagy kiterjesztés alapján", + "search_by_filename_example": "például IMG_1234.JPG vagy PNG", + "search_camera_make": "Kameragyártó keresése...", + "search_camera_model": "Kameramodell keresése...", + "search_city": "Város keresése...", + "search_country": "Ország keresése...", + "search_for_existing_person": "Már meglévő személy keresése", + "search_no_people": "Nincs személy", + "search_no_people_named": "Nincs személy \"{name}\" néven", + "search_people": "Személyek keresése", + "search_places": "Helyek keresése", + "search_state": "Régió keresése...", + "search_timezone": "Időzóna keresése...", + "search_type": "Típus keresése", "search_your_photos": "Fotók keresése", "searching_locales": "", "second": "Másodperc", - "select_album_cover": "", + "see_all_people": "Minden személy megtekintése", + "select_album_cover": "Albumborító kiválasztása", "select_all": "Összes kijelölése", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_library_owner": "", - "select_new_face": "", + "select_all_duplicates": "Minden duplikátum kiválasztása", + "select_avatar_color": "Avatár színének választása", + "select_face": "Arc kiválasztása", + "select_featured_photo": "Kijelölt fénykép kiválasztása", + "select_from_computer": "Kiválasztás számítógépről", + "select_keep_all": "Minden megtartása", + "select_library_owner": "Könyvtártulajdonos kijelölése", + "select_new_face": "Új arc kiválasztása", "select_photos": "Fotók választása", + "select_trash_all": "Minden szemétbe helyezése", "selected": "Kijelölt", - "send_message": "", + "selected_count": "{count, plural, other {# kiválasztva}}", + "send_message": "Üzenet küldése", + "send_welcome_email": "Üdvözlő üzenet küldése", "server": "Szerver", - "server_stats": "", - "set": "", - "set_as_album_cover": "", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", + "server_offline": "Szerver Nem Elérhető", + "server_online": "Szerver Elérhető", + "server_stats": "Szerver Statisztikák", + "server_version": "Szerver Verzió", + "set": "Beállítás", + "set_as_album_cover": "Beállítás albumborítóként", + "set_as_profile_picture": "Beállítás profilképként", + "set_date_of_birth": "Születési dátum beállítása", + "set_profile_picture": "Profilkép beállítása", + "set_slideshow_to_fullscreen": "Diavetítés teljes képernyőre állítása", "settings": "Beállítások", - "settings_saved": "", + "settings_saved": "Beállítások mentve", "share": "Megosztás", "shared": "Megosztva", - "shared_by": "", - "shared_by_you": "", + "shared_by": "Megosztva általa:", + "shared_by_user": "Megosztva {user} által", + "shared_by_you": "Megosztva Ön által", + "shared_from_partner": "Fényképek {partner}-tól/től", + "shared_link_options": "Megosztott link beállítások", "shared_links": "Megosztott Linkek", + "shared_photos_and_videos_count": "{assetCount, plural, other {# megosztott kép és videó.}}", + "shared_with_partner": "Megosztva vele: {partner}", "sharing": "Megosztás", - "sharing_sidebar_description": "", - "show_album_options": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", + "sharing_enter_password": "Jelszó megadása szükséges az oldal megtekintéséhez.", + "sharing_sidebar_description": "Jelenítsen meg linket a Megosztás fülhöz oldalt", + "shift_to_permanent_delete": "nyomja meg a ⇧-t hogy véglegesen törölje az elemet", + "show_album_options": "Albummegjelenítési beállítások", + "show_albums": "Albumok megtekintése", + "show_all_people": "Minden személy megjelenítése", + "show_and_hide_people": "Személyek megjelenítése és elrejtése", + "show_file_location": "Fájl helyének megjelenítése", + "show_gallery": "Galéria megjelenítése", + "show_hidden_people": "Rejtett személyek megjelenítése", + "show_in_timeline": "Megjelenítés az idővonalon", + "show_in_timeline_setting_description": "Ettől a felhasználótól származó képek és videók megjelenítése az Ön idővonalán", + "show_keyboard_shortcuts": "Billentyűparancsok megjelenítése", "show_metadata": "Metaadatok mutatása", "show_or_hide_info": "Info mutatása vagy elrejtése", "show_password": "Jelszó mutatása", "show_person_options": "Személy opciók mutatása", - "show_progress_bar": "", + "show_progress_bar": "Haladás megjelenítése", "show_search_options": "Keresési opciók mutatása", + "show_supporter_badge": "Támogató jelvény", + "show_supporter_badge_description": "Támogató jelvény megjelenítése", "shuffle": "Keverés", "sign_out": "Kilépés", "sign_up": "Feliratkozás", @@ -940,61 +1162,99 @@ "slideshow": "Diavetítés", "slideshow_settings": "Diavetítés beállításai", "sort_albums_by": "Albumok rendezése...", + "sort_created": "Létrehozva", + "sort_items": "Elemek száma", + "sort_modified": "Módosítva", + "sort_oldest": "Legrégebbi fénykép", + "sort_recent": "Legújabb fénykép", + "sort_title": "Cím", + "source": "Forrás", "stack": "Fotók csoportosítása", - "stack_selected_photos": "", - "stacktrace": "", - "start_date": "", + "stack_duplicates": "Duplikátumok csoportosítása", + "stack_select_one_photo": "Fő fénykép kiválasztása", + "stack_selected_photos": "Kiválasztott fényképek csoportosítása", + "stacked_assets_count": "{count, plural, other {# elem}} csoportba helyezve", + "stacktrace": "Stacktrace", + "start": "Kezdet", + "start_date": "Kezdet", "state": "Állam", "status": "Állapot", "stop_motion_photo": "Mozgókép megállítása", "stop_photo_sharing": "Fotók megosztásának megszűntetése?", - "storage": "", - "storage_label": "", + "stop_photo_sharing_description": "{partner} mostantól nem fog tudni hozzáférni az Ön fényképeihez.", + "stop_sharing_photos_with_user": "Fényképek megosztásának abbahagyása ezzel a felhasználóval", + "storage": "Tárhely", + "storage_label": "Tárolási címke", "storage_usage": "{used}/{available} használatban", - "submit": "", + "submit": "Beadás", "suggestions": "Javaslatok", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", + "sunrise_on_the_beach": "Napkelte a tengerparton", + "swap_merge_direction": "Egyesítés irányának megfordítása", "sync": "Szinkronizálás", "template": "Minta", "theme": "Téma", "theme_selection": "Témaválasztás", "theme_selection_description": "A böngésző beállításának megfelelően automatikusan használjon világos vagy sötét témát", - "time_based_memories": "", + "they_will_be_merged_together": "Egyesítve lesznek", + "time_based_memories": "Emlékek idő alapján", "timezone": "Időzóna", "to_archive": "Archívum", + "to_change_password": "Jelszó megváltoztatása", "to_favorite": "Kedvenc", + "to_login": "Bejelentkezés", + "to_trash": "Szemétbe helyezés", "toggle_settings": "Beállítások változtatása", "toggle_theme": "Témaváltás", "toggle_visibility": "Láthatóság változtatása", "total_usage": "Összesen használatban", "trash": "Lomtár", "trash_all": "Mindet lomtárba", + "trash_count": "{count, number} elem szemétbe helyezése", + "trash_delete_asset": "Elem szemétbe helyezése / törlése", "trash_no_results_message": "Itt lesznek láthatóak a lomtárba tett képek és videok.", - "type": "", + "trashed_items_will_be_permanently_deleted_after": "A szemeteskosárban lévő elemek véglegesen törlésre kerülnek {days, plural, other {# nap}} múlva.", + "type": "Típus", "unarchive": "Archívumból kivétel", "unarchived": "Archívumból kivett", + "unarchived_count": "{count, plural, other {# elem kivéve az archívumból}}", "unfavorite": "Nem Kedvenc", "unhide_person": "Nem rejtett személy", "unknown": "Ismeretlen", "unknown_album": "Ismeretlen Album", "unknown_year": "Ismeretlen év", "unlimited": "Korlátlan", - "unlink_oauth": "", - "unlinked_oauth_account": "", + "unlink_oauth": "OAuth leválasztása", + "unlinked_oauth_account": "Leválasztott OAuth felhasználó", "unnamed_album": "Névtelen Album", "unnamed_share": "Névtelen Megosztás", + "unsaved_change": "Mentés nélküli változtatás", "unselect_all": "Összes kiválasztás törlése", + "unselect_all_duplicates": "Duplikátumok kijelölésének megszüntetése", "unstack": "Csoport Megszűntetése", + "unstacked_assets_count": "{count, plural, other {# elemből}} álló csoport szétszedve", + "untracked_files": "Nem megfigyelt fájlok", + "untracked_files_decription": "Ezek a fájlok nincsenek az alkalmazás által megfigyelve. Létrehozódhattak sikertelen mozgatástól, félbeszakított feltöltéstől, vagy hátrahagyva hiba miatt", "up_next": "Következik", "updated_password": "Jelszó megváltoztatva", "upload": "Feltöltés", "upload_concurrency": "", - "url": "", - "usage": "", + "upload_errors": "Feltöltés befejezve {count, plural, other {# hibával}}, frissítse az oldalt az újonnan feltöltött elemek megtekintéséhez.", + "upload_progress": "Hátra van {remaining, number} - Feldolgozva {processed, number}/{total, number}", + "upload_skipped_duplicates": "{count, plural, other {# megegyező elem}} kihagyva", + "upload_status_duplicates": "Duplikátumok", + "upload_status_errors": "Hibák", + "upload_status_uploaded": "Feltöltve", + "upload_success": "Feltöltés sikeres, frissítse az oldalt az újonnan feltöltött elemek megtekintéséhez.", + "url": "URL", + "usage": "Felhasználás", + "use_custom_date_range": "Szabadon megadott időintervallum használata", "user": "Felhasználó", "user_id": "Felhasználó azonosítója", - "user_usage_detail": "", + "user_liked": "{type, select, photo {ez a fénykép} video {ez a videó} other {ez}} tetszik neki: {user}", + "user_purchase_settings": "Megvásárlás", + "user_purchase_settings_description": "Vásárlás kezelése", + "user_role_set": "{user} beállítása {role} szerepbe", + "user_usage_detail": "Felhasználó használati adatai", "username": "Felhasználónév", "users": "Felhasználók", "utilities": "Eszközök", @@ -1006,15 +1266,21 @@ "video_hover_setting_description": "Ha az egér a bélyegkép felett időzik, a bélyegkép videó lejátszása induljon el. A lejátszás az indítás ikon feletti időzéssel akkor is elindul, ha ez az opció ki van kapcsolva.", "videos": "Videók", "videos_count": "{count, plural, one {# Videó} other {# Videó}}", + "view": "Nézet", + "view_album": "Album megtekintése", "view_all": "Összes mutatása", - "view_all_users": "", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", + "view_all_users": "Minden felhasználó megtekintése", + "view_links": "Linkek megtekintése", + "view_next_asset": "Következő elem megtekintése", + "view_previous_asset": "Előző elem megtekintése", + "view_stack": "Csoport megtekintése", "viewer": "", - "waiting": "", - "week": "", - "welcome_to_immich": "", + "visibility_changed": "Láthatóság megváltozott {count, plural, other {# személy}} számára", + "waiting": "Várakozás", + "warning": "Figyelmeztetés", + "week": "Hét", + "welcome": "Üdv", + "welcome_to_immich": "Üdvözöljük az Immich-ben", "year": "Év", "years_ago": "{years, plural, one {# évvel} other {# évvel}} ezelőtt", "yes": "Igen", diff --git a/web/src/lib/i18n/id.json b/web/src/lib/i18n/id.json index ac29e27ec38cf..80a8914ceee2a 100644 --- a/web/src/lib/i18n/id.json +++ b/web/src/lib/i18n/id.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Tambahkan ke album terbagi", "added_to_archive": "Ditambahkan ke arsip", "added_to_favorites": "Ditambahkan ke favorit", - "added_to_favorites_count": "Ditambahkan {count} ke favorit", + "added_to_favorites_count": "Ditambahkan {count, number} ke favorit", "admin": { "add_exclusion_pattern_description": "Tambahkan pola pengecualian. Glob menggunakan *, **, dan ? didukung. Untuk mengabaikan semua berkas dalam direktori apa pun bernama \"Raw\", gunakan \"**/Raw/**\". Untuk mengabaikan semua berkas berakhiran dengan \".tif\", gunakan \"**/*.tif\". Untuk mengabaikan jalur absolut, gunakan \"/jalur/untuk/diabaikan/**\".", "authentication_settings": "Pengaturan Autentikasi", @@ -127,12 +127,13 @@ "map_enable_description": "Aktifkan fitur peta", "map_gps_settings": "Pengaturan Peta & GPS", "map_gps_settings_description": "Kelola Pengaturan Peta & GPS (Pengodean Geografis Terbalik)", + "map_implications": "Fitur peta mengandalkan layanan tile eksternal", "map_light_style": "Gaya terang", "map_manage_reverse_geocoding_settings": "Kelola settingan Pengodean Geografis Terbalik", "map_reverse_geocoding": "Pengodean Geografis Terbalik", "map_reverse_geocoding_enable_description": "Aktifkan pengodean geografis terbalik", "map_reverse_geocoding_settings": "Pengaturan Pengodean Geografis Terbalik", - "map_settings": "Pengaturan Peta", + "map_settings": "Peta", "map_settings_description": "Kelola pengaturan peta", "map_style_description": "URL ke tema peta style.json", "metadata_extraction_job": "Ekstrak metadata", @@ -171,7 +172,7 @@ "oauth_issuer_url": "URL Penerbit", "oauth_mobile_redirect_uri": "URI pengalihan ponsel", "oauth_mobile_redirect_uri_override": "Penimpaan URI penerusan ponsel", - "oauth_mobile_redirect_uri_override_description": "Aktifkan ketika 'app.immich:/' adalah URL penerusan yang tidak valid.", + "oauth_mobile_redirect_uri_override_description": "Aktifkan ketika provider OAuth tidak mengizinkan tautan mobile, seperti '{callback}'", "oauth_profile_signing_algorithm": "Algoritma penandatanganan profil", "oauth_profile_signing_algorithm_description": "Algoritma yang digunakan untuk menandatangani profil pengguna.", "oauth_scope": "Cakupan", @@ -275,7 +276,7 @@ "transcoding_preferred_hardware_device": "Perangkat keras yang lebih disukai", "transcoding_preferred_hardware_device_description": "Hanya diterapkan pada VAAPI dan QSV. Menetapkan node dri yang digunakan untuk transkode perangkat keras.", "transcoding_preset_preset": "Prasetel (-preset)", - "transcoding_preset_preset_description": "Kecepatan pengompresan. Prasetel lebih lambat membuat berkas lebih kecil dan meningkatkan kualitas ketika menargetkan kecepatan bit tertentu. VP9 mengabaikan kecepatan di atas `faster`.", + "transcoding_preset_preset_description": "Kecepatan kompresi. Pra setel yang lebih lambat membuat berkas lebih kecil dan meningkatkan kualitas ketika menargetkan kecepatan bit tertentu. VP9 mengabaikan kecepatan di atas `faster`.", "transcoding_reference_frames": "Bingkai referensi", "transcoding_reference_frames_description": "Jumlah bingkai untuk direferensikan ketika mengompres bingkai tertentu. Nilai lebih tinggi meningkatkan efisiensi kompresi, tetapi membuat pengodean lambat. 0 menetapkan nilai ini secara otomatis.", "transcoding_required_description": "Hanya video dalam format yang tidak diterima", @@ -317,7 +318,8 @@ "user_settings": "Pengaturan Pengguna", "user_settings_description": "Kelola pengaturan pengguna", "user_successfully_removed": "Pengguna {email} berhasil dikeluarkan.", - "version_check_enabled_description": "Aktifkan permintaan berkala ke GitHub untuk memeriksa rilis baru", + "version_check_enabled_description": "Aktifkan pemeriksaan versi", + "version_check_implications": "Fitur pemeriksaan versi tergantung komunikasi berkala dengan github.com", "version_check_settings": "Pemeriksaan Versi", "version_check_settings_description": "Aktifkan/nonaktifkan notifikasi versi baru", "video_conversion_job": "Transkode video", @@ -333,7 +335,8 @@ "album_added": "Album ditambahkan", "album_added_notification_setting_description": "Terima notifikasi surel ketika Anda ditambahkan ke album terbagi", "album_cover_updated": "Kover album diperbarui", - "album_delete_confirmation": "Apakah Anda yakin ingin menghapus album {album}?\nJika album ini dibagikan, pengguna lain tidak akan dapat mengaksesnya lagi.", + "album_delete_confirmation": "Apakah Anda yakin ingin menghapus album {album}?", + "album_delete_confirmation_description": "Jika album ini dibagikan, pengguna lain tidak akan dapat mengaksesnya lagi.", "album_info_updated": "Info album diperbarui", "album_leave": "Tinggalkan album?", "album_leave_confirmation": "Apakah Anda yakin ingin keluar dari {album}?", @@ -357,6 +360,7 @@ "allow_edits": "Perbolehkan penyuntingan", "allow_public_user_to_download": "Perbolehkan pengguna publik untuk mengunduh", "allow_public_user_to_upload": "Perbolehkan pengguna publik untuk mengunggah", + "anti_clockwise": "Berlawanan arah jarum jam", "api_key": "Kunci API", "api_key_description": "Nilai ini hanya akan ditampilkan sekali. Pastikan untuk menyalin sebelum menutup jendela ini.", "api_key_empty": "Nama Kunci API Anda seharusnya jangan kosong", @@ -365,7 +369,7 @@ "appears_in": "Muncul dalam", "archive": "Arsip", "archive_or_unarchive_photo": "Arsipkan atau batalkan pengarsipan foto", - "archive_size": "Ukuran Arsip", + "archive_size": "Ukuran arsip", "archive_size_description": "Atur ukuran arsip untuk unduhan (dalam GiB)", "archived": "", "archived_count": "{count, plural, other {# terarsip}}", @@ -407,7 +411,7 @@ "bulk_delete_duplicates_confirmation": "Apakah Anda yakin ingin menghapus {count, plural, one {# aset duplikat} other {# aset duplikat}} secara bersamaan? Ini akan menjaga aset terbesar dari setiap kelompok dan menghapus semua duplikat lain secara permanen. Anda tidak dapat mengurungkan tindakan ini!", "bulk_keep_duplicates_confirmation": "Apakah Anda yakin ingin menyimpan {count, plural, one {# aset duplikat} other {# aset duplikat}}? Ini akan menyelesaikan semua kelompok duplikat tanpa menghapus apa pun.", "bulk_trash_duplicates_confirmation": "Apakah Anda yakin ingin membuang {count, plural, one {# aset duplikat} other {# aset duplikat}} secara bersamaan? Ini akan menyimpan aset terbesar dari setiap kelompok dan membuang semua duplikat lainnya.", - "buy": "Beli Lisensi", + "buy": "Beli Immich", "camera": "Kamera", "camera_brand": "Merek kamera", "camera_model": "Model kamera", @@ -435,11 +439,14 @@ "city": "Kota", "clear": "Hapus", "clear_all": "Hapus semua", + "clear_all_recent_searches": "Hapus semua pencarian terakhir", "clear_message": "Hapus pesan", "clear_value": "Hapus nilai", + "clockwise": "Searah jarum jam", "close": "Tutup", "collapse": "Tutup", "collapse_all": "Tutup Semua", + "color": "Warna", "color_theme": "Tema warna", "comment_deleted": "Komentar dihapus", "comment_options": "Opsi komentar", @@ -473,6 +480,8 @@ "create_new_person": "Buat orang baru", "create_new_person_hint": "Tetapkan aset yang dipilih ke orang yang baru", "create_new_user": "Buat pengguna baru", + "create_tag": "Buat tag", + "create_tag_description": "Buat tag baru. Untuk tag bersarang, harap di input jalur tag secara lengkap termasuk tanda /", "create_user": "Buat pengguna", "created": "Dibuat", "current_device": "Perangkat saat ini", @@ -496,6 +505,7 @@ "delete_library": "Hapus pustaka", "delete_link": "Hapus tautan", "delete_shared_link": "Hapus tautan terbagi", + "delete_tag": "Hapus tag", "delete_user": "Hapus pengguna", "deleted_shared_link": "Tautan terbagi dihapus", "description": "Deskripsi", @@ -513,6 +523,8 @@ "do_not_show_again": "Jangan tampilkan pesan ini lagi", "done": "Selesai", "download": "Unduh", + "download_include_embedded_motion_videos": "Video tersematkan", + "download_include_embedded_motion_videos_description": "Sertakan video yg tersematkan dalam foto gerak sebagai file terpisah", "download_settings": "Pengunduhan", "download_settings_description": "Kelola pengaturan berkaitan dengan pengunduhan aset", "downloading": "Mengunduh", @@ -538,7 +550,11 @@ "edit_title": "Sunting Judul", "edit_user": "Sunting pengguna", "edited": "Disunting", - "editor": "", + "editor": "Editor", + "editor_close_without_save_prompt": "Perubahan tidak akan di simpan", + "editor_close_without_save_title": "Tutup editor?", + "editor_crop_tool_h2_aspect_ratios": "Perbandingan aspek", + "editor_crop_tool_h2_rotation": "Rotasi", "email": "Surel", "empty_trash": "Kosongkan sampah", "empty_trash_confirmation": "Apakah Anda yakin ingin mengosongkan sampah? Ini akan menghapus semua aset dalam sampah secara permanen dari Immich.\nAnda tidak dapat mengurungkan tindakan ini!", @@ -564,6 +580,7 @@ "error_adding_users_to_album": "Terjadi kesalahan menambahkan pengguna ke album", "error_deleting_shared_user": "Terjadi eror menghapus pengguna terbagi", "error_downloading": "Terjadi eror mengunduh {filename}", + "error_hiding_buy_button": "Kesalahan menyembunyikan tombol beli", "error_removing_assets_from_album": "Terjadi eror menghapus aset dari album, lihat konsol untuk detail lebih lanjut", "error_selecting_all_assets": "Terjadi eror memilih semua aset", "exclusion_pattern_already_exists": "Pola pengecualian ini sudah ada.", @@ -574,6 +591,8 @@ "failed_to_get_people": "Gagal mendapatkan orang", "failed_to_load_asset": "Gagal membuka aset", "failed_to_load_assets": "Gagal membuka aset-aset", + "failed_to_load_people": "Gagal mengunggah orang", + "failed_to_remove_product_key": "Gagal menghapus kunci produk", "failed_to_stack_assets": "Gagal menumpuk aset", "failed_to_unstack_assets": "Gagal membatalkan penumpukan aset", "import_path_already_exists": "Jalur pengimporan ini sudah ada.", @@ -675,6 +694,7 @@ "expired": "Kedaluwarsa", "expires_date": "Kedaluwarsa pada {date}", "explore": "Jelajahi", + "explorer": "Jelajah", "export": "Ekspor", "export_as_json": "Ekspor sebagai JSON", "extension": "Ekstensi", @@ -693,6 +713,7 @@ "filter_people": "Saring orang", "find_them_fast": "Temukan dengan cepat berdasarkan nama dengan pencarian", "fix_incorrect_match": "Perbaiki pencocokan salah", + "folders": "Berkas", "force_re-scan_library_files": "Paksa Pindai Ulang Semua Berkas Pustaka", "forward": "Maju", "general": "Umum", @@ -716,7 +737,16 @@ "host": "Hos", "hour": "Jam", "image": "Gambar", - "image_alt_text_date": "pada {date}", + "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} pada tanggal {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1} pada {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1} dan {person2} pada {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1}, {person2}, dan {person3} pada {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1}, {person2}, dan {additionalCount, number} lainnya pada {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} pada {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1} pada {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1} dan {person2} pada {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1}, {person2}, dan {person3} pada {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1}, {person2}, dan {additionalCount, number} lainnya pada {date}", "image_alt_text_people": "{count, plural, =1 {dengan {person1}} =2 {dengan {person1} dan {person2}} =3 {dengan {person1}, {person2}, dan {person3}} other {dengan {person1}, {person2}, dan {others, number} lainnya}}", "image_alt_text_place": "di {city}, {country}", "image_taken": "{isVideo, select, true {Video diambil} other {Gambar diambil}}", @@ -835,6 +865,7 @@ "name": "Nama", "name_or_nickname": "Nama atau nama panggilan", "never": "Tidak pernah", + "new_album": "Album baru", "new_api_key": "Kunci API Baru", "new_password": "Kata sandi baru", "new_person": "Orang baru", @@ -873,12 +904,14 @@ "ok": "Oke", "oldest_first": "Terlawas dahulu", "onboarding": "Memulai", + "onboarding_privacy_description": "Fitur berikut (opsional) bergantung pada layanan eksternal, dan dapat dinonaktifkan kapan saja di pengaturan administrasi.", "onboarding_theme_description": "Pilih tema warna untuk server Anda. Ini dapat diubah lagi dalam pengaturan Anda.", "onboarding_welcome_description": "Mari menyiapkan server Anda dengan beberapa pengaturan umum.", "onboarding_welcome_user": "Selamat datang, {user}", "online": "Daring", "only_favorites": "Hanya favorit", "only_refreshes_modified_files": "Hanya menyegarkan berkas yang diubah", + "open_in_map_view": "Buka dalam tampilan peta", "open_in_openstreetmap": "Buka di OpenStreetMap", "open_the_search_filters": "Buka saringan pencarian", "options": "Opsi", @@ -890,7 +923,7 @@ "other_variables": "Variabel lain", "owned": "Dimiliki", "owner": "Pemilik", - "partner": "Partner", + "partner": "Rekan", "partner_can_access": "{partner} dapat mengakses", "partner_can_access_assets": "Semua foto dan video Anda kecuali yang ada di Arsip dan Terhapus", "partner_can_access_location": "Lokasi di mana foto Anda diambil", @@ -943,10 +976,47 @@ "previous_memory": "Kenangan sebelumnya", "previous_or_next_photo": "Foto sebelumnya atau berikutnya", "primary": "Utama", + "privacy": "Privasi", "profile_image_of_user": "Foto profil dari {user}", "profile_picture_set": "Foto profil ditetapkan.", "public_album": "Album publik", "public_share": "Pembagian Publik", + "purchase_account_info": "Pendukung", + "purchase_activated_subtitle": "Terima kasih telah mendukung Immich dan perangkat lunak sumber terbuka", + "purchase_activated_time": "Di aktivasi pada {date, date}", + "purchase_activated_title": "Kunci kamu telah sukses di aktivasi", + "purchase_button_activate": "Aktifkan", + "purchase_button_buy": "Beli", + "purchase_button_buy_immich": "Beli Immich", + "purchase_button_never_show_again": "Jangan tampilkan lagi", + "purchase_button_reminder": "Ingatkan saya pada 30 hari lagi", + "purchase_button_remove_key": "Hapus kunci", + "purchase_button_select": "Pilih", + "purchase_failed_activation": "Gagal mengaktifkan! Silakan periksa email kamu untuk kunci produk yang benar!", + "purchase_individual_description_1": "Untuk perorangan", + "purchase_individual_description_2": "Status pendukung", + "purchase_individual_title": "Perorangan", + "purchase_input_suggestion": "Punya kunci produk? Masukkan kunci di bawah ini", + "purchase_license_subtitle": "Beli Immich untuk keberlangsungan pengembangan layanan", + "purchase_lifetime_description": "Pembayaran seumur hidup", + "purchase_option_title": "PILIHAN PEMBAYARAN", + "purchase_panel_info_1": "Membangun Immich membutuhkan banyak waktu dan upaya, dan kami memiliki insinyur penuh waktu yang bekerja untuk membuatnya sebaik mungkin. Misi kami adalah agar perangkat lunak sumber terbuka dan praktik bisnis yang beretika menjadi sumber pendapatan yang berkelanjutan bagi para pengembang dan menciptakan ekosistem yang menghargai privasi dengan alternatif nyata untuk layanan cloud yang eksploitatif.", + "purchase_panel_info_2": "Karena kami berkomitmen untuk tidak menambahkan paywall, pembelian ini tidak akan memberi kamu fitur tambahan apa pun di Immich. Kami mengandalkan pengguna seperti kamu untuk mendukung pengembangan Immich yang sedang berlangsung.", + "purchase_panel_title": "Dukung proyek ini", + "purchase_per_server": "Per server", + "purchase_per_user": "Per pengguna", + "purchase_remove_product_key": "Hapus Kunci Produk", + "purchase_remove_product_key_prompt": "Apakah kamu yakin ingin menghapus kunci produk?", + "purchase_remove_server_product_key": "Hapus kunci produk Server", + "purchase_remove_server_product_key_prompt": "Apakah kamu yakin ingin menghapus kunci produk Server?", + "purchase_server_description_1": "Untuk keseluruhan server", + "purchase_server_description_2": "Status pendukung", + "purchase_server_title": "Server", + "purchase_settings_server_activated": "Kunci produk server dikelola oleh admin", + "rating": "Peringkat bintang", + "rating_clear": "Hapus peringkat", + "rating_count": "{count, plural, one {# peringkat} other {# peringkat}}", + "rating_description": "Tampilkan peringkat exif pada panel info", "reaction_options": "Opsi reaksi", "read_changelog": "Baca Log Perubahan", "reassign": "Tetapkan ulang", @@ -989,6 +1059,7 @@ "reset_password": "Atur ulang kata sandi", "reset_people_visibility": "Atur ulang keterlihatan orang", "reset_to_default": "Atur ulang ke bawaan", + "resolve_duplicates": "Mengatasi duplikat", "resolved_all_duplicates": "Semua duplikat terselesaikan", "restore": "Pulihkan", "restore_all": "Pulihkan semua", @@ -1033,6 +1104,7 @@ "see_all_people": "Lihat semua orang", "select_album_cover": "Pilih kover album", "select_all": "Pilih semua", + "select_all_duplicates": "Pilih semua duplikat", "select_avatar_color": "Pilih warna avatar", "select_face": "Pilih wajah", "select_featured_photo": "Pilih foto terfitur", @@ -1065,6 +1137,7 @@ "shared_by_user": "Dibagikan oleh {user}", "shared_by_you": "Dibagikan oleh Anda", "shared_from_partner": "Foto dari {partner}", + "shared_link_options": "Pilihan tautan bersama", "shared_links": "Tautan terbagi", "shared_photos_and_videos_count": "{assetCount, plural, other {# foto & video terbagi.}}", "shared_with_partner": "Dibagikan dengan {partner}", @@ -1073,6 +1146,7 @@ "sharing_sidebar_description": "Tampilkan tautan ke Pembagian dalam bilah samping", "shift_to_permanent_delete": "tekan ⇧ untuk menghapus aset secara permanen", "show_album_options": "Tampilkan opsi album", + "show_albums": "Tampilkan album", "show_all_people": "Tampilkan semua orang", "show_and_hide_people": "Tampilkan & sembunyikan orang", "show_file_location": "Tampilkan lokasi berkas", @@ -1087,6 +1161,8 @@ "show_person_options": "Tampilkan opsi orang", "show_progress_bar": "Tampilkan Bilah Progres", "show_search_options": "Tampilkan opsi pencarian", + "show_supporter_badge": "Lencana suporter", + "show_supporter_badge_description": "Tampilkan lencana suporter", "shuffle": "Acak", "sign_out": "Keluar", "sign_up": "Daftar", @@ -1103,6 +1179,8 @@ "sort_title": "Judul", "source": "Sumber", "stack": "Tumpukan", + "stack_duplicates": "Stack duplikat", + "stack_select_one_photo": "Pilih satu foto utama untuk stack", "stack_selected_photos": "Tumpuk foto terpilih", "stacked_assets_count": "{count, plural, one {# aset} other {# aset}} ditumpuk", "stacktrace": "Jejak tumpukan", @@ -1135,12 +1213,12 @@ "to_login": "Log masuk", "to_trash": "Sampah", "toggle_settings": "Saklar pengaturan", - "toggle_theme": "Saklar tema", + "toggle_theme": "Beralih tema gelap", "toggle_visibility": "Saklar keterlihatan", "total_usage": "Jumlah penggunaan", "trash": "Sampah", "trash_all": "Buang Semua", - "trash_count": "Buang {count}", + "trash_count": "Sampah {count, number}", "trash_delete_asset": "Hapus Aset", "trash_no_results_message": "Foto dan video di sampah akan muncul di sini.", "trashed_items_will_be_permanently_deleted_after": "Item yang dibuang akan dihapus secara permanen setelah {days, plural, one {# hari} other {# hari}}.", @@ -1156,9 +1234,11 @@ "unlink_oauth": "Putuskan OAuth", "unlinked_oauth_account": "Akun OAuth terputus", "unnamed_album": "Album Tanpa Nama", + "unnamed_album_delete_confirmation": "Apakah kamu yakin akan menghapus album ini?", "unnamed_share": "Pembagian Tanpa Nama", "unsaved_change": "Perubahan belum disimpan", "unselect_all": "Batalkan semua pilihan", + "unselect_all_duplicates": "Batal pilih semua duplikat", "unstack": "Batalkan penumpukan", "unstacked_assets_count": "Penumpukan {count, plural, one {# aset} other {# aset}} dibatalkan", "untracked_files": "Berkas tidak dilacak", @@ -1168,7 +1248,7 @@ "upload": "Unggah", "upload_concurrency": "Konkurensi pengunggahan", "upload_errors": "Unggahan selesai dengan {count, plural, one {# eror} other {# eror}}, muat ulang laman untuk melihat aset terunggah baru.", - "upload_progress": "Tersisa {remaining} - Memproses {processed}/{total}", + "upload_progress": "Tersisa {remaining, number} - Di proses {processed, number}/{total, number}", "upload_skipped_duplicates": "Melewati {count, plural, one {# aset duplikat} other {# aset duplikat}}", "upload_status_duplicates": "Duplikat", "upload_status_errors": "Eror", @@ -1181,7 +1261,9 @@ "user_id": "ID Pengguna", "user_license_settings": "Lisensi", "user_license_settings_description": "Kelola lisensi Anda", - "user_liked": "{user} menyukai {type, select, photo {foto ini} video {video ini} asset {aset ini} other {ini}}", + "user_liked": "{user} menyukai {type, select, photo {foto ini} video {tayangan ini} asset {aset ini} other {ini}}", + "user_purchase_settings": "Pembelian", + "user_purchase_settings_description": "Atur pembelian kamu", "user_role_set": "Tetapkan {user} sebagai {role}", "user_usage_detail": "Detail penggunaan pengguna", "username": "Nama pengguna", diff --git a/web/src/lib/i18n/it.json b/web/src/lib/i18n/it.json index 86c0079e96eea..486f2dfaa620e 100644 --- a/web/src/lib/i18n/it.json +++ b/web/src/lib/i18n/it.json @@ -49,7 +49,7 @@ "external_library_created_at": "Libreria esterna (creata il {date})", "external_library_management": "Gestione Librerie Esterne", "face_detection": "Rilevamento Volti", - "face_detection_description": "Rileva i volti presenti negli assets utilizzando il machine learning. Per i video, viene presa in considerazione solo la miniatura. \"Tutto\" (ri-)processerà tutti gli assets. \"Mancanti\" selaziona solo gli assets che non sono ancora stati processati. I volti rilevati verranno selezionati per il riconoscimento facciale dopo che il rilevamento dei volti sarà stato completato, raggruppandoli in persone esistenti e/o nuove.", + "face_detection_description": "Rileva i volti presenti negli assets utilizzando il machine learning. Per i video, viene presa in considerazione solo la miniatura. \"Tutto\" (ri-)processerà tutti gli assets. \"Mancanti\" seleziona solo gli assets che non sono ancora stati processati. I volti rilevati verranno selezionati per il riconoscimento facciale dopo che il rilevamento dei volti sarà stato completato, raggruppandoli in persone esistenti e/o nuove.", "facial_recognition_job_description": "Raggruppa i volti rilevati in persone. Questo processo viene eseguito dopo che il rilevamento volti è stato completato. \"Tutti\" (ri-)unisce tutti i volti. \"Mancanti\" processa i volti che non hanno una persona assegnata.", "failed_job_command": "Il comando {command} è fallito per il processo: {job}", "force_delete_user_warning": "ATTENZIONE: Questo rimuoverà immediatamente l'utente e tutti i suoi assets. Non è possibile tornare indietro e i file non potranno essere recuperati.", @@ -68,7 +68,7 @@ "image_settings_description": "Gestisci qualità e risoluzione delle immagini generate", "image_thumbnail_format": "Formato miniatura", "image_thumbnail_resolution": "Risoluzione miniatura", - "image_thumbnail_resolution_description": "Utilizzato per vedere gruppi di foto (linea temporale,vista album, etc.). Risoluzioni piu' alte possono mantenere piu' dettaglio pero' l'encoding sara' piu' lungo, i file avranno dimensioni maggiori e potrebbero causare una riduzione nella responsivita' dell'applicazione.", + "image_thumbnail_resolution_description": "Utilizzato per vedere gruppi di foto (linea temporale, vista album, etc.). Risoluzioni più alte possono mantenere più dettaglio però l'encoding sarà più lungo, i file avranno dimensioni maggiori e potrebbero causare una riduzione nella responsività dell'applicazione.", "job_concurrency": "Concorrenza {job}", "job_not_concurrency_safe": "Questo processo non è eseguibile in maniera concorrente.", "job_settings": "Impostazioni dei processi", @@ -76,14 +76,14 @@ "job_status": "Stato Processi", "jobs_delayed": "{jobCount, plural, one {# posticipato} other {# posticipati}}", "jobs_failed": "{jobCount, plural, one {# fallito} other {# falliti}}", - "library_created": "Creata libreria {library}", + "library_created": "Creata libreria: {library}", "library_cron_expression": "Espressione cron", "library_cron_expression_description": "Imposta l'intervallo di rilevazione utilizzando il formato cron. Per più informazioni consulta es. Crontab Guru", "library_cron_expression_presets": "Espressioni cron preimpostate", "library_deleted": "Libreria eliminata", "library_import_path_description": "Specifica una cartella da importare. Questa cartella e le sue sottocartelle, verranno analizzate per cercare immagini e video.", "library_scanning": "Scansione periodica", - "library_scanning_description": "Conigura la scansione periodica della libreria", + "library_scanning_description": "Configura la scansione periodica della libreria", "library_scanning_enable_description": "Attiva la scansione periodica della libreria", "library_settings": "Libreria Esterna", "library_settings_description": "Gestisci le impostazioni della libreria esterna", @@ -95,7 +95,7 @@ "logging_level_description": "Quando attivato, che livello di log utilizzare.", "logging_settings": "Registro dei Log", "machine_learning_clip_model": "Modello CLIP", - "machine_learning_clip_model_description": "Il nome del modello CLIP mostrato qui. Bita cge devi rieseguire il processo 'Ricerca Intelligente' per tutte le immagini al cambio del modello.", + "machine_learning_clip_model_description": "Il nome del modello CLIP mostrato qui. Nota che devi rieseguire il processo 'Ricerca Intelligente' per tutte le immagini al cambio del modello.", "machine_learning_duplicate_detection": "Rilevamento Duplicati", "machine_learning_duplicate_detection_enabled": "Attiva rilevazione duplicati", "machine_learning_duplicate_detection_enabled_description": "Se disattivo, risorse perfettamente identiche saranno comunque deduplicate.", @@ -103,16 +103,16 @@ "machine_learning_enabled": "Attiva machine learning", "machine_learning_enabled_description": "Se disabilitato, tutte le funzioni di ML saranno disabilitate ignorando le importazioni sottostanti.", "machine_learning_facial_recognition": "Riconoscimento Facciale", - "machine_learning_facial_recognition_description": "Rileva, riconosci, e raggruppa faccie nelle immagini", + "machine_learning_facial_recognition_description": "Rileva, riconosci, e raggruppa facce nelle immagini", "machine_learning_facial_recognition_model": "Modello di riconoscimento facciale", - "machine_learning_facial_recognition_model_description": "I modelli sono mostrati in ordine decrescente in base alla dimensione. I modelli più grandi sono più lenti e utilizzano più memoria, peró producono risultati migliori. Nota che devi ri-eseguire il processo di rilevamento facciale per tutte le immagini quando cambi il modello.", + "machine_learning_facial_recognition_model_description": "I modelli sono mostrati in ordine decrescente in base alla dimensione. I modelli più grandi sono più lenti e utilizzano più memoria, però producono risultati migliori. Nota che devi ri-eseguire il processo di rilevamento facciale per tutte le immagini quando cambi il modello.", "machine_learning_facial_recognition_setting": "Attiva riconoscimento facciale", - "machine_learning_facial_recognition_setting_description": "Se disabilitato, le immagininon non saranno codificate per il riconoscimento facciale e non verranno mostrate nella sezione Persone della pagina Esplora.", + "machine_learning_facial_recognition_setting_description": "Se disabilitato, le immagini non saranno codificate per il riconoscimento facciale e non verranno mostrate nella sezione Persone della pagina Esplora.", "machine_learning_max_detection_distance": "Distanza massima di rilevazione", - "machine_learning_max_detection_distance_description": "Massima distanza fra due immagini per considerarle duplicate, variando da 0.001-0.1. Valori più alti rileveranno più duplicati, ma potrebbero causare risultati fasulli.", + "machine_learning_max_detection_distance_description": "Massima distanza fra due immagini per considerarle duplicate, variando da 0.001-0.1. Valori più alti rileveranno più duplicati, ma potrebbero causare falsi positivi.", "machine_learning_max_recognition_distance": "Distanza massima di riconoscimento", "machine_learning_max_recognition_distance_description": "La distanza massima tra due volti per essere considerati la stessa persona, che varia da 0 a 2. Abbassare questo valore può prevenire l'etichettatura di due persone come se fossero la stessa persona, mentre aumentarlo può prevenire l'etichettatura della stessa persona come se fossero due persone diverse. Nota che è più facile unire due persone che separare una persona in due, quindi è preferibile mantenere una soglia più bassa quando possibile.", - "machine_learning_min_detection_score": "Punteggio minimo di rilvazione", + "machine_learning_min_detection_score": "Punteggio minimo di rilevazione", "machine_learning_min_detection_score_description": "Punteggio di confidenza minimo per rilevare un volto, da 0 a 1. Valori più bassi rileveranno più volti, ma potrebbero generare risultati fasulli.", "machine_learning_min_recognized_faces": "Minimo volti rilevati", "machine_learning_min_recognized_faces_description": "Il numero minimo di volti riconosciuti per creare una persona. Aumentando questo valore si rende il riconoscimento facciale più preciso, ma aumenta la possibilità che un volto non venga assegnato a una persona.", @@ -129,7 +129,7 @@ "map_enable_description": "Abilita funzionalità della mappa", "map_gps_settings": "Impostazioni Mappe & GPS", "map_gps_settings_description": "Gestisci le impostazioni di Mappe & GPS (Geocoding Inverso)", - "map_implications": "La fnzione della mappa fa uso di un servizio tile esterno (tiles.immich.cloud)", + "map_implications": "La funzionalità mappa si basa su un servizio tile esterno (tiles.immich.cloud)", "map_light_style": "Tema chiaro", "map_manage_reverse_geocoding_settings": "Gestisci impostazioni Geocodifica inversa", "map_reverse_geocoding": "Geocodifica inversa", @@ -225,7 +225,7 @@ "storage_template_hash_verification_enabled_description": "Attiva verifica hash, non disabilitare questo se non sei certo delle implicazioni", "storage_template_migration": "Migrazione modello archiviazione", "storage_template_migration_description": "Applica il {template} attuale agli asset caricati in precedenza", - "storage_template_migration_info": "Le modifiche al modello di archiviazione verranno applicate solo agli asset nuovi. Per applicare le modifice retroattivamente esegui {job}.", + "storage_template_migration_info": "Le modifiche al modello di archiviazione verranno applicate solo agli asset nuovi. Per applicare le modifiche retroattivamente esegui {job}.", "storage_template_migration_job": "Processo Migrazione Modello di Archiviazione", "storage_template_more_details": "Per più informazioni riguardo a questa funzionalità, consulta il Modello Archiviazione e le sue conseguenze", "storage_template_onboarding_description": "Quando attivata, questa funzionalità organizzerà automaticamente i file utilizzando il modello di archiviazione definito dall'utente. Per ragioni di stabilità, questa funzionalità è disabilitata per impostazione predefinita. Per più informazioni, consulta la documentazione.", @@ -260,7 +260,7 @@ "transcoding_bitrate_description": "Video con bitrate superiore al massimo o in formato non accettato", "transcoding_codecs_learn_more": "Per saperne di più sulla terminologia utilizzata, fai riferimento alla documentazione di FFmpeg su codec H.264, codec HEVC e codec VP9.", "transcoding_constant_quality_mode": "Modalità qualità costante", - "transcoding_constant_quality_mode_description": "iCQ è migliore di CQP, peró alcuni dispositivi di accelerazione hardware non supportano questa modalità. Impostando questa opzione l'applicazione preferirà il modo specificato quando è in uso la codifica quality-based. Ignorato da NVENC perchè non supporta ICQ.", + "transcoding_constant_quality_mode_description": "iCQ è migliore di CQP, però alcuni dispositivi di accelerazione hardware non supportano questa modalità. Impostando questa opzione l'applicazione preferirà il modo specificato quando è in uso la codifica quality-based. Ignorato da NVENC perché non supporta ICQ.", "transcoding_constant_rate_factor": "Fattore di rateo costante (-crf)", "transcoding_constant_rate_factor_description": "Livello di qualità video. I valori tipici sono 23 per H.264, 28 per HEVC, 31 per VP9 e 35 per AV1. Un valore inferiore indica una qualità migliore, ma produce file di dimensioni maggiori.", "transcoding_disabled_description": "Non transcodificare alcun video, potrebbe rompere la riproduzione su alcuni client", @@ -274,12 +274,12 @@ "transcoding_max_bitrate": "Bitrate massimo", "transcoding_max_bitrate_description": "Impostare un bitrate massimo può rendere le dimensioni dei file più prevedibili a un costo minore per la qualità. A 720p, i valori tipici sono 2600k per VP9 o HEVC, o 4500k per H.264. Disabilitato se impostato su 0.", "transcoding_max_keyframe_interval": "Intervallo massimo dei keyframe", - "transcoding_max_keyframe_interval_description": "Imposta la distanza massima tra i keyframe. Valori più bassi peggiorano l'efficienza di compressione, peró migliorano i tempi di ricerca e possono migliorare la qualità nelle scene con movimenti rapidi. 0 imposta questo valore automaticamente.", + "transcoding_max_keyframe_interval_description": "Imposta la distanza massima tra i keyframe. Valori più bassi peggiorano l'efficienza di compressione, però migliorano i tempi di ricerca e possono migliorare la qualità nelle scene con movimenti rapidi. 0 imposta questo valore automaticamente.", "transcoding_optimal_description": "Video con risoluzione più alta rispetto alla risoluzione desiderata o in formato non accettato", "transcoding_preferred_hardware_device": "Dispositivo hardware preferito", "transcoding_preferred_hardware_device_description": "Si applica solo a VAAPI e QSV. Imposta il nodo DRI utilizzato per la transcodifica hardware.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Velocità di compressione. Presets più lenti producono file più piccoli e aumentano la qualità quando si punta a ottenere un certo bitrate. VP9 ignora velocità superiori a `faster`.", + "transcoding_preset_preset_description": "Velocità di compressione. Preset più lenti producono file più piccoli e aumentano la qualità quando viene impostato un certo bitrate. VP9 ignora velocità superiori a `faster`.", "transcoding_reference_frames": "Frame di riferimento", "transcoding_reference_frames_description": "Il numero di frame da prendere in considerazione nel comprimere un determinato frame. Valori più alti migliorano l'efficienza di compressione, ma rallentano la codifica. 0 imposta questo valore automaticamente.", "transcoding_required_description": "Solo video che non sono in un formato accettato", @@ -288,7 +288,7 @@ "transcoding_target_resolution": "Risoluzione desiderata", "transcoding_target_resolution_description": "Risoluzioni più elevate possono preservare più dettagli ma richiedono più tempo per la codifica, producono file di dimensioni maggiori e possono ridurre la reattività dell'applicazione.", "transcoding_temporal_aq": "AQ temporale", - "transcoding_temporal_aq_description": "Si applica solo a NVENC. Aumenta la qualita delle scene con molto dettaglio e poco movimento. Potrebbe non essere compatibile con dispositivi più vecchi.", + "transcoding_temporal_aq_description": "Si applica solo a NVENC. Aumenta la qualità delle scene con molto dettaglio e poco movimento. Potrebbe non essere compatibile con dispositivi più vecchi.", "transcoding_threads": "Thread", "transcoding_threads_description": "Valori più alti portano a una codifica più veloce, ma lasciano meno spazio al server per elaborare altre attività durante l'attività. Questo valore non dovrebbe essere superiore al numero di core CPU. Massimizza l'utilizzo se impostato su 0.", "transcoding_tone_mapping": "Mappatura della tonalità", @@ -310,14 +310,14 @@ "untracked_files_description": "Questi file non sono tracciati dall'applicazione. Potrebbero essere il risultato di spostamenti falliti, caricamenti interrotti o abbandonati a causa di un bug", "user_delete_delay": "L'account e gli asset dell'utente {user} verranno programmati per la cancellazione definitiva tra {delay, plural, one {# giorno} other {# giorni}}.", "user_delete_delay_settings": "Ritardo eliminazione", - "user_delete_delay_settings_description": "Numero di giorni dopo l'eliminazione per cancellare in modo definitivo l'account e gli asset di un utente. Il processo di cancellazione dell'utente viene eseguito a mezzanotte per verificare se esistono utenti pronti a essere eliminati. Le modifiche a questa impostazioni saranno prese in considerazione dalla possima esecuzione.", + "user_delete_delay_settings_description": "Numero di giorni dopo l'eliminazione per cancellare in modo definitivo l'account e gli asset di un utente. Il processo di cancellazione dell'utente viene eseguito a mezzanotte per verificare se esistono utenti pronti a essere eliminati. Le modifiche a questa impostazioni saranno prese in considerazione dalla prossima esecuzione.", "user_delete_immediately": "L'account e tutti gli asset dell'utente {user} verranno messi in coda per la cancellazione permanente immediata.", "user_delete_immediately_checkbox": "utente", "user_management": "Gestione Utenti", "user_password_has_been_reset": "La password dell'utente è stata reimpostata:", "user_password_reset_description": "Per favore inserisci una password temporanea per l'utente e informalo che dovrà cambiare la password al prossimo login.", "user_restore_description": "L'account di {user} verrà ripristinato.", - "user_restore_scheduled_removal": "Ripristina utente - rimozione progammata per il {date, date, long}", + "user_restore_scheduled_removal": "Ripristina utente - rimozione programmata per il {date, date, long}", "user_settings": "Impostazione Utente", "user_settings_description": "Gestisci impostazioni utente", "user_successfully_removed": "L'utente {email} è stato rimosso con successo.", @@ -362,6 +362,7 @@ "allow_edits": "Permetti modifiche", "allow_public_user_to_download": "Permetti di scaricare agli utenti pubblici", "allow_public_user_to_upload": "Permetti di caricare agli utenti pubblici", + "anti_clockwise": "Senso antiorario", "api_key": "Chiave API", "api_key_description": "Il campo verrà mostrato solo una volta. Abbi cura di copiarlo prima di chiudere la finestra.", "api_key_empty": "Il valore del nome dell'API Key non può essere vuoto", @@ -370,7 +371,7 @@ "appears_in": "Compare in", "archive": "Archivio", "archive_or_unarchive_photo": "Archivia o ripristina foto", - "archive_size": "Dimensioni Archivio", + "archive_size": "Dimensioni archivio", "archive_size_description": "Imposta le dimensioni dell'archivio per i download (in GiB)", "archived": "Archiviato", "archived_count": "{count, plural, other {Archiviati #}}", @@ -443,6 +444,7 @@ "clear_all_recent_searches": "Rimuovi tutte le ricerche recenti", "clear_message": "Pulisci messaggio", "clear_value": "Pulisci valore", + "clockwise": "Senso orario", "close": "Chiudi", "collapse": "Restringi", "collapse_all": "Comprimi tutto", @@ -455,7 +457,7 @@ "confirm_admin_password": "Conferma password amministratore", "confirm_delete_shared_link": "Sei sicuro di voler eliminare questo link condiviso?", "confirm_password": "Conferma password", - "contain": "Contieni", + "contain": "Adatta", "context": "Contesto", "continue": "Continua", "copied_image_to_clipboard": "Immagine copiata negli appunti.", @@ -468,7 +470,7 @@ "copy_password": "Copia password", "copy_to_clipboard": "Copia negli appunti", "country": "Nazione", - "cover": "Copri", + "cover": "Riempi", "covers": "Miniature", "create": "Crea", "create_album": "Crea album", @@ -519,8 +521,10 @@ "do_not_show_again": "Non mostrare questo messaggio di nuovo", "done": "Fatto", "download": "Scarica", + "download_include_embedded_motion_videos": "Video incorporati", + "download_include_embedded_motion_videos_description": "Includere i video incorporati nelle foto in movimento come file separato", "download_settings": "Scarica", - "download_settings_description": "Gestisci le impostazioni riguardandi il download degli asset", + "download_settings_description": "Gestisci le impostazioni relative al download degli asset", "downloading": "Scaricando", "downloading_asset_filename": "Scaricando l'asset {filename}", "drop_files_to_upload": "Rilascia i file ovunque per caricarli", @@ -552,6 +556,10 @@ "edit_user": "Modifica utente", "edited": "Modificato", "editor": "Editor", + "editor_close_without_save_prompt": "Le modifiche non verranno salvate", + "editor_close_without_save_title": "Vuoi chiudere l'editor?", + "editor_crop_tool_h2_aspect_ratios": "Proporzioni", + "editor_crop_tool_h2_rotation": "Rotazione", "email": "Email", "empty": "", "empty_album": "Album Vuoto", @@ -643,7 +651,7 @@ "unable_to_hide_person": "Impossibile nascondere persona", "unable_to_link_oauth_account": "Impossibile collegare l'account OAuth", "unable_to_load_album": "Impossibile caricare l'album", - "unable_to_load_asset_activity": "Impossiible caricare l'attività dell'asset", + "unable_to_load_asset_activity": "Impossibile caricare l'attività dell'asset", "unable_to_load_items": "Impossibile caricare gli elementi", "unable_to_load_liked_status": "Impossibile caricare lo stato dei preferiti", "unable_to_log_out_all_devices": "Impossibile eseguire il logout da tutti i dispositivi", @@ -663,7 +671,7 @@ "unable_to_remove_reaction": "Impossibile rimuovere reazione", "unable_to_remove_user": "", "unable_to_repair_items": "Impossibile riparare elementi", - "unable_to_reset_password": "Impossiible reimpostare la password", + "unable_to_reset_password": "Impossibile reimpostare la password", "unable_to_resolve_duplicate": "Impossibile risolvere duplicato", "unable_to_restore_assets": "Impossibile ripristinare gli asset", "unable_to_restore_trash": "Impossibile ripristinare cestino", @@ -671,23 +679,23 @@ "unable_to_save_album": "Impossibile salvare album", "unable_to_save_api_key": "Impossibile salvare chiave API", "unable_to_save_date_of_birth": "Impossible salvare la data di nascita", - "unable_to_save_name": "Impossibile salvare nome", - "unable_to_save_profile": "Impossibile salvare profilo", - "unable_to_save_settings": "Impossibile salvare impostazioni", - "unable_to_scan_libraries": "Impossibile analizzare librerie", - "unable_to_scan_library": "Impossibile analizzare libreria", + "unable_to_save_name": "Impossibile salvare il nome", + "unable_to_save_profile": "Impossibile salvare il profilo", + "unable_to_save_settings": "Impossibile salvare le impostazioni", + "unable_to_scan_libraries": "Impossibile analizzare le librerie", + "unable_to_scan_library": "Impossibile analizzare la libreria", "unable_to_set_feature_photo": "Impossibile impostare la foto in evidenza", - "unable_to_set_profile_picture": "Impossibile impostare foto profilo", - "unable_to_submit_job": "Impossibile confermare processo", - "unable_to_trash_asset": "Impossibile cestinare asset", - "unable_to_unlink_account": "Impossibile scollegare account", + "unable_to_set_profile_picture": "Impossibile impostare la foto profilo", + "unable_to_submit_job": "Impossibile eseguire l'attività", + "unable_to_trash_asset": "Impossibile cestinare l'asset", + "unable_to_unlink_account": "Impossibile scollegare l'account", "unable_to_update_album_cover": "Errore durante l'aggiornamento della copertina dell'album", - "unable_to_update_album_info": "Errore durante l'aggiornamento delle info dell'album", - "unable_to_update_library": "Impossibile aggiornare libreria", - "unable_to_update_location": "Impossibile aggiornare posizione", - "unable_to_update_settings": "Impossibile aggiornare impostazioni", - "unable_to_update_timeline_display_status": "Impossibile aggiornare lo stato visivo della linea temporale", - "unable_to_update_user": "Impossibile aggiornare utente", + "unable_to_update_album_info": "Impossibile aggiornare le informazioni sull'album", + "unable_to_update_library": "Impossibile aggiornare la libreria", + "unable_to_update_location": "Impossibile aggiornare la posizione", + "unable_to_update_settings": "Impossibile aggiornare le impostazioni", + "unable_to_update_timeline_display_status": "Impossibile aggiornare lo stato di visualizzazione della sequenza temporale", + "unable_to_update_user": "Impossibile aggiornare l'utente", "unable_to_upload_file": "Impossibile caricare il file" }, "every_day_at_onepm": "", @@ -695,7 +703,7 @@ "every_night_at_twoam": "", "every_six_hours": "", "exif": "Exif", - "exit_slideshow": "Esci dalla diapositiva", + "exit_slideshow": "Esci dalla presentazione", "expand_all": "Espandi tutto", "expire_after": "Scade dopo", "expired": "Scaduto", @@ -745,16 +753,16 @@ "host": "Host", "hour": "Ora", "image": "Immagine", - "image_alt_text_date": "{isVideo, select, true {Video} other {Immagine}} scattato il {date}", - "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} scattata con {person1} il giorno {date}", - "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} scattata con {person1} e {person2} il giorno {date}", - "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} scattata con {person1}, {person2}, e {person3} il giorno {date}", - "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} scattata con {person1}, {person2}, e altre {additionalCount, number} persone il giorno {date}", - "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} il giorno {date}", - "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} con {person1} il giorno {date}", - "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} con {person1} e {person2} il giorno {date}", - "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} con {person1}, {person2}, e {person3} il giorno {date}", - "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Immagine}} scattato a {city}, {country} con {person1}, {person2} e {additionalCount, number} altre persone il {date}", + "image_alt_text_date": "{isVideo, select, true {Video girato} other {Foto scattata}} il {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1} il giorno {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1} e {person2} il giorno {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1}, {person2}, e {person3} il giorno {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1}, {person2}, e altre {additionalCount, number} persone il giorno {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} il giorno {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1} il giorno {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1} e {person2} il giorno {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1}, {person2}, e {person3} il giorno {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1}, {person2} e {additionalCount, number} altre persone il {date}", "image_alt_text_people": "{count, plural, =1 {con {person1}} =2 {con {person1} e {person2}} =3 {con {person1}, {person2} e {person3}} other {con {person1}, {person2} e {others, number} altri}}", "image_alt_text_place": "a {city}, {country}", "image_taken": "{isVideo, select, true {Video registrato} other {Immagine scattata}}", @@ -783,7 +791,7 @@ "jobs": "Processi", "keep": "Mantieni", "keep_all": "Tieni tutto", - "keyboard_shortcuts": "Comandi rapidi", + "keyboard_shortcuts": "Scorciatoie da tastiera", "language": "Lingua", "language_setting_description": "Seleziona la tua lingua predefinita", "last_seen": "Ultimo accesso", @@ -828,7 +836,7 @@ "loading": "Caricamento", "loading_search_results_failed": "Impossibile caricare i risultati della ricerca", "log_out": "Esci", - "log_out_all_devices": "Esci da tutti i dispositivi", + "log_out_all_devices": "Disconnetti tutti i dispositivi", "logged_out_all_devices": "Disconnesso da tutti i dispositivi", "logged_out_device": "Disconnesso dal dispositivo", "login": "Login", @@ -846,7 +854,7 @@ "manage_your_account": "Gestisci il tuo account", "manage_your_api_keys": "Gestisci le tue chiavi API", "manage_your_devices": "Gestisci i tuoi dispositivi collegati", - "manage_your_oauth_connection": "Gestisci la tua connesione OAuth", + "manage_your_oauth_connection": "Gestisci la tua connessione OAuth", "map": "Mappa", "map_marker_for_images": "Indicatore mappa per le immagini scattate in {city}, {country}", "map_marker_with_image": "Segnaposto con immagine", @@ -863,7 +871,7 @@ "merge_people_limit": "Puoi unire al massimo 5 volti alla volta", "merge_people_prompt": "Vuoi unire queste persone? Questa azione è irreversibile.", "merge_people_successfully": "Unione persone completata con successo", - "merged_people_count": "Uniti {count, plural, one {# persona} other {# persone}}", + "merged_people_count": "{count, plural, one {Unita # persona} other {Unite # persone}}", "minimize": "Minimizza", "minute": "Minuto", "missing": "Mancante", @@ -886,8 +894,8 @@ "next_memory": "Prossima memoria", "no": "No", "no_albums_message": "Crea un album per organizzare le tue foto ed i tuoi video", - "no_albums_with_name_yet": "Nessun album con questo nome, per ora.", - "no_albums_yet": "Nessun album presente, per ora.", + "no_albums_with_name_yet": "Sembra che tu non abbia ancora nessun album con questo nome.", + "no_albums_yet": "Sembra che tu non abbia ancora nessun album.", "no_archived_assets_message": "Archivia foto e video per nasconderli dalla galleria di foto", "no_assets_message": "CLICCA PER CARICARE LA TUA PRIMA FOTO", "no_duplicates_found": "Nessun duplicato trovato.", @@ -914,9 +922,9 @@ "ok": "Ok", "oldest_first": "Prima vecchi", "onboarding": "Inserimento", - "onboarding_privacy_description": "Le seguenti funzioni (opzionali) fanno uso di servizi esterni, e possono essere disabilitate in qualsiasi momento dalle impostazioni d'amministratore.", + "onboarding_privacy_description": "Le seguenti funzioni (opzionali) fanno uso di servizi esterni, e possono essere disabilitate in qualsiasi momento nelle impostazioni di amministrazione.", "onboarding_theme_description": "Scegli un tema colore per la tua istanza. Potrai cambiarlo nelle impostazioni.", - "onboarding_welcome_description": "Andiamo ad impostare la tua istanza con alcuni settaggi comuni.", + "onboarding_welcome_description": "Andiamo ad impostare la tua istanza con alcune impostazioni comuni.", "onboarding_welcome_user": "Benvenuto, {user}", "online": "Online", "only_favorites": "Solo preferiti", @@ -956,7 +964,7 @@ "pending": "In attesa", "people": "Persone", "people_edits_count": "{count, plural, one {Modificata # persona} other {Modificate # persone}}", - "people_sidebar_description": "Mosta un link alle persone nella barra laterale", + "people_sidebar_description": "Mostra un link alle persone nella barra laterale", "perform_library_tasks": "", "permanent_deletion_warning": "Avviso eliminazione permanente", "permanent_deletion_warning_setting_description": "Mostra un avviso all'eliminazione definitiva di un asset", @@ -1046,10 +1054,10 @@ "refreshing_metadata": "Ricaricando i metadati", "regenerating_thumbnails": "Rigenerando le anteprime", "remove": "Rimuovi", - "remove_assets_album_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# assets}} dall'album?", - "remove_assets_shared_link_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# assets}} da questo link condiviso?", + "remove_assets_album_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# asset}} dall'album?", + "remove_assets_shared_link_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# asset}} da questo link condiviso?", "remove_assets_title": "Rimuovere asset?", - "remove_custom_date_range": "Cancella intervallo data personalizzato", + "remove_custom_date_range": "Rimuovi intervallo data personalizzato", "remove_from_album": "Rimuovere dall'album", "remove_from_favorites": "Rimuovi dai preferiti", "remove_from_shared_link": "Rimuovi dal link condiviso", @@ -1058,7 +1066,7 @@ "removed_api_key": "Rimossa chiave API: {name}", "removed_from_archive": "Rimosso dall'archivio", "removed_from_favorites": "Rimosso dai preferiti", - "removed_from_favorites_count": "{count, plural, other {Rimossi #}} dai preferiti", + "removed_from_favorites_count": "{count, plural, one {Rimosso } other {Rimossi #}} dai preferiti", "rename": "Rinomina", "repair": "Ripara", "repair_no_results_message": "I file mancanti e non tracciati saranno mostrati qui", @@ -1089,7 +1097,7 @@ "saved_settings": "Impostazioni salvate", "say_something": "Dici qualcosa", "scan_all_libraries": "Analizza tutte le librerie", - "scan_all_library_files": "Ri-analizza Tutti i File della Libreria", + "scan_all_library_files": "Scansiona nuovamente tutti i file della libreria", "scan_new_library_files": "Analizza i File Nuovi della Libreria", "scan_settings": "Impostazioni Analisi", "scanning_for_album": "Sto cercando l'album...", @@ -1098,7 +1106,7 @@ "search_by_context": "Cerca con contesto", "search_by_filename": "Cerca per nome del file o estensione", "search_by_filename_example": "es. IMG_1234.JPG o PNG", - "search_camera_make": "Cerca manufattore fotocamera...", + "search_camera_make": "Cerca produttore fotocamera...", "search_camera_model": "Cerca modello fotocamera...", "search_city": "Cerca città...", "search_country": "Cerca paese...", @@ -1109,7 +1117,7 @@ "search_places": "Cerca luoghi", "search_state": "Cerca stato...", "search_timezone": "Cerca fuso orario...", - "search_type": "Certa tipo", + "search_type": "Cerca tipo", "search_your_photos": "Cerca le tue foto", "searching_locales": "Cerca localizzazioni...", "second": "Secondo", @@ -1127,7 +1135,7 @@ "select_photos": "Seleziona foto", "select_trash_all": "Seleziona cestina tutto", "selected": "Selezionato", - "selected_count": "{count, plural, other {# selezionati}}", + "selected_count": "{count, plural, one {# selezionato} other {# selezionati}}", "send_message": "Manda messaggio", "send_welcome_email": "Invia email di benvenuto", "server": "Server", @@ -1140,7 +1148,7 @@ "set_as_profile_picture": "Imposta come foto profilo", "set_date_of_birth": "Imposta data di nascita", "set_profile_picture": "Imposta foto profilo", - "set_slideshow_to_fullscreen": "Imposta diapositiva a schermo intero", + "set_slideshow_to_fullscreen": "Imposta presentazione a schermo intero", "settings": "Impostazioni", "settings_saved": "Impostazioni salvate", "share": "Condivisione", @@ -1173,9 +1181,9 @@ "show_person_options": "Mostra opzioni persona", "show_progress_bar": "Mostra Barra Avanzamento", "show_search_options": "Mostra impostazioni di ricerca", - "show_supporter_badge": "Insignia di Contributore", - "show_supporter_badge_description": "Mostra un'insignia di contributore", - "shuffle": "Mescola", + "show_supporter_badge": "Medaglia di Contributore", + "show_supporter_badge_description": "Mostra la medaglia di contributore", + "shuffle": "Casuale", "sign_out": "Esci", "sign_up": "Registrati", "size": "Dimensione", @@ -1194,16 +1202,16 @@ "stack_duplicates": "Raggruppa i duplicati", "stack_select_one_photo": "Seleziona una foto principale per il gruppo", "stack_selected_photos": "Impila foto selezionate", - "stacked_assets_count": "{count, plural, one {Raggruppato # asset} other {Raggruppati # assets}}", + "stacked_assets_count": "{count, plural, one {Raggruppato # asset} other {Raggruppati # asset}}", "stacktrace": "Traccia dell'errore", "start": "Inizio", "start_date": "Data di inizio", "state": "Provincia", "status": "Stato", "stop_motion_photo": "Ferma Foto in Movimento", - "stop_photo_sharing": "Stoppare la condivisione delle tue foto?", + "stop_photo_sharing": "Interrompere la condivisione delle tue foto?", "stop_photo_sharing_description": "{partner} non potrà più accedere alle tue foto.", - "stop_sharing_photos_with_user": "Non condividere più le tue foto con questo utente", + "stop_sharing_photos_with_user": "Interrompi la condivisione delle tue foto con questo utente", "storage": "Spazio di archiviazione", "storage_label": "Etichetta archiviazione", "storage_usage": "{used} di {available} utilizzati", @@ -1235,7 +1243,7 @@ "trash_no_results_message": "Le foto cestinate saranno mostrate qui.", "trashed_items_will_be_permanently_deleted_after": "Gli elementi cestinati saranno eliminati definitivamente dopo {days, plural, one {# giorno} other {# giorni}}.", "type": "Tipo", - "unarchive": "Rimuovi dagli archivi", + "unarchive": "Annulla l'archiviazione", "unarchived": "Rimosso dall'archivio", "unarchived_count": "{count, plural, other {Non archiviati #}}", "unfavorite": "Rimuovi preferito", @@ -1252,16 +1260,16 @@ "unselect_all": "Deseleziona tutto", "unselect_all_duplicates": "Deseleziona tutti i duplicati", "unstack": "Rimuovi dal gruppo", - "unstacked_assets_count": "{count, plural, one {Separato # asset} other {Separati # assets}}", + "unstacked_assets_count": "{count, plural, one {Separato # asset} other {Separati # asset}}", "untracked_files": "File non tracciati", "untracked_files_decription": "Questi file non vengono tracciati dall'applicazione. Sono il risultato di spostamenti falliti, caricamenti interrotti, oppure sono stati abbandonati a causa di un bug", "up_next": "Prossimo", "updated_password": "Password aggiornata", "upload": "Carica", "upload_concurrency": "Caricamenti contemporanei", - "upload_errors": "Caricamento completato con {count, plural, one {# errore} other {# errori}}, ricarica la pagina per vedere gli assets caricati.", + "upload_errors": "Caricamento completato con {count, plural, one {# errore} other {# errori}}, ricarica la pagina per vedere gli asset caricati.", "upload_progress": "Rimanenti {remaining, number} - Processati {processed, number}/{total, number}", - "upload_skipped_duplicates": "{count, plural, one {Ignorato # asset duplicato} other {Ignorati # assets duplicati}}", + "upload_skipped_duplicates": "{count, plural, one {Ignorato # asset duplicato} other {Ignorati # asset duplicati}}", "upload_status_duplicates": "Duplicati", "upload_status_errors": "Errori", "upload_status_uploaded": "Caricato", @@ -1285,7 +1293,7 @@ "variables": "Variabili", "version": "Versione", "version_announcement_closing": "Il tuo amico, Alex", - "version_announcement_message": "Heilà! È stata rilasciata una nuova versione dell'applicazione. Leggi le note di rilascio e assicurati che i tuoi file docker-compose.yml/.env siano aggiornati per evitare problemi e incongruenze, sopratutto se utilizzi WatchTower o altri strumenti per aggiornare l'applicazione in automatico.", + "version_announcement_message": "Ehilà! È stata rilasciata una nuova versione dell'applicazione. Leggi le note di rilascio e assicurati che i tuoi file docker-compose.yml/.env siano aggiornati per evitare problemi e incongruenze, soprattutto se utilizzi WatchTower o altri strumenti per aggiornare l'applicazione in automatico.", "video": "Video", "video_hover_setting": "Riproduci l'anteprima del video al passaggio del mouse", "video_hover_setting_description": "Riproduci miniatura video quando il mouse passa sopra l'elemento. Anche se disabilitato, la riproduzione può essere avviata passando con il mouse sopra l'icona riproduci.", @@ -1305,7 +1313,7 @@ "warning": "Attenzione", "week": "Settimana", "welcome": "Benvenuto", - "welcome_to_immich": "Benvenuto a immich", + "welcome_to_immich": "Benvenuto in immich", "year": "Anno", "years_ago": "{years, plural, one {# anno} other {# anni}} fa", "yes": "Si", diff --git a/web/src/lib/i18n/ja.json b/web/src/lib/i18n/ja.json index 4e01b62850998..72a9f456bdd24 100644 --- a/web/src/lib/i18n/ja.json +++ b/web/src/lib/i18n/ja.json @@ -25,7 +25,7 @@ "add_to_shared_album": "共有アルバムに追加", "added_to_archive": "アーカイブに追加済", "added_to_favorites": "お気に入りに追加済", - "added_to_favorites_count": "{count} 画像をお気に入りに追加済", + "added_to_favorites_count": "{count, number} 枚の画像をお気に入りに追加済", "admin": { "add_exclusion_pattern_description": "除外パターンを追加します。ワイルドカード「*」「**」「?」を使用できます。すべてのディレクトリで「Raw」と名前が付いたファイルを無視するには、「**/Raw/**」を使用します。また、「.tif」で終わるファイルをすべて無視するには、「**/*.tif」を使用します。さらに、絶対パスを無視するには「/path/to/ignore/**」を使用します。", "authentication_settings": "認証設定", @@ -37,7 +37,7 @@ "cleared_jobs": "{job}のジョブをクリアしました", "config_set_by_file": "設定は現在 Config File で設定されている", "confirm_delete_library": "本当に {library} を削除しますか?", - "confirm_delete_library_assets": "本当にこのライブラリを削除しますか? {count, plural, one {#個のアセット} other {all #個のアセット全て}} がImmichから削除され、元に戻すことはできません。ファイルはディスク上に残ります。", + "confirm_delete_library_assets": "本当にこのライブラリを削除しますか? {count, plural, one {#個のアセット} other {#個のアセット全て}} がImmichから削除され、元に戻すことはできません。ファイルはディスク上に残ります。", "confirm_email_below": "確認のため、以下に \"{email}\" と入力してください", "confirm_reprocess_all_faces": "本当にすべての顔を再処理しますか? これにより名前が付けられた人物も消去されます。", "confirm_user_password_reset": "本当に {user} のパスワードをリセットしますか?", @@ -126,15 +126,16 @@ "manage_concurrency": "同時実行数の管理", "manage_log_settings": "ログ設定を管理します", "map_dark_style": "ダークモード", - "map_enable_description": "地図表示を有効にします", + "map_enable_description": "地図表示機能を有効にします", "map_gps_settings": "地図・GPS設定", "map_gps_settings_description": "地図とGPS(逆ジオコーディング)の設定を管理します", + "map_implications": "地図表示機能は外部のタイルサービス(tiles.immich.cloud)に依存します", "map_light_style": "ライトモード", "map_manage_reverse_geocoding_settings": "逆ジオコーディングの設定を管理します", "map_reverse_geocoding": "逆ジオコーディング", "map_reverse_geocoding_enable_description": "逆ジオコーディング(緯度経度から住所を生成)を有効にする", "map_reverse_geocoding_settings": "逆ジオコーディング設定", - "map_settings": "地図・GPS設定", + "map_settings": "地図", "map_settings_description": "地図設定", "map_style_description": "マップテーマ(style.json)の参照先URL", "metadata_extraction_job": "メタデータの展開", @@ -173,15 +174,17 @@ "oauth_issuer_url": "発行元URL", "oauth_mobile_redirect_uri": "モバイル用リダイレクトURI", "oauth_mobile_redirect_uri_override": "モバイル用リダイレクトURI(上書き)", - "oauth_mobile_redirect_uri_override_description": "\"app.immich:/\" が無効なリダイレクトURIである場合に有効にします。", + "oauth_mobile_redirect_uri_override_description": "'{callback}'など、モバイルURIがOAuthプロバイダーによって許可されていない場合に有効にしてください", + "oauth_profile_signing_algorithm": "プロファイルの署名アルゴリズム", + "oauth_profile_signing_algorithm_description": "ユーザープロファイルを署名するのに使用するアルゴリズム。", "oauth_scope": "スコープ", "oauth_settings": "OAuth", "oauth_settings_description": "OAuthログイン設定を管理します", "oauth_settings_more_details": "この機能の詳細については、ドキュメントを参照してください。", "oauth_signing_algorithm": "署名アルゴリズム", - "oauth_storage_label_claim": "", + "oauth_storage_label_claim": "ストレージラベル クレーム", "oauth_storage_label_claim_description": "ユーザーのストレージラベルを、このクレームの値に自動的に設定します。", - "oauth_storage_quota_claim": "", + "oauth_storage_quota_claim": "ストレージクォータ クレーム", "oauth_storage_quota_claim_description": "ユーザーのストレージクォータをこのクレームの値に自動的に設定します。", "oauth_storage_quota_default": "デフォルトのストレージ割り当て(GiB)", "oauth_storage_quota_default_description": "クレームが提供されていない場合に使用されるクォータをGiB単位で設定します(無制限にする場合は0を入力してください)。", @@ -197,8 +200,8 @@ "registration_description": "あなたはシステムの最初のユーザーであるため、管理者として割り当てられ、管理タスクを担当し、追加のユーザーはあなたによって作成されます。", "removing_offline_files": "オフライン ファイルを削除します", "repair_all": "すべてを修復", - "repair_matched_items": "一致: {count, plural, one {# item} other {# items}}", - "repaired_items": "修復済み: {count, plural, one {# item} other {# items}}", + "repair_matched_items": "一致: {count, plural, one {#件} other {#件}}", + "repaired_items": "修復済み: {count, plural, one {#件} other {#件}}", "require_password_change_on_login": "初回ログイン時にパスワード変更を要求する", "reset_settings_to_default": "設定をデフォルトにリセットします", "reset_settings_to_recent_saved": "前回の設定値に戻す", @@ -276,7 +279,7 @@ "transcoding_preferred_hardware_device": "推奨ハードウェアデバイス", "transcoding_preferred_hardware_device_description": "VAAPI と QSV のみに適用されます。 ハードウェアトランスコードに使用されるdriノードを設定します。", "transcoding_preset_preset": "プリセット (-preset)", - "transcoding_preset_preset_description": "圧縮速度。遅いプリセットはファイルサイズを小さくし、特定のビットレートを目標とする場合に品質を向上させます。VP9は `faster` 以上の速度を無視します。", + "transcoding_preset_preset_description": "圧縮速度。遅いプリセットはファイルサイズを小さくし、特定のビットレートを目標とする場合に品質を向上させます。VP9は 'faster'以上の速度を無視します。", "transcoding_reference_frames": "参照フレーム", "transcoding_reference_frames_description": "特定のフレームを圧縮するときに参照するフレームの数。より高い値は圧縮効率を改善しますが、エンコードが遅くなります。\"0\" に設定すると、この値が自動的に設定されます。", "transcoding_required_description": "許容されていない動画形式のみ", @@ -284,7 +287,7 @@ "transcoding_settings_description": "動画ファイルの解像度とエンコード情報を管理します", "transcoding_target_resolution": "解像度", "transcoding_target_resolution_description": "解像度を高くすると細かなディテールを保持できますが、エンコードに時間がかかり、ファイルサイズが大きくなり、アプリの応答性が低下する可能性があります。", - "transcoding_temporal_aq": "Temporal AQ", + "transcoding_temporal_aq": "適応的量子化(Temporal AQ)", "transcoding_temporal_aq_description": "NVEncにのみ適用されます。高精細で動きの少ないシーンの画質を向上させます。古いデバイスとの互換性はありません。", "transcoding_threads": "スレッド数", "transcoding_threads_description": "値を高くするとエンコード速度が速くなりますが、アクティブな間はサーバーが他のタスクを処理する余裕が少なくなります。この値はCPUのコア数を超えないようにする必要があります。\"0\" に設定すると、最大限利用されます。", @@ -318,7 +321,8 @@ "user_settings": "ユーザー設定", "user_settings_description": "ユーザー設定を管理します", "user_successfully_removed": "ユーザー {email} は正常に削除されました。", - "version_check_enabled_description": "新しいリリースを確認するための定期的なGitHubへのリクエストを有効にする", + "version_check_enabled_description": "バージョンの確認を有効にする", + "version_check_implications": "このバージョン確認機能は定期的なgithub.comとの通信によります", "version_check_settings": "バージョンチェック", "version_check_settings_description": "新しいバージョンの通知を有効/無効にします", "video_conversion_job": "動画をトランスコード", @@ -328,10 +332,14 @@ "admin_password": "管理者パスワード", "administration": "管理", "advanced": "詳細設定", + "age_months": "{months,plural, one {#か月} other {#か月}}", + "age_year_months": "1歳{months,plural, one {#か月} other {#か月}}", + "age_years": "{years,plural, one {#歳} other {#歳}}", "album_added": "アルバム追加", "album_added_notification_setting_description": "共有アルバムに追加されたときEメール通知を受信する", "album_cover_updated": "アルバムカバー更新", - "album_delete_confirmation": "アルバム{album}を本当に削除しますか?\nこのアルバムが共有されているなら、他のユーザーもアルバムにアクセスできなくなります。", + "album_delete_confirmation": "アルバム{album}を本当に削除しますか?", + "album_delete_confirmation_description": "このアルバムが共有されているなら、他のユーザーもアルバムにアクセスできなくなります。", "album_info_updated": "アルバム情報更新", "album_leave": "アルバムから去りますか?", "album_leave_confirmation": "本当に {album} から去りますか?", @@ -355,6 +363,7 @@ "allow_edits": "編集を許可", "allow_public_user_to_download": "一般ユーザーによるダウンロードを許可", "allow_public_user_to_upload": "一般ユーザーによるアップロードを許可", + "anti_clockwise": "反時計回り", "api_key": "APIキー", "api_key_description": "この値は一回のみ表示されます。 ウィンドウを閉じる前に必ずコピーしてください。", "api_key_empty": "APIキー名は空白にできません", @@ -410,7 +419,7 @@ "camera_model": "カメラモデル", "cancel": "キャンセル", "cancel_search": "検索をキャンセル", - "cannot_merge_people": "人物をマージできません", + "cannot_merge_people": "人物を統合できません", "cannot_undo_this_action": "この操作は元に戻せません!", "cannot_update_the_description": "説明を更新できません", "cant_apply_changes": "", @@ -426,16 +435,20 @@ "change_password_description": "これは、初めてのサインインであるか、パスワードの変更要求が行われたかのいずれかです。 新しいパスワードを下に入力してください。", "change_your_password": "パスワードを変更します", "changed_visibility_successfully": "非表示設定を正常に変更しました", + "check_all": "全て選択", "check_logs": "ログを確認", + "choose_matching_people_to_merge": "統合先の人物を選んでください", "city": "市町村", "clear": "クリア", "clear_all": "全てクリア", "clear_all_recent_searches": "全ての最近の検索をクリア", "clear_message": "メッセージをクリア", "clear_value": "値をクリア", + "clockwise": "時計回り", "close": "閉じる", "collapse": "展開", "collapse_all": "全て展開", + "color": "カラー", "color_theme": "カラーテーマ", "comment_deleted": "コメントが削除されました", "comment_options": "コメント設定", @@ -469,18 +482,20 @@ "create_new_person": "新しい人物を作成", "create_new_person_hint": "選択されたアセットを新しい人物に割り当て", "create_new_user": "新規ユーザーの作成", + "create_tag": "タグを作成する", + "create_tag_description": "タグを作成します。入れ子構造のタグは、はじめのスラッシュを含めた、タグの完全なパスを入力してください。", "create_user": "ユーザーを作成", "created": "作成", "current_device": "現在のデバイス", "custom_locale": "カスタムロケール", "custom_locale_description": "言語と地域に基づいて日付と数値をフォーマットします", - "dark": "", + "dark": "ダークモード", "date_after": "この日以降", "date_and_time": "日付と時間", "date_before": "この日以前", "date_of_birth_saved": "生年月日は正常に保存されました", "date_range": "日付", - "day": "", + "day": "ライトモード", "deduplicate_all": "全て重複排除", "default_locale": "デフォルトのロケール", "default_locale_description": "ブラウザのロケールに基づいて日付と数値をフォーマットします", @@ -492,6 +507,8 @@ "delete_library": "ライブラリを削除", "delete_link": "リンクを削除", "delete_shared_link": "共有リンクを消す", + "delete_tag": "タグを削除する", + "delete_tag_confirmation_prompt": "本当に{tagName}タグを削除しますか?", "delete_user": "ユーザーを削除", "deleted_shared_link": "共有リンクを削除", "description": "概要欄", @@ -509,12 +526,15 @@ "do_not_show_again": "このメッセージを再び表示しない", "done": "完了", "download": "ダウンロード", + "download_include_embedded_motion_videos": "埋め込まれた動画", + "download_include_embedded_motion_videos_description": "別ファイルとして、モーションフォトに埋め込まれた動画を含める", "download_settings": "ダウンロード", "download_settings_description": "アセットのダウンロードに関連する設定を管理します", "downloading": "ダウンロード中", "downloading_asset_filename": "アセット {filename} をダウンロード中", "drop_files_to_upload": "ファイルをドロップしてアップロード", "duplicates": "重複", + "duplicates_description": "もしあれば、重複しているグループを示すことで解決します", "duration": "間隔", "durations": { "days": "", @@ -537,10 +557,15 @@ "edit_location": "位置情報を編集", "edit_name": "名前を変更", "edit_people": "人物を編集", + "edit_tag": "タグを編集する", "edit_title": "タイトルを編集", "edit_user": "ユーザーを編集", - "edited": "", - "editor": "", + "edited": "変更を保存しました", + "editor": "編集画面", + "editor_close_without_save_prompt": "変更は破棄されます", + "editor_close_without_save_title": "編集画面を閉じますか?", + "editor_crop_tool_h2_aspect_ratios": "アスペクト比", + "editor_crop_tool_h2_rotation": "回転", "email": "メールアドレス", "empty": "", "empty_album": "", @@ -581,6 +606,8 @@ "failed_to_load_assets": "アセットを読み込めませんでした", "failed_to_load_people": "人物を読み込めませんでした", "failed_to_remove_product_key": "プロダクトキーを削除できませんでした", + "failed_to_stack_assets": "アセットをスタックできませんでした", + "failed_to_unstack_assets": "アセットをスタックから解除することができませんでした", "import_path_already_exists": "このインポートパスは既に存在します。", "incorrect_email_or_password": "メールアドレスまたはパスワードが間違っています", "paths_validation_failed": "{paths, plural, one {#個} other {#個}}のパスの検証に失敗しました", @@ -667,12 +694,13 @@ "unable_to_set_profile_picture": "プロフィール画像を設定できません", "unable_to_submit_job": "ジョブを送信できません", "unable_to_trash_asset": "アセットをゴミ箱に移動できません", - "unable_to_unlink_account": "", + "unable_to_unlink_account": "アカウントのリンクを解除できません", "unable_to_update_album_cover": "アルバムカバーを更新できません", "unable_to_update_album_info": "アルバム情報を更新できません", "unable_to_update_library": "ライブラリを更新できません", "unable_to_update_location": "場所を更新できません", "unable_to_update_settings": "設定を更新できません", + "unable_to_update_timeline_display_status": "タイムラインでの表示の設定状態を更新できません", "unable_to_update_user": "ユーザーを更新できません", "unable_to_upload_file": "ファイルをアップロードできません" }, @@ -687,6 +715,7 @@ "expired": "有効期限が切れました", "expires_date": "{date} に失効", "explore": "探索", + "explorer": "探索", "export": "エクスポート", "export_as_json": "JSONとしてエクスポート", "extension": "拡張子", @@ -700,6 +729,8 @@ "feature": "", "feature_photo_updated": "人物画像が更新されました", "featurecollection": "", + "features": "機能", + "features_setting_description": "アプリの機能を管理する", "file_name": "ファイル名", "file_name_or_extension": "ファイル名または拡張子", "filename": "ファイル名", @@ -708,10 +739,12 @@ "filter_people": "人物を絞り込み", "find_them_fast": "名前で検索して素早く発見", "fix_incorrect_match": "間違った一致を修正", + "folders": "フォルダ", + "folders_feature_description": "ファイルシステム上の写真と動画のフォルダビューを閲覧する", "force_re-scan_library_files": "強制的に全てのライブラリのファイルを再スキャン", "forward": "前へ", "general": "一般", - "get_help": "", + "get_help": "助けを求める", "getting_started": "はじめる", "go_back": "戻る", "go_to_search": "検索へ", @@ -720,8 +753,8 @@ "group_no": "グループ化なし", "group_owner": "所有者でグループ化", "group_year": "年でグループ化", - "has_quota": "", - "hi_user": "こんにちは、{name} {email}", + "has_quota": "クォータ有り", + "hi_user": "こんにちは、{name}( {email})さん", "hide_all_people": "全ての人物を非表示", "hide_gallery": "ギャラリーを非表示", "hide_named_person": "人物 {name} を非表示", @@ -732,6 +765,15 @@ "hour": "時間", "image": "写真", "image_alt_text_date": "{isVideo, select, true {動画} other {写真}}は{date} に撮影", + "image_alt_text_date_1_person": "{date}の、{person1}との{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_2_people": "{date}の、{person1}と{person2}の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_3_people": "{date}の、{person1}と{person2}、そして{person3}の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_4_or_more_people": "{date}の、{person1}と{person2}、そしてその他{additionalCount,number}人の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_place": "{date}の、{country}、{city}での{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_place_1_person": "{date}の、{country}、{city}での{person1}の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_place_2_people": "{date}の、{country}、{city}での{person1}と{person2}の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_place_3_people": "{date}の、{country}、{city}での{person1}と{person2}、そして{person3}の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_place_4_or_more_people": "{date}の、{country}、{city}での{person1}と{person2}、そしてその他{additionalCount, number}人の{isVideo, select, true {動画} other {画像}}", "image_alt_text_place": "{country} {city}で撮影", "image_taken": "{isVideo, select, true {動画は} other {写真は}}", "img": "", @@ -739,11 +781,12 @@ "immich_web_interface": "Immich Webインターフェース", "import_from_json": "JSONからインポート", "import_path": "インポートパス", + "in_albums": "{count, plural, one {#件のアルバム} other {#件のアルバム}}の中", "in_archive": "アーカイブ済み", "include_archived": "アーカイブ済みを含める", "include_shared_albums": "共有アルバムを含める", "include_shared_partner_assets": "パートナーがシェアしたアセットを含める", - "individual_share": "", + "individual_share": "1枚の共有", "info": "情報", "interval": { "day_at_onepm": "毎日午後1時", @@ -764,16 +807,16 @@ "last_seen": "最新の活動", "latest_version": "最新バージョン", "latitude": "緯度", - "leave": "", + "leave": "標高", "let_others_respond": "他のユーザーの返信を許可する", "level": "レベル", "library": "ライブラリ", "library_options": "ライブラリ設定", - "light": "", + "light": "ライトモード", "like_deleted": "いいねが削除されました", "link_options": "リンクのオプション", - "link_to_oauth": "", - "linked_oauth_account": "", + "link_to_oauth": "OAuthへリンクする", + "linked_oauth_account": "リンクされたOAuthアカウント", "list": "リスト", "loading": "読み込み中", "loading_search_results_failed": "検索結果を読み込めませんでした", @@ -799,20 +842,21 @@ "manage_your_oauth_connection": "OAuth接続を管理します", "map": "地図", "map_marker_for_images": "{country} {city}で撮影された写真の地図マーカー", - "map_marker_with_image": "", + "map_marker_with_image": "画像の地図マーカー", "map_settings": "マップの設定", "matches": "マッチ", "media_type": "メディアタイプ", "memories": "メモリー", "memories_setting_description": "メモリーの内容を管理します", "memory": "メモリー", + "memory_lane_title": "思い出 {title}", "menu": "メニュー", - "merge": "マージ", - "merge_people": "人物をマージ", + "merge": "統合", + "merge_people": "人物を統合", "merge_people_limit": "一度に結合できる顔は最大5つまでです", "merge_people_prompt": "これらの人物を統合しますか? この操作は元に戻せません。", - "merge_people_successfully": "", - "merged_people_count": "{count, plural, one {#人} other {#人}}の人物をマージしました", + "merge_people_successfully": "人物の統合に成功しました", + "merged_people_count": "{count, plural, one {#人} other {#人}}の人物を統合しました", "minimize": "最小化", "minute": "分", "missing": "行方不明", @@ -824,6 +868,7 @@ "name": "名前", "name_or_nickname": "名前またはニックネーム", "never": "行わない", + "new_album": "新たなアルバム", "new_api_key": "新しいAPI キー", "new_password": "新しいパスワード", "new_person": "新しい人物", @@ -861,12 +906,15 @@ "offline_paths_description": "これらの結果は、外部ライブラリの一部ではないファイルを手動で削除したことが原因である可能性があります。", "ok": "了解", "oldest_first": "古い順", + "onboarding": "はじめに", + "onboarding_privacy_description": "次の(任意の)機能は外部サービスに依存し、いつでも管理者用設定で無効にできます。", "onboarding_theme_description": "インスタンスのカラーテーマを選択してください。これは後から設定で変更できます。", "onboarding_welcome_description": "いくつかの一般的な設定を使用してインスタンスをセットアップしましょう。", "onboarding_welcome_user": "ようこそ、{user} さん", "online": "オンライン", "only_favorites": "お気に入りのみ", "only_refreshes_modified_files": "変更されたファイルのみを更新します", + "open_in_map_view": "地図表示で見る", "open_in_openstreetmap": "OpenStreetMapで開く", "open_the_search_filters": "検索フィルタを開く", "options": "オプション", @@ -879,6 +927,7 @@ "owned": "所有中", "owner": "オーナー", "partner": "パートナー", + "partner_can_access": "{partner} がアクセスできます", "partner_can_access_assets": "アーカイブ済みのものと削除済みのものを除いた全ての写真と動画", "partner_can_access_location": "写真が撮影された場所", "partner_sharing": "パートナとの共有", @@ -888,9 +937,9 @@ "password_required": "パスワードが必要", "password_reset_success": "パスワードのリセットに成功", "past_durations": { - "days": "", - "hours": "", - "years": "" + "days": "{days, plural, one {#日} other {#日}}前", + "hours": "{hours, plural, one {#時間} other {#時間}}前", + "years": "{years, plural, one {#年} other {#年}}前" }, "path": "パス", "pattern": "パターン", @@ -899,6 +948,8 @@ "paused": "停止", "pending": "待機中", "people": "人物", + "people_edits_count": "{count, plural, one {#人} other {#人}}が編集済", + "people_feature_description": "人物でグループ化された写真と動画を閲覧する", "people_sidebar_description": "人物へのリンクをサイドバーに表示", "perform_library_tasks": "", "permanent_deletion_warning": "永久削除の警告", @@ -909,10 +960,11 @@ "permanently_deleted_asset": "アセットを完全に削除しました", "permanently_deleted_assets_count": "{count, plural, one {#個} other {#個}}のアセットを完全に削除しました", "person": "人物", + "person_hidden": "{name}{hidden, select, true { (非表示)} other {}}", "photo_shared_all_users": "写真をすべてのユーザーと共有したか、共有するユーザーがいないようです。", "photos": "写真", "photos_and_videos": "写真と動画", - "photos_count": "{count, plural, one {{count, number}枚} other {{count, number}枚}}", + "photos_count": "{count, plural, one {{count, number}枚の写真} other {{count, number}枚の写真}}", "photos_from_previous_years": "以前の年の写真", "pick_a_location": "場所を選択", "place": "場所", @@ -922,13 +974,14 @@ "play_motion_photo": "モーションビデオを再生", "play_or_pause_video": "動画を再生または一時停止", "point": "", - "port": "", + "port": "ポートレート", "preset": "プリセット", "preview": "プレビュー", "previous": "前", "previous_memory": "前のメモリー", "previous_or_next_photo": "前または次の写真", - "primary": "", + "primary": "最優先", + "privacy": "プライバシー", "profile_image_of_user": "{user} のプロフィール画像", "profile_picture_set": "プロフィール画像が設定されました。", "public_album": "公開アルバム", @@ -946,11 +999,14 @@ "purchase_button_select": "選択", "purchase_failed_activation": "アクティベートに失敗しました! メールで正しいプロダクトキーを確認してください!", "purchase_individual_description_1": "個人向け", + "purchase_individual_description_2": "サポーターの状態", "purchase_individual_title": "個人", "purchase_input_suggestion": "プロダクトキーをお持ちですか? 下に入力してください", "purchase_license_subtitle": "Immich を購入してサービスの継続的な開発を支援してください", "purchase_lifetime_description": "生涯の購入", "purchase_option_title": "購入オプション", + "purchase_panel_info_1": "Immichの製作には多くの時間と労力を要しており、また、可能な限りImmichを良いものにするために取り組んでいる専任の技術者がいます。私たちの使命は、オープンソースソフトウェアであり倫理観に則したビジネスの実践のために、開発者の持続可能な収入源となること、そして搾取的なクラウドサービスの本当の代替サービスで、プライバシーを尊重したエコシステムをつくることです。", + "purchase_panel_info_2": "私たちは有料化しないことを約束していますので、この購入によってImmichに追加の機能が付与されることはありません。私たちは、皆様のようなImmichの継続的な開発を支援するユーザーに支えられています。", "purchase_panel_title": "プロジェクトを支援", "purchase_per_server": "サーバーごと", "purchase_per_user": "ユーザーごと", @@ -958,9 +1014,17 @@ "purchase_remove_product_key_prompt": "本当にプロダクトキーを削除しますか?", "purchase_remove_server_product_key": "サーバープロダクトキーを削除", "purchase_remove_server_product_key_prompt": "本当にサーバープロダクトキーを削除しますか?", + "purchase_server_description_1": "サーバ全体", + "purchase_server_description_2": "サポーターの状態", + "purchase_server_title": "サーバー", + "purchase_settings_server_activated": "サーバーのプロダクトキーは管理者に管理されています", "range": "", + "rating": "星での評価", + "rating_clear": "評価を取り消す", + "rating_count": "星{count, plural, one {#つ} other {#つ}}", + "rating_description": "情報欄にEXIFの評価を表示", "raw": "", - "reaction_options": "", + "reaction_options": "リアクションの選択", "read_changelog": "変更履歴を読む", "reassign": "再割り当て", "reassigned_assets_to_existing_person": "{count, plural, one {#個} other {#個}}のアセットを{name, select, null {既存の人物} other {{name}}}に再割り当てしました", @@ -991,6 +1055,7 @@ "removed_from_archive": "アーカイブから削除されました", "removed_from_favorites": "お気に入りから削除しました", "removed_from_favorites_count": "{count, plural, other {#項目}}お気に入りから削除しました", + "removed_tagged_assets": "{count, plural, one {#個のアセット} other {#個のアセット}}からタグを削除しました", "rename": "リネーム", "repair": "修復", "repair_no_results_message": "追跡されていないファイルや存在しないファイルがここに表示されます", @@ -1003,6 +1068,7 @@ "reset_people_visibility": "人物の非表示設定をリセット", "reset_settings_to_default": "", "reset_to_default": "デフォルトにリセット", + "resolve_duplicates": "重複を解決する", "resolved_all_duplicates": "全ての重複を解決しました", "restore": "復元", "restore_all": "全て復元", @@ -1027,6 +1093,8 @@ "search": "検索", "search_albums": "アルバムを検索", "search_by_context": "状況で検索", + "search_by_filename": "ファイル名もしくは拡張子で検索", + "search_by_filename_example": "例: IMG_1234.JPG もしくは PNG", "search_camera_make": "カメラメーカーを検索…", "search_camera_model": "カメラのモデルを検索…", "search_city": "市町村を検索…", @@ -1037,6 +1105,7 @@ "search_people": "人物を検索", "search_places": "場所を検索", "search_state": "都道府県を検索…", + "search_tags": "タグを検索...", "search_timezone": "タイムゾーンを検索…", "search_type": "検索タイプ", "search_your_photos": "写真を検索", @@ -1045,6 +1114,7 @@ "see_all_people": "全ての人物を見る", "select_album_cover": "アルバムカバーを選択", "select_all": "全て選択", + "select_all_duplicates": "全ての重複を選択", "select_avatar_color": "アバターの色を選択", "select_face": "顔を選択", "select_featured_photo": "人物写真を選択", @@ -1054,10 +1124,13 @@ "select_new_face": "新しい顔を選択", "select_photos": "写真を選択", "select_trash_all": "全て削除", - "selected": "", + "selected": "選択済み", + "selected_count": "{count, plural, other {#個選択済み}}", "send_message": "メッセージを送信", "send_welcome_email": "ウェルカムメールを送信", "server": "サーバー", + "server_offline": "サーバーがオフラインです", + "server_online": "サーバーがオンラインです", "server_stats": "サーバー統計", "server_version": "サーバーバージョン", "set": "設定", @@ -1070,10 +1143,11 @@ "settings_saved": "設定が保存されました", "share": "共有", "shared": "共有済み", - "shared_by": "", + "shared_by": "により共有", "shared_by_user": "{user} により共有", "shared_by_you": "あなたにより共有", "shared_from_partner": "{partner} による写真", + "shared_link_options": "共有リンクのオプション", "shared_links": "共有リンク", "shared_photos_and_videos_count": "{assetCount, plural, other {#個の共有された写真と動画}}", "shared_with_partner": "{partner} と共有しました", @@ -1082,11 +1156,12 @@ "sharing_sidebar_description": "共有へのリンクをサイドバーに表示", "shift_to_permanent_delete": "⇧を押してアセットを完全に削除", "show_album_options": "アルバム設定を表示", + "show_albums": "アルバムを表示", "show_all_people": "全ての人物を表示", "show_and_hide_people": "人物を表示/非表示", "show_file_location": "ファイルの場所を表示", "show_gallery": "ギャラリーを表示", - "show_hidden_people": "", + "show_hidden_people": "非表示の人物を表示", "show_in_timeline": "タイムラインに表示", "show_in_timeline_setting_description": "このユーザーの写真と動画をタイムラインに表示", "show_keyboard_shortcuts": "キーボードショートカットを表示", @@ -1096,11 +1171,15 @@ "show_person_options": "人物設定を表示", "show_progress_bar": "プログレスバーを表示", "show_search_options": "検索オプションを表示", + "show_supporter_badge": "サポーターバッジ", + "show_supporter_badge_description": "サポーターバッジを表示", "shuffle": "ランダム", + "sidebar": "サイドバー", + "sidebar_display_description": "サイドバーにビューへのリンクを表示", "sign_out": "サインアウト", "sign_up": "登録", "size": "サイズ", - "skip_to_content": "", + "skip_to_content": "コンテンツへスキップ", "slideshow": "スライドショー", "slideshow_settings": "スライドショー設定", "sort_albums_by": "この順序でアルバムをソート…", @@ -1112,7 +1191,10 @@ "sort_title": "タイトル", "source": "ソース", "stack": "スタック", - "stack_selected_photos": "", + "stack_duplicates": "スタックの重複", + "stack_select_one_photo": "スタックのメインの写真を選択", + "stack_selected_photos": "選択した写真をスタックする", + "stacked_assets_count": "{count, plural, one {#枚} other {#枚}}スタックしました", "stacktrace": "スタックトレース", "start": "開始", "start_date": "開始日", @@ -1122,88 +1204,118 @@ "stop_photo_sharing": "写真の共有を無効化しますか?", "stop_photo_sharing_description": "{partner} はあなたの写真にアクセスできなくなります。", "stop_sharing_photos_with_user": "このユーザーとの写真の共有をやめる", - "storage": "ストレージ", + "storage": "ストレージの空き容量", "storage_label": "ストレージラベル", "storage_usage": "{available} 中 {used} 使用中", "submit": "送信", "suggestions": "ユーザーリスト", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", + "sunrise_on_the_beach": "海岸の日の出", + "swap_merge_direction": "統合する方向を入れ替え", "sync": "同期", + "tag": "タグ付けする", + "tag_assets": "アセットにタグ付けする", + "tag_created": "タグ: {tag} を作成しました", + "tag_updated": "タグ: {tag} を更新しました", + "tagged_assets": "{count, plural, one {#個のアセット} other {#個のアセット}}をタグ付けしました", + "tags": "タグ", "template": "テンプレート", "theme": "テーマ", "theme_selection": "テーマ選択", "theme_selection_description": "ブラウザのシステム設定に基づいてテーマを明色または暗色に自動的に設定します", + "they_will_be_merged_together": "これらは一緒に統合されます", "time_based_memories": "時間によるメモリー", "timezone": "タイムゾーン", "to_archive": "アーカイブ", "to_change_password": "パスワードを変更", "to_favorite": "お気に入り", "to_login": "ログイン", + "to_root": "最上層のフォルダへ", "to_trash": "ゴミ箱", "toggle_settings": "設定をトグル", - "toggle_theme": "テーマを切り替え", + "toggle_theme": "ダークテーマを切り替え", "toggle_visibility": "", "total_usage": "総使用量", "trash": "ゴミ箱", "trash_all": "全て削除", + "trash_count": "{count, number}枚ゴミ箱へ移動", "trash_delete_asset": "アセットをゴミ箱へ移動/削除", "trash_no_results_message": "ゴミ箱に移動した写真や動画がここに表示されます。", "trashed_items_will_be_permanently_deleted_after": "ゴミ箱に入れられたアイテムは{days, plural, one {#日} other {#日}}後に完全に削除されます。", "type": "タイプ", "unarchive": "アーカイブを解除", "unarchived": "", + "unarchived_count": "{count, plural, other {#枚アーカイブしました}}", "unfavorite": "お気に入りから外す", "unhide_person": "人物の非表示を解除", - "unknown": "", + "unknown": "不明", "unknown_album": "", - "unknown_year": "", - "unlink_oauth": "", - "unlinked_oauth_account": "", - "unselect_all": "", + "unknown_year": "不明な年", + "unlimited": "無制限", + "unlink_oauth": "OAuthのリンクを解除", + "unlinked_oauth_account": "リンクが解除されたOAuthアカウント", + "unnamed_album": "無名のアルバム", + "unnamed_album_delete_confirmation": "本当にこのアルバムを削除しますか?", + "unnamed_share": "無名の共有", + "unsaved_change": "未保存の変更", + "unselect_all": "全て選択解除", + "unselect_all_duplicates": "全ての重複の選択を解除", "unstack": "スタックを解除", + "unstacked_assets_count": "{count, plural, one {#個のアセット} other {#個のアセット}}をスタックから解除しました", + "untracked_files": "未追跡ファイル", "untracked_files_decription": "これらのファイルはアプリケーションによって追跡されていません。これらは移動の失敗、アップロードの中断、またはバグにより取り残されたものである可能性があります", - "up_next": "", - "updated_password": "", + "up_next": "次へ", + "updated_password": "パスワードを更新しました", "upload": "アップロード", "upload_concurrency": "アップロードの同時実行数", "upload_errors": "アップロードは{count, plural, one {#個} other {#個}}のエラーで完了しました、新しくアップロードされたアセットを見るにはページを更新してください。", - "upload_progress": "残り {remaining} - {processed}/{total} 処理済み", + "upload_progress": "残り {remaining, number} - {processed, number}/{total, number} 処理済み", "upload_skipped_duplicates": "{count, plural, one {#個} other {#個}}の重複アセットをスキップしました", "upload_status_duplicates": "重複", "upload_status_errors": "エラー", "upload_status_uploaded": "アップロード済", "upload_success": "アップロード成功、新しくアップロードされたアセットを見るにはページを更新してください。", - "url": "", - "usage": "", + "url": "URL", + "usage": "使用容量", "use_custom_date_range": "代わりにカスタム日付範囲を使用", - "user": "", + "user": "ユーザー", "user_id": "ユーザーID", "user_liked": "{user} が{type, select, photo {この写真を} video {この動画を} asset {このアセットを} other {}}いいねしました", + "user_purchase_settings": "購入", + "user_purchase_settings_description": "購入を管理", + "user_role_set": "{user} を{role}に設定しました", "user_usage_detail": "ユーザー使用状況の詳細", - "username": "", + "username": "ユーザー名", "users": "ユーザー", "utilities": "ユーティリティ", - "validate": "", - "variables": "", + "validate": "認証", + "variables": "変数", "version": "バージョン", "version_announcement_closing": "あなたの友人、Alex", + "version_announcement_message": "こんにちは、親愛なる皆様へ。アプリの新しいバージョンがありますので、構成の不整合を防ぐためにリリースノートにアクセスし、docker-compose.yml、及び.cnvの設定が最新か確認してください。特に自動的にアプリの更新を制御するWatchTowerやその他システムを利用している場合に当てはまります。", "video": "動画", "video_hover_setting": "ホバー時にサムネイルで動画を再生", "video_hover_setting_description": "マウスが項目の上にあるときに動画のサムネイルを再生します。無効時でも再生アイコンにカーソルを合わせると再生を開始できます。", "videos": "ビデオ", "videos_count": "{count, plural, one {#個} other {#個}}の動画", + "view": "見る", + "view_album": "アルバムを見る", "view_all": "すべて見る", - "view_all_users": "", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", + "view_all_users": "全てのユーザーを確認する", + "view_in_timeline": "タイムラインで見る", + "view_links": "リンクを確認する", + "view_next_asset": "次のアセットを見る", + "view_previous_asset": "前のアセットを見る", + "view_stack": "ビュースタック", "viewer": "", "visibility_changed": "{count, plural, one {#人} other {#人}}の人物の非表示設定が変更されました", - "waiting": "", - "week": "", - "welcome_to_immich": "", - "year": "", + "waiting": "待機中", + "warning": "警告", + "week": "週", + "welcome": "ようこそ", + "welcome_to_immich": "immichにようこそ", + "year": "年", + "years_ago": "{years, plural, one {#年} other {#年}}前", "yes": "はい", + "you_dont_have_any_shared_links": "共有リンクはありません", "zoom_image": "画像を拡大" } diff --git a/web/src/lib/i18n/ko.json b/web/src/lib/i18n/ko.json index 9d94a918fb7c5..89c5ca068f8e6 100644 --- a/web/src/lib/i18n/ko.json +++ b/web/src/lib/i18n/ko.json @@ -129,12 +129,13 @@ "map_enable_description": "지도 기능 활성화", "map_gps_settings": "지도 및 GPS 설정", "map_gps_settings_description": "지도 및 GPS (역지오코딩) 설정 관리", + "map_implications": "지도 기능은 외부 타일 서비스(tiles.immich.clou를 사용합니다.", "map_light_style": "라이트 스타일", "map_manage_reverse_geocoding_settings": "역지오코딩 설정 관리", "map_reverse_geocoding": "역지오코딩", "map_reverse_geocoding_enable_description": "역지오코딩 활성화", "map_reverse_geocoding_settings": "역지오코딩 설정", - "map_settings": "지도 설정", + "map_settings": "지도", "map_settings_description": "지도 설정 관리", "map_style_description": "지도 테마 style.json URL", "metadata_extraction_job": "메타데이터 추출", @@ -320,7 +321,8 @@ "user_settings": "사용자 설정", "user_settings_description": "사용자 설정 관리", "user_successfully_removed": "{email}이(가) 성공적으로 제거되었습니다.", - "version_check_enabled_description": "최신 버전 확인을 위한 주기적인 GitHub 확인 활성화", + "version_check_enabled_description": "버전 확인 활성화", + "version_check_implications": "버전 확인 기능은 주기적으로 github.com에 요청을 보냅니다.", "version_check_settings": "버전 확인", "version_check_settings_description": "최신 버전 알림 설정 관리", "video_conversion_job": "동영상 트랜스코드", @@ -336,7 +338,8 @@ "album_added": "공유 앨범 초대", "album_added_notification_setting_description": "공유 앨범으로 초대를 받은 경우 이메일 알림 받기", "album_cover_updated": "앨범 커버를 변경했습니다.", - "album_delete_confirmation": "{album} 앨범을 삭제하시겠습니까?\n이 앨범을 공유한 경우 다른 사용자가 더 이상 앨범에 접근할 수 없습니다.", + "album_delete_confirmation": "{album} 앨범을 삭제하시겠습니까?", + "album_delete_confirmation_description": "이 앨범을 공유한 경우 다른 사용자가 더 이상 앨범에 접근할 수 없습니다.", "album_info_updated": "앨범 정보가 수정되었습니다.", "album_leave": "앨범에서 나가시겠습니까?", "album_leave_confirmation": "{album} 앨범에서 나가시겠습니까?", @@ -360,6 +363,7 @@ "allow_edits": "편집자로 설정", "allow_public_user_to_download": "모든 사용자의 다운로드 허용", "allow_public_user_to_upload": "모든 사용자의 업로드 허용", + "anti_clockwise": "반시계 방향", "api_key": "API 키", "api_key_description": "이 값은 한 번만 표시됩니다. 창을 닫기 전 반드시 복사하세요.", "api_key_empty": "키 이름은 비어 있을 수 없습니다.", @@ -441,6 +445,7 @@ "clear_all_recent_searches": "검색 기록 전체 삭제", "clear_message": "메시지 지우기", "clear_value": "값 지우기", + "clockwise": "시계 방향", "close": "닫기", "collapse": "접기", "collapse_all": "모두 접기", @@ -550,6 +555,10 @@ "edit_user": "사용자 수정", "edited": "펀집되었습니다.", "editor": "편집자", + "editor_close_without_save_prompt": "변경 사항이 반영되지 않습니다.", + "editor_close_without_save_title": "편집을 종료하시겠습니까?", + "editor_crop_tool_h2_aspect_ratios": "종횡비", + "editor_crop_tool_h2_rotation": "회전", "email": "이메일", "empty": "", "empty_album": "", @@ -720,6 +729,7 @@ "filter_people": "인물 필터", "find_them_fast": "이름으로 검색하여 빠르게 찾기", "fix_incorrect_match": "잘못된 분류 수정", + "folders": "폴더", "force_re-scan_library_files": "모든 파일 강제 다시 스캔", "forward": "앞으로", "general": "일반", @@ -895,6 +905,7 @@ "ok": "확인", "oldest_first": "오래된 순", "onboarding": "온보딩", + "onboarding_privacy_description": "이 선택적 기능은 외부 서비스를 사용하며, 관리자 설정에서 언제든 비활성화할 수 있습니다.", "onboarding_storage_template_description": "활성화한 경우, 사용자 정의 템플릿을 기반으로 파일을 자동 분류합니다. 안정성 문제로 인해 해당 기능은 기본적으로 비활성화 되어 있습니다. 자세한 내용은 [공식 문서]를 참조하세요.", "onboarding_theme_description": "색상 테마를 선택하세요. 나중에 설정에서 변경할 수 있습니다.", "onboarding_welcome_description": "몇 가지 일반적인 설정을 진행하겠습니다.", @@ -969,6 +980,7 @@ "previous_memory": "이전 추억", "previous_or_next_photo": "이전 또는 다음 이미지로", "primary": "주요", + "privacy": "프라이버시", "profile_image_of_user": "{user}님의 프로필 이미지", "profile_picture_set": "프로필 사진이 설정되었습니다.", "public_album": "공개 앨범", @@ -1007,6 +1019,8 @@ "purchase_settings_server_activated": "서버 제품 키는 관리자가 관리합니다.", "range": "", "rating": "등급", + "rating_clear": "등급 초기화", + "rating_count": "{count, plural, one {#점} other {#점}}", "rating_description": "상세 정보에 EXIF의 등급 정보 표시", "raw": "", "reaction_options": "반응 옵션", @@ -1130,6 +1144,7 @@ "shared_by_user": "{user}님이 공유함", "shared_by_you": "내가 공유함", "shared_from_partner": "{partner}님의 사진", + "shared_link_options": "공유 링크 옵션", "shared_links": "공유 링크", "shared_photos_and_videos_count": "사진 및 동영상 {assetCount, plural, other {#개를 공유했습니다.}}", "shared_with_partner": "{partner}님과 공유함", @@ -1205,7 +1220,7 @@ "to_login": "로그인", "to_trash": "삭제", "toggle_settings": "설정 변경", - "toggle_theme": "테마 변경", + "toggle_theme": "다크 모드 사용", "toggle_visibility": "숨김 여부 변경", "total_usage": "총 사용량", "trash": "휴지통", @@ -1227,6 +1242,7 @@ "unlink_oauth": "OAuth 연결 해제", "unlinked_oauth_account": "OAuth 계정 연결이 해제되었습니다.", "unnamed_album": "이름 없는 앨범", + "unnamed_album_delete_confirmation": "선텍한 앨범을 삭제하시겠습니까?", "unnamed_share": "이름 없는 공유", "unsaved_change": "저장되지 않은 변경 사항", "unselect_all": "모두 선택 해제", @@ -1283,7 +1299,7 @@ "warning": "경고", "week": "주", "welcome": "환영합니다", - "welcome_to_immich": "Immich에 오신 것을 환영합니다", + "welcome_to_immich": "환영합니다", "year": "년", "years_ago": "{years, plural, one {#년} other {#년}} 전", "yes": "네", diff --git a/web/src/lib/i18n/lt.json b/web/src/lib/i18n/lt.json index e656754c7d3ef..dfaf64e406e94 100644 --- a/web/src/lib/i18n/lt.json +++ b/web/src/lib/i18n/lt.json @@ -7,6 +7,7 @@ "actions": "Veiksmai", "active": "Vykdoma", "activity": "Veikla", + "activity_changed": "Veikla yra {enabled, select, true {įjungta} other {išjungta}}", "add": "Pridėti", "add_a_description": "Pridėti aprašymą", "add_a_location": "Pridėti vietovę", @@ -34,43 +35,51 @@ "config_set_by_file": "Konfigūracija dabar nustatyta konfigūracinio failo", "confirm_delete_library": "Ar tikrai norite ištrinti {library} biblioteką?", "confirm_email_below": "Patvirtinimui įveskite \"{email}\" žemiau", + "confirm_reprocess_all_faces": "Ar tikrai norite iš naujo apdoroti visus veidus? Tai taip pat ištrins įvardytus asmenis.", "confirm_user_password_reset": "Ar tikrai norite iš naujo nustatyti {user} slaptažodį?", "crontab_guru": "", "disable_login": "Išjungti prisijungimą", "disabled": "", - "duplicate_detection_job_description": "", + "duplicate_detection_job_description": "Vykdykite mašininį mokymąsi tam, kad aptiktumėte panašius vaizdus. Nuo šios funkcijos priklauso išmanioji paieška", "exclusion_pattern_description": "Išimčių šablonai leidžia nepaisyti failų ir aplankų skenuojant jūsų biblioteką. Tai yra naudinga, jei turite aplankų su failais, kurių nenorite importuoti, pavyzdžiui, RAW failai.", "external_library_created_at": "Išorinė biblioteka (sukurta {date})", "external_library_management": "Išorinių bibliotekų tvarkymas", "face_detection": "Veido atpažinimas", - "image_format_description": "", - "image_prefer_embedded_preview": "", + "failed_job_command": "Darbo {job} komanda {command} nepavyko", + "force_delete_user_warning": "ĮSPĖJIMAS: Šis veiksmas iš karto pašalins naudotoją ir visą jo informaciją. Šis žingsnis nesugrąžinamas ir failų nebus galima atkurti.", + "forcing_refresh_library_files": "Priverstinai atnaujinami visi failai bilbiotekoje", + "image_format_description": "WebP sukuria mažesnius failus nei JPEG, bet lėčiau juos apdoroja.", + "image_prefer_embedded_preview": "Pageidautinai rodyti įterptą peržiūrą", "image_prefer_embedded_preview_setting_description": "", - "image_prefer_wide_gamut": "", + "image_prefer_wide_gamut": "Teikti pirmenybę plačiai gamai", "image_prefer_wide_gamut_setting_description": "", "image_preview_format": "Peržiūros formatas", "image_preview_resolution": "Peržiūros rezoliucija", - "image_preview_resolution_description": "", + "image_preview_resolution_description": "Naudojama peržiūrint vieną nuotrauką ir mašininiam mokymui. Didesnė rezoliucija gali išsaugoti daugiau detalių, bet ilgiau užtrukti apdoroti ir sumažinti programos greitumą.", "image_quality": "Kokybė", "image_quality_description": "Vaizdo kokybė nuo 1 iki 100. Aukštesnė kokybė yra geresnė, tačiau sukuriami didesni failai. Ši parinktis turi įtakos peržiūros ir miniatiūrų vaizdams.", - "image_settings": "", - "image_settings_description": "", + "image_settings": "Nuotraukos nustatymai", + "image_settings_description": "Keisti sugeneruotų nuotraukų kokybę ir rezoliuciją", "image_thumbnail_format": "Miniatūros formatas", "image_thumbnail_resolution": "Miniatūros rezoliucija", - "image_thumbnail_resolution_description": "", - "job_settings": "", - "job_settings_description": "", + "image_thumbnail_resolution_description": "Naudojama žiūrint nuotraukų grupes (pagrindinis nuotraukų puslapis, albumų peržiūra ir t.t.). Aukštesnė rezoliucija gali išlaikyti daugiau detalių, bet užtrunka ilgiau apdoroti, gali turėti didesnius failų dydžius ir gali sumažinti programos greitumą.", + "job_concurrency": "{job} lygiagretumas", + "job_not_concurrency_safe": "Šis darbas nėra saugus apdoroti lygiagrečiai.", + "job_settings": "Darbo nustatymai", + "job_settings_description": "Keisti darbų lygiagretumą", "job_status": "Darbų būsenos", "library_created": "Sukurta biblioteka: {library}", "library_cron_expression": "Cron išraiška", + "library_cron_expression_description": "Nustatykite nuskaitymo intervalą naudodami „cron“ formatą. Daugiau informacijos rasite pvz. Crontab Guru", "library_cron_expression_presets": "", "library_deleted": "Biblioteka ištrinta", + "library_import_path_description": "Nurodykite aplanką, kurį norite importuoti. Šiame aplanke, įskaitant poaplankius, bus nuskaityti vaizdai ir vaizdo įrašai.", "library_scanning": "Periodinis skanavimas", "library_scanning_description": "Konfigūruoti periodinį bibliotekos skanavimą", "library_scanning_enable_description": "Įgalinti periodinį bibliotekos skanavimą", "library_settings": "Išorinė biblioteka", "library_settings_description": "Tvarkyti išorinės bibliotekos parametrus", - "library_tasks_description": "", + "library_tasks_description": "Atlikit bibliotekos užduotis", "library_watching_enable_description": "", "library_watching_settings": "", "library_watching_settings_description": "", @@ -83,7 +92,7 @@ "machine_learning_duplicate_detection_enabled_description": "", "machine_learning_duplicate_detection_setting_description": "", "machine_learning_enabled": "Įgalinti mašininį mokymąsi", - "machine_learning_enabled_description": "", + "machine_learning_enabled_description": "Jei išjungta, visos „ML“ funkcijos bus išjungtos, nepaisant toliau pateiktų nustatymų.", "machine_learning_facial_recognition": "Veido atpažinimas", "machine_learning_facial_recognition_description": "Aptikti, atpažinti ir sugrupuoti veidus nuotraukose", "machine_learning_facial_recognition_model": "Veido atpažinimo modelis", @@ -91,20 +100,20 @@ "machine_learning_facial_recognition_setting": "Įgalinti veido atpažinimą", "machine_learning_facial_recognition_setting_description": "", "machine_learning_max_detection_distance": "", - "machine_learning_max_detection_distance_description": "", - "machine_learning_max_recognition_distance": "", + "machine_learning_max_detection_distance_description": "Didžiausias atstumas tarp dviejų vaizdų, kad jie būtų laikomi dublikatais, svyruoja nuo 0,001 iki 0,1. Didesnės vertės aptiks daugiau dublikatų, tačiau gali būti klaidingai teigiami.", + "machine_learning_max_recognition_distance": "Maksimalus atpažinimo atstumas", "machine_learning_max_recognition_distance_description": "", "machine_learning_min_detection_score": "", "machine_learning_min_detection_score_description": "", "machine_learning_min_recognized_faces": "", - "machine_learning_min_recognized_faces_description": "", + "machine_learning_min_recognized_faces_description": "Mažiausias atpažintų veidų skaičius asmeniui, kurį reikia sukurti. Tai padidinus, veido atpažinimas tampa tikslesnis, bet padidėja tikimybė, kad veidas žmogui nepriskirtas.", "machine_learning_settings": "Mašininio mokymosi nustatymai", "machine_learning_settings_description": "Tvarkyti mašininio mokymosi funkcijas ir nustatymus", "machine_learning_smart_search": "Išmanioji paieška", "machine_learning_smart_search_description": "", "machine_learning_smart_search_enabled": "Įjungti išmaniąją paiešką", - "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", + "machine_learning_smart_search_enabled_description": "Jei išjungta, vaizdai nebus užkoduoti išmaniajai paieškai.", + "machine_learning_url_description": "Mašininio mokymosi serverio URL", "manage_log_settings": "", "map_dark_style": "Tamsioji tema", "map_enable_description": "", @@ -190,20 +199,21 @@ "thumbnail_generation_job": "Generuoti miniatiūras", "thumbnail_generation_job_description": "", "transcode_policy_description": "", - "transcoding_acceleration_api": "", + "transcoding_acceleration_api": "Spartinimo API", "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", + "transcoding_acceleration_nvenc": "NVENC (reikalinga NVIDIA GPU)", "transcoding_acceleration_qsv": "", "transcoding_acceleration_rkmpp": "", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "", "transcoding_accepted_audio_codecs_description": "", + "transcoding_accepted_containers": "Priimami konteineriai", "transcoding_accepted_video_codecs": "", "transcoding_accepted_video_codecs_description": "", "transcoding_advanced_options_description": "Parinktys, kurių daugelis vartotojų keisti neturėtų", "transcoding_audio_codec": "Garso kodekas", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", + "transcoding_audio_codec_description": "Opus yra aukščiausios kokybės variantas, tačiau turi mažesnį suderinamumą su senesniais įrenginiais ar programine įranga.", + "transcoding_bitrate_description": "Vaizdo įrašai viršija maksimalią leistiną bitų spartą arba nėra priimtino formato", "transcoding_constant_quality_mode": "Pastovios kokybės režimas", "transcoding_constant_quality_mode_description": "", "transcoding_constant_rate_factor": "", @@ -216,7 +226,7 @@ "transcoding_hevc_codec": "HEVC kodekas", "transcoding_max_b_frames": "", "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", + "transcoding_max_bitrate": "Maksimalus bitų srautas", "transcoding_max_bitrate_description": "", "transcoding_max_keyframe_interval": "", "transcoding_max_keyframe_interval_description": "", @@ -785,13 +795,27 @@ "public_share": "", "purchase_account_info": "Rėmėjas", "purchase_activated_subtitle": "Dėkojame, kad remiate Immich ir atviro kodo programinę įrangą", + "purchase_activated_time": "Suaktyvinta {date, date}", "purchase_activated_title": "Jūsų raktas sėkmingai aktyvuotas", "purchase_button_activate": "Aktyvuoti", "purchase_button_buy": "Pirkti", "purchase_button_buy_immich": "Pirkti Immich", + "purchase_button_never_show_again": "Niekada daugiau nerodyti", + "purchase_button_reminder": "Priminti man po 30 dienų", + "purchase_button_remove_key": "Pašalinti produkto rakta", "purchase_button_select": "Pasirinkti", + "purchase_failed_activation": "Nepavyko suaktyvinti! Patikrinkite el. paštą, ar turite teisingo produkto koda!", + "purchase_individual_description_1": "Asmeniui", "purchase_individual_description_2": "Rėmėjo statusas", "purchase_input_suggestion": "Turite produkto raktą? Įveskite jį žemiau", + "purchase_license_subtitle": "Įsigykite „Immich“, kad palaikytumėte tolesnį paslaugos vystymą", + "purchase_lifetime_description": "Pirkimas visam gyvenimui", + "purchase_option_title": "PIRKIMO PASIRINKIMAS", + "purchase_panel_info_1": "„Immich“ kūrimas užima daug laiko ir pastangų, o visą darbo dieną dirba inžinieriai, kad jis būtų kuo geresnis. Mūsų misija yra, kad atvirojo kodo programinė įranga ir etiška verslo praktika taptų tvariu programuotojų pajamų šaltiniu ir sukurtų privatumą gerbiančią ekosistemą su realiomis alternatyvomis išnaudojamoms debesijos paslaugoms.", + "purchase_panel_info_2": "Kadangi esame įsipareigoję nepridėti mokamų sienų, šis pirkinys nesuteiks jums jokių papildomų „Immich“ funkcijų. Mes tikime, kad tokie vartotojai kaip jūs palaikys nuolatinį „Immich“ vystymąsi.", + "purchase_panel_title": "Palaikykite projektą", + "purchase_per_server": "Vienam serveriui", + "purchase_per_user": "Vienam naudotojui", "purchase_remove_product_key": "Pašalinti produkto raktą", "purchase_remove_product_key_prompt": "Ar tikrai norite pašalinti produkto raktą?", "purchase_remove_server_product_key": "Pašalinti serverio produkto raktą", @@ -801,6 +825,7 @@ "purchase_server_title": "Serveris", "purchase_settings_server_activated": "Serverio produkto raktas yra tvarkomas administratoriaus", "range": "", + "rating": "Įvertinimas žvaigždutėmis", "raw": "", "reaction_options": "", "read_changelog": "", diff --git a/web/src/lib/i18n/mn.json b/web/src/lib/i18n/mn.json index 54a4710a0366e..1bd96a43fd323 100644 --- a/web/src/lib/i18n/mn.json +++ b/web/src/lib/i18n/mn.json @@ -1,32 +1,40 @@ { - "account": "", - "acknowledge": "", - "action": "", - "actions": "", - "active": "", - "activity": "", - "add": "", - "add_a_description": "", - "add_a_location": "", - "add_a_name": "", - "add_a_title": "", + "about": "Тухай", + "account": "Бүртгэл", + "account_settings": "Бүртгэлийн тохиргоо", + "acknowledge": "Ойлголоо", + "action": "Үйлдэл", + "actions": "Үйлдлүүд", + "active": "Идэвхтэй", + "activity": "Үйлдлийн бүртгэл", + "activity_changed": "Үйлдлийн бүртгэл {enabled, select, true {идэвхтэй} other {идэвхгүй}}", + "add": "Нэмэх", + "add_a_description": "Тайлбар оруулах", + "add_a_location": "Байршил нэмэх", + "add_a_name": "Нэр өгөх", + "add_a_title": "Гарчиг оруулах", "add_exclusion_pattern": "", "add_import_path": "", - "add_location": "", - "add_more_users": "", - "add_partner": "", + "add_location": "Байршил оруулах", + "add_more_users": "Өөр хэрэглэгчид нэмэх", + "add_partner": "Хамтрагч нэмэх", "add_path": "", - "add_photos": "", + "add_photos": "Зураг нэмэх", "add_to": "", - "add_to_album": "", - "add_to_shared_album": "", + "add_to_album": "Цомогт оруулах", + "add_to_shared_album": "Нээлттэй албумд оруулах", + "added_to_archive": "Архивд оруулах", + "added_to_favorites": "Дуртай зурганд нэмэх", + "added_to_favorites_count": "Дуртай зурагнуудад {count, number} нэмэгдлээ", "admin": { - "authentication_settings": "", + "authentication_settings": "Танин нэвтрэлт тохиргоо", "authentication_settings_description": "", + "check_all": "Бүгдийг сонгох", "crontab_guru": "", "disable_login": "", "disabled": "", "duplicate_detection_job_description": "", + "face_detection": "Нүүр илрүүлэх", "image_format_description": "", "image_prefer_embedded_preview": "", "image_prefer_embedded_preview_setting_description": "", @@ -35,15 +43,16 @@ "image_preview_format": "", "image_preview_resolution": "", "image_preview_resolution_description": "", - "image_quality": "", + "image_quality": "Чанар", "image_quality_description": "", "image_settings": "", "image_settings_description": "", "image_thumbnail_format": "", "image_thumbnail_resolution": "", "image_thumbnail_resolution_description": "", - "job_settings": "", + "job_settings": "Ажлын тохиргоо", "job_settings_description": "", + "job_status": "Ажлын төлөв", "library_cron_expression": "", "library_cron_expression_presets": "", "library_scanning": "", @@ -62,11 +71,13 @@ "machine_learning_duplicate_detection": "", "machine_learning_duplicate_detection_enabled_description": "", "machine_learning_duplicate_detection_setting_description": "", - "machine_learning_enabled_description": "", - "machine_learning_facial_recognition": "", - "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", - "machine_learning_facial_recognition_model_description": "", + "machine_learning_enabled": "Машин сургалт идэвхжүүлэх", + "machine_learning_enabled_description": "Идэвхгүй болгосон үед доорх тохиргооноос хамаарахгүйгээр бүх машин сургалтын боломж идэвхгүй болно.", + "machine_learning_facial_recognition": "Нүүр танилт", + "machine_learning_facial_recognition_description": "Зураг дээрх хүмүүсийн нүүрийг илрүүлж, таньж, бүлэглэнэ", + "machine_learning_facial_recognition_model": "Нүүр танилтын загвар", + "machine_learning_facial_recognition_model_description": "Загварууд хэмжээ нь буурах эрэмбээр жагссан. Том загварууд удаан, илүү их санах ой хэрэглэх боловч харьцангуй чанартай үр дүн үзүүлнэ. Загвар өөрчилсөн тохиолдолд нүүр илрүүлэлтийн ажлыг дахин эхлүүлэх шаардлагатайг санаарай.", + "machine_learning_facial_recognition_setting": "Нүүр танилт идэвхжүүлэх", "machine_learning_facial_recognition_setting_description": "", "machine_learning_max_detection_distance": "", "machine_learning_max_detection_distance_description": "", @@ -89,7 +100,7 @@ "map_reverse_geocoding": "", "map_reverse_geocoding_enable_description": "", "map_reverse_geocoding_settings": "", - "map_settings": "", + "map_settings": "Газрын зураг", "map_settings_description": "", "map_style_description": "", "metadata_extraction_job_description": "", @@ -210,14 +221,17 @@ "transcoding_two_pass_encoding_setting_description": "", "transcoding_video_codec": "", "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", + "trash_enabled_description": "Хогийн сав идэвхжүүлэх", + "trash_number_of_days": "Хоногийн тоо", + "trash_number_of_days_description": "Хогийн саванд хэд хоног хадгалаад бүр мөсөн устгах вэ", + "trash_settings": "Хогийн савны тохиргоо", + "trash_settings_description": "Хогийн савны тохиргоог өөрчлөх", "user_delete_delay_settings": "", "user_delete_delay_settings_description": "", - "user_settings": "", + "user_management": "Хэрэглэгчийн удирдлага", + "user_password_has_been_reset": "Хэрэглэгчийн нууц үг шинээр тохируулагдлаа:", + "user_restore_description": "{user}-н бүртгэл сэргэнэ.", + "user_settings": "Хэрэглэгчийн тохиргоо", "user_settings_description": "", "version_check_enabled_description": "", "version_check_settings": "", @@ -226,57 +240,70 @@ }, "admin_email": "", "admin_password": "", - "administration": "", + "administration": "Админ", "advanced": "", - "album_added": "", + "album_added": "Цомог нэмэгдлээ", "album_added_notification_setting_description": "", "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", + "album_info_updated": "Цомгийн мэлээлэл шинэчлэгдлээ", + "album_leave": "Цомгоос гарах уу?", + "album_leave_confirmation": "Та {album} цомгоос гарахдаа итгэлтэй байна уу?", + "album_name": "Цомгийн нэр", + "album_options": "Цомгийн тохиргоо", + "album_remove_user": "Хэрэглэгч хасах уу?", + "album_remove_user_confirmation": "{user} хэрэглэгчийг хасахдаа итгэлтэй байна уу?", "album_updated": "", "album_updated_setting_description": "", - "albums": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", + "albums": "Цомгууд", + "all": "Бүгд", + "all_albums": "Бүх цомог", + "all_people": "Бүх хүн", + "all_videos": "Бүх бичлэг", + "allow_dark_mode": "Харанхуй горим зөвшөөрөх", + "allow_edits": "Засварлалт зөвшөөрөх", + "api_key": "API түлхүүр", + "api_key_description": "Энэ утга зөвхөн ганц л удаа харагдана. Цонхоо хаахаас өмнө хуулж аваарай.", + "api_key_empty": "Таны API түлхүүрийн нэр хоосон байж болохгүй", + "api_keys": "API түлхүүрүүд", + "app_settings": "Апп-н тохиргоо", "appears_in": "", - "archive": "", - "archive_or_unarchive_photo": "", + "archive": "Архив", + "archive_or_unarchive_photo": "Зургийг архивт хийх эсвэл гаргах", + "archive_size": "Архивын хэмжээ", + "archive_size_description": "Татах үеийн архивын хэмжээг тохируулах (GiB-р)", "archived": "", + "asset_added_to_album": "Цомогт нэмсэн", + "asset_adding_to_album": "Цомогт нэмж байна...", "asset_offline": "", "assets": "", "authorized_devices": "", "back": "", "backward": "", "blurred_background": "", - "camera": "", - "camera_brand": "", - "camera_model": "", + "buy": "Immich худалдаж авах", + "camera": "Камер", + "camera_brand": "Камерын үйлдвэр", + "camera_model": "Камерын загвар", "cancel": "Цуцлах", - "cancel_search": "", + "cancel_search": "Хайлт цуцлах", "cannot_merge_people": "", "cannot_update_the_description": "", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", - "change_date": "", + "change_date": "Огноо өөрчлөх", "change_expiration_time": "", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "", + "change_location": "Байршил өөрчлөх", + "change_name": "Нэр өөрчлөх", + "change_name_successfully": "Нэр амжилттай өөрчлөгдлөө", + "change_password": "Нууц үг өөрчлөх", "change_your_password": "", "changed_visibility_successfully": "", "check_logs": "", - "city": "", - "clear": "", - "clear_all": "", + "city": "Хот", + "clear": "Цэвэрлэх", + "clear_all": "Бүгдийг цэвэрлэх", "clear_message": "", "clear_value": "", "close": "", @@ -371,7 +398,7 @@ "email": "", "empty": "", "empty_album": "", - "empty_trash": "", + "empty_trash": "Хогийн сав хоослох", "enable": "", "enabled": "", "end_date": "", @@ -392,7 +419,7 @@ "unable_to_delete_album": "", "unable_to_delete_asset": "", "unable_to_delete_user": "", - "unable_to_empty_trash": "", + "unable_to_empty_trash": "Хогийн савыг хоослож чадсангүй", "unable_to_enter_fullscreen": "", "unable_to_exit_fullscreen": "", "unable_to_hide_person": "", @@ -412,7 +439,7 @@ "unable_to_reset_password": "", "unable_to_resolve_duplicate": "", "unable_to_restore_assets": "", - "unable_to_restore_trash": "", + "unable_to_restore_trash": "Хогийн савнаас гаргаж чадсангүй", "unable_to_restore_user": "", "unable_to_save_album": "", "unable_to_save_name": "", @@ -437,13 +464,13 @@ "expand_all": "", "expire_after": "", "expired": "", - "explore": "", + "explore": "Эрж олох", "extension": "", "external_libraries": "", "failed_to_get_people": "", "favorite": "", "favorite_or_unfavorite_photo": "", - "favorites": "", + "favorites": "Дуртай", "feature": "", "feature_photo_updated": "", "featurecollection": "", @@ -485,7 +512,7 @@ "night_at_midnight": "", "night_at_twoam": "" }, - "invite_people": "", + "invite_people": "Хүмүүс урих", "invite_to_album": "", "job_settings_description": "", "jobs": "", @@ -497,7 +524,7 @@ "leave": "", "let_others_respond": "", "level": "", - "library": "", + "library": "Зургийн сан", "library_options": "", "light": "", "link_options": "", @@ -551,9 +578,9 @@ "no": "", "no_albums_message": "", "no_archived_assets_message": "", - "no_assets_message": "", + "no_assets_message": "Энд дарж та эхний зургаа хуулж үзэх үү", "no_exif_info_available": "", - "no_explore_results_message": "", + "no_explore_results_message": "Зураг хуулж оруулсаны дараа ашиглах боломжтой болно.", "no_favorites_message": "", "no_libraries_message": "", "no_name": "", @@ -570,7 +597,7 @@ "ok": "", "oldest_first": "", "online": "", - "only_favorites": "", + "only_favorites": "Зөвхөн дуртай зурагнууд", "only_refreshes_modified_files": "", "open_the_search_filters": "", "options": "", @@ -597,7 +624,7 @@ "pause_memories": "", "paused": "", "pending": "", - "people": "", + "people": "Хүмүүс", "people_sidebar_description": "", "perform_library_tasks": "", "permanent_deletion_warning": "", @@ -608,7 +635,7 @@ "photos_from_previous_years": "", "pick_a_location": "", "place": "", - "places": "", + "places": "Байршилууд", "play": "", "play_memories": "", "play_motion_photo": "", @@ -634,9 +661,11 @@ "refreshes_every_file": "", "remove": "", "remove_from_album": "", - "remove_from_favorites": "", + "remove_from_favorites": "Дуртай зурагнуудаас хасах", "remove_from_shared_link": "", "remove_offline_files": "", + "removed_from_favorites": "Дуртай зурагнуудаас хасагдсан", + "removed_from_favorites_count": "Дуртай зурагнуудаас {count, plural, other {Removed #}} хасагдлаа", "repair": "", "repair_no_results_message": "", "replace_with_upload": "", @@ -667,11 +696,11 @@ "search_country": "", "search_for_existing_person": "", "search_people": "", - "search_places": "", + "search_places": "Байршил хайх", "search_state": "", "search_timezone": "", "search_type": "", - "search_your_photos": "", + "search_your_photos": "Зурагнуудаасаа хайлт хийх", "searching_locales": "", "second": "", "select_album_cover": "", @@ -685,6 +714,7 @@ "selected": "", "send_message": "", "server": "", + "server_online": "Сервер Онлайн", "server_stats": "", "set": "", "set_as_album_cover": "", @@ -699,7 +729,7 @@ "shared_by": "", "shared_by_you": "", "shared_links": "", - "sharing": "", + "sharing": "Хуваалцах", "sharing_sidebar_description": "", "show_album_options": "", "show_file_location": "", @@ -715,6 +745,7 @@ "show_progress_bar": "", "show_search_options": "", "shuffle": "", + "sign_out": "Гарах", "sign_up": "", "size": "", "skip_to_content": "", @@ -728,8 +759,9 @@ "state": "", "status": "", "stop_motion_photo": "", - "storage": "", + "storage": "Дискний багтаамж", "storage_label": "", + "storage_usage": "Нийт {available} боломжтойгоос {used} хэрэглэсэн", "submit": "", "suggestions": "", "sunrise_on_the_beach": "", @@ -745,7 +777,7 @@ "toggle_theme": "", "toggle_visibility": "", "total_usage": "", - "trash": "", + "trash": "Хогийн сав", "trash_all": "", "trash_no_results_message": "", "type": "", @@ -762,7 +794,7 @@ "unstack": "", "up_next": "", "updated_password": "", - "upload": "", + "upload": "Зураг хуулах", "upload_concurrency": "", "url": "", "usage": "", @@ -771,15 +803,15 @@ "user_usage_detail": "", "username": "", "users": "", - "utilities": "", + "utilities": "Багаж хэрэгсэл", "validate": "", "variables": "", "version": "", "video": "", "video_hover_setting_description": "", "videos": "", - "view_all": "", - "view_all_users": "", + "view_all": "Бүгдийг харах", + "view_all_users": "Бүх хэрэглэгчийг харах", "view_links": "", "view_next_asset": "", "view_previous_asset": "", diff --git a/web/src/lib/i18n/nb_NO.json b/web/src/lib/i18n/nb_NO.json index b3851a22477fc..df56d27a237bf 100644 --- a/web/src/lib/i18n/nb_NO.json +++ b/web/src/lib/i18n/nb_NO.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Legg til delt album", "added_to_archive": "Lagt til i arkiv", "added_to_favorites": "Lagt til i favoritter", - "added_to_favorites_count": "Lagt til {count} i favoritter", + "added_to_favorites_count": "Lagt til {count, number} i favoritter", "admin": { "add_exclusion_pattern_description": "Legg til ekskluderingsmønstre. Globbing med *, ** og ? støttes. For å ignorere alle filer i en hvilken som helst mappe som heter \"Raw\", bruk \"**/Raw/**\". For å ignorere alle filer som slutter på \".tif\", bruk \"**/*.tif\". For å ignorere en absolutt filplassering, bruk \"/filbane/til/ignorer/**\".", "authentication_settings": "Autentiserings innstillinger", @@ -128,6 +128,7 @@ "map_dark_style": "Mørk stil", "map_enable_description": "Aktiver kartfunksjoner", "map_gps_settings": "Kart & GPS Innstillinger", + "map_gps_settings_description": "Administrer innstillinger for kart og GPS (Reversert geokoding)", "map_light_style": "Lys stil", "map_reverse_geocoding": "Omvendt geokoding", "map_reverse_geocoding_enable_description": "Aktiver omvendt geokoding", @@ -319,6 +320,7 @@ "user_settings_description": "Administrer brukerinnstillinger", "user_successfully_removed": "Brukeren {email} er nå fjernet.", "version_check_enabled_description": "Aktiver periodiske forespørsler til GitHub for å sjekke etter nye utgivelser", + "version_check_implications": "Versjonssjekkfunksjonen baserer seg på periodisk kommunikasjon med github.com", "version_check_settings": "Versjonssjekk", "version_check_settings_description": "Aktiver/deaktiver varsel om ny versjon", "video_conversion_job": "Transkod videoer", @@ -334,6 +336,7 @@ "album_added_notification_setting_description": "Motta en e-postvarsling når du legges til i et delt album", "album_cover_updated": "Albumomslag oppdatert", "album_delete_confirmation": "Er du sikker på at du vil slette albumet {album}?\nHvis dette albumet er delt, vil ikke andre brukere ha tilgang til det lenger.", + "album_delete_confirmation_description": "Hvis dette albumet deles, vil andre brukere miste tilgangen til dette.", "album_info_updated": "Albuminformasjon oppdatert", "album_leave": "Forlate album?", "album_leave_confirmation": "Er du sikker på at du vil forlate {album}?", @@ -357,6 +360,7 @@ "allow_edits": "Tillat redigering", "allow_public_user_to_download": "Tillat uautentiserte brukere å laste ned", "allow_public_user_to_upload": "Tillat uautentiserte brukere å laste opp", + "anti_clockwise": "Mot klokken", "api_key": "API Nøkkel", "api_key_description": "Denne verdien vil vises kun én gang. Pass på å kopiere den før du lukker vinduet.", "api_key_empty": "API Key-navnet bør ikke være tomt", diff --git a/web/src/lib/i18n/nl.json b/web/src/lib/i18n/nl.json index 36f9886b04d29..ccf36827fe72b 100644 --- a/web/src/lib/i18n/nl.json +++ b/web/src/lib/i18n/nl.json @@ -174,7 +174,7 @@ "oauth_issuer_url": "Uitgever URL", "oauth_mobile_redirect_uri": "Omleidings URI voor mobiel", "oauth_mobile_redirect_uri_override": "Omleidings URI voor mobiele app overschrijven", - "oauth_mobile_redirect_uri_override_description": "Inschakelen wanneer 'app.immich:/' een ongeldige omleidings-URI is.", + "oauth_mobile_redirect_uri_override_description": "Inschakelen wanneer de OAuth-provider geen mobiele URI toestaat, zoals '{callback}'", "oauth_profile_signing_algorithm": "Algoritme voor profielondertekening", "oauth_profile_signing_algorithm_description": "Algoritme voor het ondertekenen van het gebruikersprofiel.", "oauth_scope": "Scope", @@ -338,7 +338,8 @@ "album_added": "Album toegevoegd", "album_added_notification_setting_description": "Ontvang een e-mailmelding wanneer je aan een gedeeld album wordt toegevoegd", "album_cover_updated": "Album cover is bijgewerkt", - "album_delete_confirmation": "Weet je zeker dat je het album {album} wilt verwijderen?\nAls dit album gedeeld is, hebben andere gebruikers er geen toegang meer toe.", + "album_delete_confirmation": "Weet je zeker dat je het album {album} wilt verwijderen?", + "album_delete_confirmation_description": "Als dit album gedeeld is, hebben andere gebruikers er geen toegang meer toe.", "album_info_updated": "Albumgegevens bijgewerkt", "album_leave": "Album verlaten?", "album_leave_confirmation": "Weet je zeker dat je {album} wilt verlaten?", @@ -362,6 +363,7 @@ "allow_edits": "Bewerkingen toestaan", "allow_public_user_to_download": "Sta openbare gebruiker toe om te downloaden", "allow_public_user_to_upload": "Sta openbare gebruiker toe om te uploaden", + "anti_clockwise": "Linksom", "api_key": "API sleutel", "api_key_description": "Deze waarde wordt slechts één keer getoond. Zorg ervoor dat je deze kopieert voordat je het venster sluit.", "api_key_empty": "De naam van uw API sleutel mag niet leeg zijn", @@ -443,9 +445,11 @@ "clear_all_recent_searches": "Wis alle recente zoekopdrachten", "clear_message": "Bericht wissen", "clear_value": "Waarde wissen", + "clockwise": "Rechtsom", "close": "Sluiten", "collapse": "Inklappen", "collapse_all": "Alles inklappen", + "color": "Kleur", "color_theme": "Kleurthema", "comment_deleted": "Opmerking verwijderd", "comment_options": "Opties voor opmerkingen", @@ -479,6 +483,8 @@ "create_new_person": "Nieuwe persoon aanmaken", "create_new_person_hint": "Geselecteerde assets toewijzen aan een nieuwe persoon", "create_new_user": "Nieuwe gebruiker aanmaken", + "create_tag": "Tag aanmaken", + "create_tag_description": "Maak een nieuwe tag. Voor geneste tags, voer het volledige pad van de tag in, inclusief schuine strepen.", "create_user": "Gebruiker aanmaken", "created": "Aangemaakt", "current_device": "Huidig apparaat", @@ -502,6 +508,8 @@ "delete_library": "Verwijder bibliotheek", "delete_link": "Verwijder link", "delete_shared_link": "Verwijder gedeelde link", + "delete_tag": "Tag verwijderen", + "delete_tag_confirmation_prompt": "Weet je zeker dat je de tag {tagName} wilt verwijderen?", "delete_user": "Verwijder gebruiker", "deleted_shared_link": "Gedeelde link verwijderd", "description": "Beschrijving", @@ -519,6 +527,8 @@ "do_not_show_again": "Laat dit bericht niet meer zien", "done": "Klaar", "download": "Downloaden", + "download_include_embedded_motion_videos": "Ingesloten video's", + "download_include_embedded_motion_videos_description": "Voeg video's toe die ingesloten zijn in bewegende foto's als een apart bestand", "download_settings": "Downloaden", "download_settings_description": "Beheer instellingen voor het downloaden van assets", "downloading": "Downloaden", @@ -548,10 +558,15 @@ "edit_location": "Locatie bewerken", "edit_name": "Naam bewerken", "edit_people": "Mensen bewerken", + "edit_tag": "Tag bewerken", "edit_title": "Titel bewerken", "edit_user": "Gebruiker bewerken", "edited": "Bijgewerkt", "editor": "Bewerker", + "editor_close_without_save_prompt": "De wijzigingen worden niet opgeslagen", + "editor_close_without_save_title": "Editor sluiten?", + "editor_crop_tool_h2_aspect_ratios": "Beeldverhoudingen", + "editor_crop_tool_h2_rotation": "Rotatie", "email": "E-mailadres", "empty": "", "empty_album": "Leeg album", @@ -701,6 +716,7 @@ "expired": "Verlopen", "expires_date": "Verloopt {date}", "explore": "Verkennen", + "explorer": "Verkenner", "export": "Exporteren", "export_as_json": "Exporteren als JSON", "extension": "Extensie", @@ -714,6 +730,8 @@ "feature": "", "feature_photo_updated": "Uitgelichte afbeelding bijgewerkt", "featurecollection": "", + "features": "Functies", + "features_setting_description": "Beheer de app functies", "file_name": "Bestandsnaam", "file_name_or_extension": "Bestandsnaam of extensie", "filename": "Bestandsnaam", @@ -722,6 +740,8 @@ "filter_people": "Filter op mensen", "find_them_fast": "Vind ze snel op naam door te zoeken", "fix_incorrect_match": "Onjuiste overeenkomst corrigeren", + "folders": "Mappen", + "folders_feature_description": "Bladeren door de mapweergave van de foto's en video's op het bestandssysteem", "force_re-scan_library_files": "Forceer herscan van alle bibliotheekbestanden", "forward": "Vooruit", "general": "Algemeen", @@ -923,7 +943,7 @@ "only_favorites": "Alleen favorieten", "only_refreshes_modified_files": "Vernieuwt alleen gewijzigde bestanden", "open_in_map_view": "Openen in kaartweergave", - "open_in_openstreetmap": "Openen met OpenStreetMap", + "open_in_openstreetmap": "Openen in OpenStreetMap", "open_the_search_filters": "Open de zoekfilters", "options": "Opties", "or": "of", @@ -957,6 +977,7 @@ "pending": "In behandeling", "people": "Mensen", "people_edits_count": "{count, plural, one {# persoon} other {# mensen}} bijgewerkt", + "people_feature_description": "Bladeren door foto's en video's gegroepeerd op personen", "people_sidebar_description": "Toon een link naar Mensen in de zijbalk", "perform_library_tasks": "", "permanent_deletion_warning": "Waarschuwing voor permanent verwijderen", @@ -1028,7 +1049,9 @@ "purchase_settings_server_activated": "De productcode van de server wordt beheerd door de beheerder", "range": "", "rating": "Ster waardering", - "rating_description": "De exif-waardering weergeven in het infopaneel", + "rating_clear": "Waardering verwijderen", + "rating_count": "{count, plural, one {# ster} other {# sterren}}", + "rating_description": "De EXIF-waardering weergeven in het infopaneel", "raw": "", "reaction_options": "Reactie opties", "read_changelog": "Lees wijzigingen", @@ -1061,6 +1084,7 @@ "removed_from_archive": "Verwijderd uit archief", "removed_from_favorites": "Verwijderd uit favorieten", "removed_from_favorites_count": "{count, plural, other {# verwijderd}} uit favorieten", + "removed_tagged_assets": "Tag verwijderd van {count, plural, one {# asset} other {# assets}}", "rename": "Hernoemen", "repair": "Repareren", "repair_no_results_message": "Niet bijgehouden en ontbrekende bestanden zullen hier verschijnen", @@ -1110,6 +1134,7 @@ "search_people": "Zoek mensen", "search_places": "Zoek plaatsen", "search_state": "Zoek staat...", + "search_tags": "Tags zoeken...", "search_timezone": "Zoek tijdzone...", "search_type": "Type zoekopdracht", "search_your_photos": "Foto's doorzoeken", @@ -1178,6 +1203,8 @@ "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Toon een supporterbadge", "shuffle": "Willekeurig", + "sidebar": "Zijbalk", + "sidebar_display_description": "Toon een link naar deze pagina in de zijbalk", "sign_out": "Uitloggen", "sign_up": "Registreren", "size": "Grootte", @@ -1214,6 +1241,13 @@ "sunrise_on_the_beach": "Zonsopkomst op het strand", "swap_merge_direction": "Wissel richting voor samenvoegen om", "sync": "Sync", + "tag": "Tag", + "tag_assets": "Assets taggen", + "tag_created": "Tag aangemaakt: {tag}", + "tag_feature_description": "Bladeren door foto's en video's gegroepeerd op tags", + "tag_updated": "Tag bijgewerkt: {tag}", + "tagged_assets": "{count, plural, one {# asset} other {# assets}} getagd", + "tags": "Tags", "template": "Template", "theme": "Thema", "theme_selection": "Thema selectie", @@ -1225,9 +1259,10 @@ "to_change_password": "Wijzig wachtwoord", "to_favorite": "Toevoegen aan favorieten", "to_login": "Inloggen", + "to_root": "Naar hoofdmap", "to_trash": "Prullenbak", "toggle_settings": "Zichtbaarheid instellingen wisselen", - "toggle_theme": "Thema wisselen", + "toggle_theme": "Donker thema toepassen", "toggle_visibility": "Zichtbaarheid wisselen", "total_usage": "Totaal gebruik", "trash": "Prullenbak", @@ -1249,6 +1284,7 @@ "unlink_oauth": "Ontkoppel OAuth", "unlinked_oauth_account": "OAuth account ontkoppeld", "unnamed_album": "Naamloos album", + "unnamed_album_delete_confirmation": "Weet je zeker dat je dit album wilt verwijderen?", "unnamed_share": "Naamloze deellink", "unsaved_change": "Niet-opgeslagen wijziging", "unselect_all": "Alles deselecteren", @@ -1297,6 +1333,7 @@ "view_album": "Bekijk album", "view_all": "Bekijk alle", "view_all_users": "Bekijk alle gebruikers", + "view_in_timeline": "Bekijk in tijdlijn", "view_links": "Links bekijken", "view_next_asset": "Bekijk volgende asset", "view_previous_asset": "Bekijk vorige asset", diff --git a/web/src/lib/i18n/pl.json b/web/src/lib/i18n/pl.json index 682f6fcb55ab1..267afd0141634 100644 --- a/web/src/lib/i18n/pl.json +++ b/web/src/lib/i18n/pl.json @@ -57,7 +57,7 @@ "image_format_description": "Użycie formatu WebP skutkuje utworzeniem plików o rozmiarze mniejszym niż w przypadku JPEG ale jego kodowanie trwa dłużej.", "image_prefer_embedded_preview": "Preferuj podgląd wbudowany", "image_prefer_embedded_preview_setting_description": "Jeśli to możliwe, używaj osadzonych podglądów w zdjęciach RAW jako danych wejściowych do przetwarzania obrazu. Może to zapewnić dokładniejsze kolory w przypadku niektórych obrazów, ale jakość podglądu zależy od aparatu, a obraz może zawierać więcej artefaktów kompresji.", - "image_prefer_wide_gamut": "Preferuj szeroką przestrzeń barw", + "image_prefer_wide_gamut": "Preferuj szeroką gamę kolorów", "image_prefer_wide_gamut_setting_description": "Do wyświetlania miniatur użyj wyświetlacza P3. Dzięki temu lepiej zachowuje się intensywność obrazów o dużej ilości kolorów, ale obrazy mogą wyglądać inaczej na starych urządzeniach ze starą wersją przeglądarki. Obrazy sRGB są zachowywane jako sRGB, aby uniknąć przesunięć kolorów.", "image_preview_format": "Format podglądu", "image_preview_resolution": "Rozdzielczość podglądu", @@ -129,6 +129,7 @@ "map_enable_description": "Włącz funkcję mapy", "map_gps_settings": "Mapa i ustawienia lokalizacji", "map_gps_settings_description": "Zarządzaj mapą oraz ustawieniami odwróconego geokodowania", + "map_implications": "Funkcja mapy opiera się na zewnętrznej usłudze kafelków (tiles.immich.cloud)", "map_light_style": "Styl jasny", "map_manage_reverse_geocoding_settings": "Zarządzaj Ustawieniem Odwrotne Geokodowanie", "map_reverse_geocoding": "Odwrotne Geokodowanie", @@ -320,7 +321,8 @@ "user_settings": "Ustawienia Użytkownika", "user_settings_description": "Zarządzaj ustawieniami użytkownika", "user_successfully_removed": "Użytkownik {email} został usunięty pomyślnie.", - "version_check_enabled_description": "Włącz cykliczne sprawdzanie nowych wersji na GitHubie", + "version_check_enabled_description": "Włącz sprawdzanie wersji", + "version_check_implications": "Funkcja sprawdzania wersji opiera się na okresowej komunikacji z github.com", "version_check_settings": "Sprawdzenie Wersji", "version_check_settings_description": "Włącz/wyłącz powiadomienie o nowej wersji", "video_conversion_job": "Transkodowanie wideo", @@ -336,7 +338,8 @@ "album_added": "Album udostępniony", "album_added_notification_setting_description": "Otrzymaj powiadomienie email, gdy zostanie Ci udostępniony album", "album_cover_updated": "Okładka albumu została zaktualizowana", - "album_delete_confirmation": "Na pewno chcesz usunąć album {album}?\nJeśli został udostępniony, inni użytkownicy nie będą w stanie go obejrzeć.", + "album_delete_confirmation": "Czy na pewno chcesz usunąć album {album}?", + "album_delete_confirmation_description": "Jeżeli album jest udostępniany, inny stracą do niego dostęp.", "album_info_updated": "Szczegóły albumu zostały zaktualizowane", "album_leave": "Opuścić album?", "album_leave_confirmation": "Na pewno chcesz opuścić {album}?", @@ -360,6 +363,7 @@ "allow_edits": "Pozwól edytować", "allow_public_user_to_download": "Zezwól użytkownikowi publicznemu na pobieranie", "allow_public_user_to_upload": "Zezwól użytkownikowi publicznemu na przesyłanie plików", + "anti_clockwise": "Przeciwnie do ruchu wskazówek zegara", "api_key": "Klucz API", "api_key_description": "Widzisz tę wartość po raz pierwszy i ostatni, więc lepiej ją skopiuj przed zamknięciem okna.", "api_key_empty": "Twój Klucz API nie powinien być pusty", @@ -368,8 +372,8 @@ "appears_in": "W albumach", "archive": "Archiwum", "archive_or_unarchive_photo": "Dodaj lub usuń zasób z archiwum", - "archive_size": "Maksymalny Rozmiar Archiwum", - "archive_size_description": "Podziel pobierane pliki na więcej niż jedno archiwum, jeżeli rozmiar archiwum przekroczy tą wartość w GiB", + "archive_size": "Rozmiar archiwum", + "archive_size_description": "Podziel pobierane pliki na więcej niż jedno archiwum, jeżeli rozmiar archiwum przekroczy tę wartość w GiB", "archived": "Zarchiwizowano", "archived_count": "{count, plural, other {Zarchiwizowano #}}", "are_these_the_same_person": "Czy to jedna i ta sama osoba?", @@ -405,7 +409,7 @@ "birthdate_saved": "Data urodzenia zapisana pomyślnie", "birthdate_set_description": "Data urodzenia jest używana do obliczenia wieku danej osoby podczas wykonania zdjęcia.", "blurred_background": "Rozmyte tło", - "build": "Build", + "build": "Kompilacja", "build_image": "Obraz Buildu", "bulk_delete_duplicates_confirmation": "Czy na pewno chcesz trwale usunąć {count, plural, one {# zduplikowany zasób} few {# zduplikowane zasoby} many {# zduplikowanych zasobów} other {# zduplikowanych zasobów}}? Zostanie zachowany największy zasób z każdej grupy, a wszystkie pozostałe duplikaty zostaną trwale usunięte. Nie można cofnąć tej operacji!", "bulk_keep_duplicates_confirmation": "Czy na pewno chcesz zachować {count, plural, one {# zduplikowany zasób} few {# zduplikowane zasoby} many {# zduplikowanych zasobów} other {# zduplikowanych zasobów}}? To spowoduje rozwiązanie wszystkich grup duplikatów bez usuwania czegokolwiek.", @@ -441,6 +445,7 @@ "clear_all_recent_searches": "Usuń ostatnio wyszukiwane", "clear_message": "Zamknij wiadomość", "clear_value": "Wyczyść wartość", + "clockwise": "Zgodnie z ruchem wskazówek zegara", "close": "Zamknij", "collapse": "Zwiń", "collapse_all": "Zwiń wszystko", @@ -517,6 +522,8 @@ "do_not_show_again": "Nie pokazuj więcej tej wiadomości", "done": "Gotowe", "download": "Pobierz", + "download_include_embedded_motion_videos": "Osadzone filmy", + "download_include_embedded_motion_videos_description": "Dołącz filmy osadzone w ruchomych zdjęciach jako oddzielny plik", "download_settings": "Pobieranie", "download_settings_description": "Zarządzaj pobieraniem zasobów", "downloading": "Pobieranie", @@ -550,6 +557,10 @@ "edit_user": "Edytuj użytkownika", "edited": "Edytowane", "editor": "Edytor", + "editor_close_without_save_prompt": "Zmiany nie zostaną zapisane", + "editor_close_without_save_title": "Zamknąć edytor?", + "editor_crop_tool_h2_aspect_ratios": "Proporcje obrazu", + "editor_crop_tool_h2_rotation": "Obrót", "email": "E-mail", "empty": "", "empty_album": "Pusty Album", @@ -692,7 +703,7 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", - "exif": "Exif", + "exif": "Metadane EXIF", "exit_slideshow": "Zamknij Pokaz Slajdów", "expand_all": "Rozwiń wszystko", "expire_after": "Wygasa po", @@ -720,6 +731,7 @@ "filter_people": "Szukaj osoby", "find_them_fast": "Wyszukuj szybciej przypisując nazwę", "fix_incorrect_match": "Napraw nieprawidłowe dopasowanie", + "folders": "Foldery", "force_re-scan_library_files": "Wymuś ponowne przeskanowanie wszystkich plików biblioteki", "forward": "Do przodu", "general": "Ogólne", @@ -887,6 +899,7 @@ "ok": "Ok", "oldest_first": "Od najstarszych", "onboarding": "Wdrożenie", + "onboarding_privacy_description": "Śledzenie (opcjonalne) funkcja opiera się na zewnętrznych usługach i może zostać wyłączona w dowolnym momencie w ustawieniach administracyjnych.", "onboarding_theme_description": "Wybierz motyw kolorystyczny dla twojej instancji. Możesz go później zmienić w ustawieniach.", "onboarding_welcome_description": "Przejdźmy do konfiguracji twojej instancji, ustawiając kilka powszechnych opcji.", "onboarding_welcome_user": "Witaj, {user}", @@ -959,6 +972,7 @@ "previous_memory": "Poprzednie wspomnienie", "previous_or_next_photo": "Poprzednie lub następne zdjęcie", "primary": "Główny", + "privacy": "Prywatność", "profile_image_of_user": "Zdjęcie profilowe {user}", "profile_picture_set": "Zdjęcie profilowe ustawione.", "public_album": "Publiczny album", @@ -996,6 +1010,9 @@ "purchase_server_title": "Serwer", "purchase_settings_server_activated": "Klucz produktu serwera jest zarządzany przez administratora", "range": "", + "rating": "Ocena gwiazdkowa", + "rating_count": "{count, plural, one {# star} other {# stars}}", + "rating_description": "Wyświetl ocenę EXIF w panelu informacji", "raw": "", "reaction_options": "Opcje reakcji", "read_changelog": "Zobacz Zmiany", @@ -1118,6 +1135,7 @@ "shared_by_user": "Udostępnione przez {user}", "shared_by_you": "Udostępnione przez ciebie", "shared_from_partner": "Zdjęcia od {partner}", + "shared_link_options": "Opcje udostępniania linku", "shared_links": "Udostępnione linki", "shared_photos_and_videos_count": "{assetCount, plural, other {# udostępnione zdjęcia i filmy.}}", "shared_with_partner": "Dzielisz się z {partner}", @@ -1163,7 +1181,7 @@ "stack_select_one_photo": "Wybierz jedno główne zdjęcie do stosu", "stack_selected_photos": "Układaj wybrane zdjęcia", "stacked_assets_count": "Ułożone {count, plural, one {# zasób} other{# zasoby}}", - "stacktrace": "Stacktrace", + "stacktrace": "Ślad stosu", "start": "Start", "start_date": "Od dnia", "state": "Stan", @@ -1193,7 +1211,7 @@ "to_login": "Login", "to_trash": "Kosz", "toggle_settings": "Przełącz ustawienia", - "toggle_theme": "Przełącz motyw", + "toggle_theme": "Przełącz ciemny motyw", "toggle_visibility": "Zmień widoczność", "total_usage": "Całkowite wykorzystanie", "trash": "Kosz", @@ -1215,6 +1233,7 @@ "unlink_oauth": "Odłącz OAuth", "unlinked_oauth_account": "Odłączone konto OAuth", "unnamed_album": "Nienazwany album", + "unnamed_album_delete_confirmation": "Czy jesteś pewna/pewien, że chcesz usunąć te album?", "unnamed_share": "Nienazwany udział", "unsaved_change": "Niezapisana zmiana", "unselect_all": "Odznacz wszystko", diff --git a/web/src/lib/i18n/pt.json b/web/src/lib/i18n/pt.json index b146e2ee2fbda..943cde377dca9 100644 --- a/web/src/lib/i18n/pt.json +++ b/web/src/lib/i18n/pt.json @@ -129,12 +129,13 @@ "map_enable_description": "Ativar recursos do mapa", "map_gps_settings": "Mapas e Definições de GPS", "map_gps_settings_description": "Configurações de mapas e GPS (Geocoding inverso)", + "map_implications": "A funcionalidade do mapa necessita um servico externo (tiles.immich.cloud)", "map_light_style": "Tema Claro", "map_manage_reverse_geocoding_settings": "Gerir definições de Geocoding inverso", "map_reverse_geocoding": "Geocodificação reversa", "map_reverse_geocoding_enable_description": "Ativar geocodificação reversa", "map_reverse_geocoding_settings": "Configurações de geocodificação reversa", - "map_settings": "Configurações de mapas e GPS", + "map_settings": "Mapa", "map_settings_description": "Gerenciar configurações do mapa", "map_style_description": "URL para um tema de mapa style.json", "metadata_extraction_job": "Extrair metadados", @@ -217,6 +218,7 @@ "sidecar_job_description": "Descubra ou sincronize metadados secundários do sistema de arquivos", "slideshow_duration_description": "Tempo em segundos para exibir cada imagem", "smart_search_job_description": "Execute a aprendizagem automática em arquivos para oferecer suporte à pesquisa inteligente", + "storage_template_date_time_description": "O registro de data e hora da criação é usado para fornecer essas informações", "storage_template_date_time_sample": "Exemplo de tempo {date}", "storage_template_enable_description": "Habilitar mecanismo de modelo de armazenamento", "storage_template_hash_verification_enabled": "Verificação de hash ativada", @@ -315,10 +317,12 @@ "user_password_has_been_reset": "A senha do utilizador foi redefinida:", "user_password_reset_description": "Forneça a senha temporária ao utilizador e informe que ele precisará alterar a senha no próximo início de sessão.", "user_restore_description": "A conta de {user} será restaurada.", + "user_restore_scheduled_removal": "Restaurar usuário - planejar remoção em {date, date, long}", "user_settings": "Configurações do Utilizador", "user_settings_description": "Gerenciar configurações do utilizador", "user_successfully_removed": "O utilizador {email} foi removido com sucesso.", - "version_check_enabled_description": "Ativa verificações periódicas no GitHub para novas versões", + "version_check_enabled_description": "Ativa verificação de novas versões", + "version_check_implications": "A funcionalidade de verificação da versão necessita comunicação periodica com github.com", "version_check_settings": "Verificação de versão", "version_check_settings_description": "Ativar/desativar a notificação de nova versão", "video_conversion_job": "Transcodificar vídeos", @@ -334,7 +338,8 @@ "album_added": "Álbum adicionado", "album_added_notification_setting_description": "Receba uma notificação por e-mail quando você for adicionado a um álbum compartilhado", "album_cover_updated": "Capa do álbum atualizada", - "album_delete_confirmation": "De certeza que quer apagar o álbum {album}?\nSe o álbum for partilhado, os outros utilizadores não poderão acessá-lo novamente.", + "album_delete_confirmation": "Tem a certeza que quer apagar o álbum {album}? Se o álbum for partilhado, os outros utilizadores não poderão aceder-lhe novamente.", + "album_delete_confirmation_description": "Se este álbum for partilhado, os outros utilizadores deixam de poder aceder.", "album_info_updated": "Informações do álbum atualizadas", "album_leave": "Sair do álbum?", "album_leave_confirmation": "Tem a certeza que quer sair de {album}?", @@ -347,6 +352,7 @@ "album_updated_setting_description": "Receba uma notificação por e-mail quando um álbum compartilhado tiver novos arquivos", "album_user_left": "Saída {album}", "album_user_removed": "Utilizador {user} removido", + "album_with_link_access": "Permite acesso a fotos e pessoas deste album por qualquer pessoa com o link.", "albums": "Álbuns", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbuns}}", "all": "Todos", @@ -355,23 +361,34 @@ "all_videos": "Todos os vídeos", "allow_dark_mode": "Permitir modo escuro", "allow_edits": "Permitir edições", + "allow_public_user_to_download": "Permit acesso de download ao user publico", + "allow_public_user_to_upload": "Permite acesso de upload ao user publico", + "anti_clockwise": "Sentido anti-horário", "api_key": "Chave de API", "api_key_description": "Este valor será apresentado apenas uma única vez. Por favor, certifique-se que o copiou antes de fechar a janela.", + "api_key_empty": "O nome da API Key não pode ser vazio", "api_keys": "Chaves de API", "app_settings": "Configurações do Aplicativo", "appears_in": "Aparece em", - "archive": "Arquivados", + "archive": "Arquivo", "archive_or_unarchive_photo": "Arquivar ou desarquivar foto", - "archive_size": "Tamanho do Arquivo", + "archive_size": "Tamanho do arquivo", "archive_size_description": "Configure o tamanho do arquivo para downloads (em GiB)", "archived": "Arquivado", + "archived_count": "{count, plural, other {Arquivado #}}", "are_these_the_same_person": "São a mesma pessoa?", + "are_you_sure_to_do_this": "Tem a certeza que quer fazer isto?", "asset_added_to_album": "Adicionado ao álbum", "asset_adding_to_album": "A adicionar ao álbum...", "asset_description_updated": "A descrição do arquivo foi atualizada", "asset_filename_is_offline": "O arquivo {filename} está offline", "asset_has_unassigned_faces": "O arquivo tem rostos sem atribuição", + "asset_hashing": "Hashing...", "asset_offline": "Ativo off-line", + "asset_offline_description": "Este arquivo está offline. Immich não consegue acessar o local do arquivo. Certifique-se de que o arquivo esteja disponível e, em seguida, escaneie a biblioteca novamente.", + "asset_skipped": "Ignorado", + "asset_uploaded": "Enviado", + "asset_uploading": "Em upload...", "assets": "Arquivos", "assets_added_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}", "assets_added_to_album_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} ao álbum", @@ -381,14 +398,19 @@ "assets_moved_to_trash_count": "{count, plural, one {# arquivo movido} other {# arquivos movidos}} para a lixeira", "assets_permanently_deleted_count": "{count, plural, one {# arquivo} other {# arquivos}} excluídos permanentemente", "assets_removed_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}}", + "assets_restore_confirmation": "Tem a certeza que quer recuperar todos os artigos apagados? Não é possivel voltar atrás nesta acção!", "assets_restored_count": "{count, plural, one {# arquivo restaurado} other {# arquivos restaurados}}", "assets_trashed_count": "{count, plural, one {# arquivo enviado} other {# arquivos enviados}} para a lixeira", + "assets_were_part_of_album_count": "{count, plural, one {Arquivo já era} other {Os arquivos já eram}} parte do álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Voltar", + "back_close_deselect": "Voltar, fechar ou desmarcar", "backward": "Para trás", "birthdate_saved": "Data de nascimento guardada com sucesso", "birthdate_set_description": "A data de nascimento é usada para calcular a idade desta pessoa no momento em que uma fotografia foi tirada.", "blurred_background": "Fundo desfocado", + "build": "Construir", + "build_image": "Construir Imagem", "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja excluir {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e exclui permanentemente todas as outras duplicidades. Você não pode desfazer esta ação!", "bulk_keep_duplicates_confirmation": "Tem certeza de que deseja manter {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso resolverá todos os grupos duplicados sem excluir nada.", "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a lixeira {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso manterá o maior arquivo de cada grupo e moverá para a lixeira todas as outras duplicidades.", @@ -399,6 +421,7 @@ "cancel": "Cancelar", "cancel_search": "Cancelar pesquisa", "cannot_merge_people": "Não é possível mesclar pessoas", + "cannot_undo_this_action": "Não pode voltar atrás nesta ação!", "cannot_update_the_description": "Não é possível atualizar a descrição", "cant_apply_changes": "Não é possível aplicar alterações", "cant_get_faces": "Não foi possível obter faces", @@ -410,6 +433,7 @@ "change_name": "Alterar nome", "change_name_successfully": "Nome alterado com sucesso", "change_password": "Mudar a senha", + "change_password_description": "Esta é a primeira vez que você está entrando no sistema ou uma solicitação foi feita para alterar sua senha. Insira a nova senha abaixo.", "change_your_password": "Alterar sua senha", "changed_visibility_successfully": "Visibilidade alterada com sucesso", "check_all": "Verificar tudo", @@ -421,12 +445,14 @@ "clear_all_recent_searches": "Limpar todas as pesquisas recentes", "clear_message": "Limpar mensagem", "clear_value": "Limpar valor", + "clockwise": "Sentido horário", "close": "Fechar", "collapse": "Colapsar", "collapse_all": "Colapsar tudo", "color_theme": "Tema de cores", "comment_deleted": "Comentário eliminado", "comment_options": "Opções de comentário", + "comments_and_likes": "Comentários e gostos", "comments_are_disabled": "Comentários estão desativados", "confirm": "Confirmar", "confirm_admin_password": "Confirmar senha de administrador", @@ -452,7 +478,9 @@ "create_library": "Criar biblioteca", "create_link": "Criar link", "create_link_to_share": "Criar link para partilhar", + "create_link_to_share_description": "Permiter a visualização desta imagem(s) a qualquer pessoa com este link", "create_new_person": "Criar nova pessoa", + "create_new_person_hint": "Associe os arquivos para uma nova pessoa", "create_new_user": "Criar novo utilizador", "create_user": "Criar utilizador", "created": "Criado", @@ -494,10 +522,13 @@ "do_not_show_again": "Não mostrar esta mensagem novamente", "done": "Feito", "download": "Transferir", + "download_include_embedded_motion_videos": "Vídeos incorporados", + "download_include_embedded_motion_videos_description": "Incluir vídeos incorporados em fotos em movimento como um arquivo separado", "download_settings": "Transferir", "download_settings_description": "Gerenciar configurações relacionadas a transferir ativos", "downloading": "Baixando", "downloading_asset_filename": "A transferir o arquivo {filename}", + "drop_files_to_upload": "Coloque os ficheiros em qualquer lugar para fazer o upload", "duplicates": "Duplicados", "duplicates_description": "Marque cada grupo indicando quais arquivos, se algum, são duplicados", "duration": "Duração", @@ -526,10 +557,15 @@ "edit_user": "Editar utilizador", "edited": "Editado", "editor": "Editar", + "editor_close_without_save_prompt": "As alterações não serão salvas", + "editor_close_without_save_title": "Fechar editor?", + "editor_crop_tool_h2_aspect_ratios": "Proporções de aspecto", + "editor_crop_tool_h2_rotation": "Rotação", "email": "E-mail", "empty": "", "empty_album": "", "empty_trash": "Esvaziar lixo", + "empty_trash_confirmation": "Tem certeza de que deseja esvaziar a lixeira? Isso removerá todos os arquivos da lixeira do Immich permanentemente.\nVocê não pode desfazer esta ação!", "enable": "Ativar", "enabled": "Ativado", "end_date": "Data final", @@ -537,7 +573,11 @@ "error_loading_image": "Erro ao carregar a página", "error_title": "Erro - Algo correu mal", "errors": { + "cannot_navigate_next_asset": "Não pode navegar para o proximo artigo", + "cannot_navigate_previous_asset": "Não pode navegar para o artigo anterior", "cant_apply_changes": "Não foi possível aplicar as alterações", + "cant_change_activity": "Não é possível {enabled, select, true {desativar} other {ativar}} atividade", + "cant_change_asset_favorite": "Não pode alterar o favorito deste artigo", "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", "cant_get_faces": "Não foi possível obter os rostos", "cant_get_number_of_comments": "Não foi possível obter o número de comentários", @@ -545,34 +585,49 @@ "cant_search_places": "Não foi possível pesquisar locais", "cleared_jobs": "Trabalhos eliminados para: {job}", "error_adding_assets_to_album": "Erro ao adicionar arquivos ao álbum", + "error_adding_users_to_album": "Erro a adicionar utilizador ao album", + "error_deleting_shared_user": "Error a apagar o utilizador partilhado", "error_downloading": "Erro a transferir {filename}", "error_hiding_buy_button": "Erro ao esconder botão de compra", + "error_removing_assets_from_album": "Erro a eliminar artigos do album, verifique a consola para mais detalhes", "error_selecting_all_assets": "Erro ao selecionar todos os arquivos", "exclusion_pattern_already_exists": "Este padrão de exclusão já existe.", "failed_job_command": "Comando {command} falhou para o trabalho: {job}", "failed_to_create_album": "Falha ao criar álbum", + "failed_to_create_shared_link": "Falhou a criar um link partilhado", + "failed_to_edit_shared_link": "Falhou a editar o link partilhado", "failed_to_get_people": "Falha na obtenção de pessoas", "failed_to_load_asset": "Falha ao carregar arquivo", "failed_to_load_assets": "Falha ao carregar arquivos", "failed_to_load_people": "Falha ao carregar pessoas", "failed_to_remove_product_key": "Falha ao remover chave de produto", + "failed_to_stack_assets": "Falha ao empilhar os arquivos", + "failed_to_unstack_assets": "Falha ao desempilhar arquivos", "import_path_already_exists": "Este caminho de importação já existe.", + "incorrect_email_or_password": "Email ou password incorretos", "paths_validation_failed": "a validação de {paths, plural, one {# caminho falhou} other {# caminhos falharam}}", + "profile_picture_transparent_pixels": "Imagem de perfil não pode ter pixels transparentes. Por favor faça zoom in e/ou mova a imagem.", "quota_higher_than_disk_size": "Você definiu uma cota maior do que o tamanho do disco", "repair_unable_to_check_items": "Não foi possível verificar {count, select, one {um item} other {alguns itens}}", "unable_to_add_album_users": "Não foi possível adicionar utilizadores ao álbum", + "unable_to_add_assets_to_shared_link": "Não foi possivel adicionar os artigos ao link partilhado", "unable_to_add_comment": "Não foi possível adicionar o comentário", "unable_to_add_exclusion_pattern": "Não foi possível adicionar o padrão de exclusão", "unable_to_add_import_path": "Não foi possível adicionar o caminho de importação", "unable_to_add_partners": "Não foi possível adicionar parceiros", + "unable_to_add_remove_archive": "Não é possível {archived, select, true {remover o arquivo de} other {adicionar o arquivo}}", "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar arquivo aos} other {remover arquivo dos}} favoritos", + "unable_to_archive_unarchive": "Não é possível {archived, select, true {arquivar} other {desarquivar}}", "unable_to_change_album_user_role": "Não foi possível alterar a permissão do utilizador no álbum", "unable_to_change_date": "Não foi possível alterar a data", + "unable_to_change_favorite": "Não foi possivel mudar o favorito do artigo", "unable_to_change_location": "Não foi possível alterar a localização", "unable_to_change_password": "Não foi possível alterar a senha", + "unable_to_change_visibility": "Não é possível alterar a visibilidade de {count, plural, one {# pessoa} other {# pessoas}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_complete_oauth_login": "Não foi possível completar início de sessão com OAuth", + "unable_to_connect": "Não é possível conectar", "unable_to_connect_to_server": "Não foi possível ligar ao servidor", "unable_to_copy_to_clipboard": "Não é possível copiar para a área de transferência, certifique-se que está acessando a pagina através de https", "unable_to_create_admin_account": "Não foi possível criar conta de administrador", @@ -593,6 +648,7 @@ "unable_to_enter_fullscreen": "Não foi possível entrar em modo de tela cheia", "unable_to_exit_fullscreen": "Não foi possível sair do modo de tela cheia", "unable_to_get_comments_number": "Não foi possível obter número de comentários", + "unable_to_get_shared_link": "Falha ao obter link compartilhado", "unable_to_hide_person": "Não foi possível esconder a pessoa", "unable_to_link_oauth_account": "Não foi possível associar a conta OAuth", "unable_to_load_album": "Não foi possível carregar o álbum", @@ -603,9 +659,12 @@ "unable_to_log_out_device": "Não foi possível terminar a sessão no dispositivo", "unable_to_login_with_oauth": "Não foi possível iniciar sessão com OAuth", "unable_to_play_video": "Não foi possível reproduzir o vídeo", + "unable_to_reassign_assets_existing_person": "Não é possível reatribuir arquivos para {name, select, null {uma pessoa existente} other {{name}}}", + "unable_to_reassign_assets_new_person": "Não é possível reatribuir os arquivos a uma nova pessoa", "unable_to_refresh_user": "Não foi possível atualizar o utilizador", "unable_to_remove_album_users": "Não foi possível remover utilizador do álbum", "unable_to_remove_api_key": "Não foi possível a Chave de API", + "unable_to_remove_assets_from_shared_link": "Não é possível remover os arquivos do link compartilhado", "unable_to_remove_comment": "", "unable_to_remove_library": "Não foi possível remover a biblioteca", "unable_to_remove_offline_files": "Não foi possível remover arquivos offline", @@ -626,6 +685,7 @@ "unable_to_save_settings": "Não foi possível salvar as configurações", "unable_to_scan_libraries": "Não foi possível escanear as bibliotecas", "unable_to_scan_library": "Não foi possível escanear a biblioteca", + "unable_to_set_feature_photo": "Não é possível definir a foto do recurso", "unable_to_set_profile_picture": "Não foi possível definir a foto de perfil", "unable_to_submit_job": "Não foi possível enviar o trabalho", "unable_to_trash_asset": "Não foi possível enviar o ativo para a lixeira", @@ -650,6 +710,7 @@ "expired": "Expirou", "expires_date": "Expira em {date}", "explore": "Explorar", + "explorer": "Explorador", "export": "Exportar", "export_as_json": "Exportar como JSON", "extension": "Extensão", @@ -671,6 +732,7 @@ "filter_people": "Filtrar pessoas", "find_them_fast": "Encontre pelo nome em uma pesquisa", "fix_incorrect_match": "Corrigir correspondência incorreta", + "folders": "Pastas", "force_re-scan_library_files": "Força escanear novamente todos os arquivos da biblioteca", "forward": "Para frente", "general": "Geral", @@ -724,6 +786,7 @@ }, "invite_people": "Convidar Pessoas", "invite_to_album": "Convidar para o álbum", + "items_count": "{count, plural, one {item #} other {itens #}}", "job_settings_description": "", "jobs": "Trabalhos", "keep": "Manter", @@ -740,6 +803,7 @@ "library": "Biblioteca", "library_options": "Opções da biblioteca", "light": "Claro", + "like_deleted": "Curtida removida", "link_options": "Opções do Link", "link_to_oauth": "Link do OAuth", "linked_oauth_account": "Conta OAuth Vinculada", @@ -752,6 +816,8 @@ "logged_out_device": "Sessão terminada no dispositivo", "login": "Iniciar sessão", "login_has_been_disabled": "Login foi desativado.", + "logout_all_device_confirmation": "Tem certeza de que deseja desconectar todos os dispositivos?", + "logout_this_device_confirmation": "Tem certeza de que deseja sair deste dispositivo?", "longitude": "Longitude", "look": "Estilo", "loop_videos": "Repetir vídeos", @@ -773,12 +839,14 @@ "memories": "Memórias", "memories_setting_description": "Gerencie o que vê em suas memórias", "memory": "Memória", + "memory_lane_title": "Memórias {title}", "menu": "Menu", "merge": "Mesclar", "merge_people": "Mesclar pessoas", "merge_people_limit": "Só é possível mesclar até 5 faces de uma só vez", "merge_people_prompt": "Tem certeza que deseja mesclar estas pessoas? Esta ação é irreversível.", "merge_people_successfully": "Pessoas mescladas com sucesso", + "merged_people_count": "Mesclada {count, plural, one {1 pessoa} other {# pessoas}}", "minimize": "Minimizar", "minute": "Minuto", "missing": "Faltando", @@ -801,6 +869,8 @@ "next_memory": "Próxima memória", "no": "Não", "no_albums_message": "Crie um álbum para organizar suas fotos e vídeos", + "no_albums_with_name_yet": "Parece que você ainda não tem nenhum álbum com este nome.", + "no_albums_yet": "Parece que você ainda não tem nenhum álbum.", "no_archived_assets_message": "Arquive fotos e vídeos para os ocultar da sua visualização de fotos", "no_assets_message": "CLIQUE PARA CARREGAR SUA PRIMEIRA FOTO", "no_duplicates_found": "Nenhuma duplicidade foi encontrada.", @@ -811,6 +881,7 @@ "no_name": "Sem nome", "no_places": "Sem lugares", "no_results": "Sem resultados", + "no_results_description": "Tente um sinônimo ou uma palavra-chave mais comum", "no_shared_albums_message": "Crie um álbum para compartilhar fotos e vídeos com pessoas em sua rede", "not_in_any_album": "Fora de álbum", "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", @@ -825,10 +896,15 @@ "offline_paths_description": "Estes resultados podem ser devidos a arquivos deletados manualmente e que não são parte de uma biblioteca externa.", "ok": "Ok", "oldest_first": "Mais antigo primeiro", + "onboarding": "Integração", + "onboarding_privacy_description": "Os seguintes recursos (opcionais) dependem de serviços externos e podem ser desabilitados a qualquer momento nas configurações de administração.", + "onboarding_theme_description": "Escolha um tema de cor para sua instância. Você pode alterar isso mais tarde em suas configurações.", + "onboarding_welcome_description": "Vamos configurar sua instância com algumas configurações comuns.", "onboarding_welcome_user": "Bem-vindo(a), {user}", "online": "Online", "only_favorites": "Somente favoritos", "only_refreshes_modified_files": "Somente atualize arquivos modificados", + "open_in_map_view": "Abrir na visualização do mapa", "open_in_openstreetmap": "Abrir no OpenStreetMap", "open_the_search_filters": "Abre os filtros de pesquisa", "options": "Opções", @@ -840,6 +916,7 @@ "other_variables": "Outras variáveis", "owned": "Seu", "owner": "Dono", + "partner": "Parceiro", "partner_can_access": "{partner} pode acessar", "partner_can_access_assets": "Todas as suas fotos e vídeos, exceto os Arquivados ou Excluídos", "partner_can_access_location": "A localização onde as fotos foram tiradas", @@ -850,9 +927,9 @@ "password_required": "A senha é obrigatório", "password_reset_success": "Senha resetada com sucesso", "past_durations": { - "days": "{days, plural, one {Último dia} other {Últimos {days, number} dias}}", - "hours": "{hours, plural, one {Última hora} other {Últimas {hours, number} horas}}", - "years": "{years, plural, one {Último ano} other {Últimos {years, number} anos}}" + "days": "{days, plural, one {Último dia} other {# últimos dias}}", + "hours": "Últimas {hours, plural, one {horas} other {# horas}}", + "years": "{years, plural, one {Último ano} other {Últimos # anos}}" }, "path": "Caminho", "pattern": "Padrão", @@ -868,10 +945,13 @@ "permanent_deletion_warning_setting_description": "Exibe um aviso ao excluir arquivos de forma permanente", "permanently_delete": "Deletar permanentemente", "permanently_delete_assets_count": "Excluir permanentemente {count, plural, one {arquivo} other {arquivos}}", + "permanently_delete_assets_prompt": "Tem certeza que deseja excluir permanentemente {count, plural, one {esse arquivo?} other {estes # arquivos?}} Essa ação também removerá {count, plural, one {isto do} other {isto dos}} álbum(s).", "permanently_deleted_asset": "Ativo deletado permanentemente", "permanently_deleted_assets": "{count, plural, one {# ativo deletado} other {# ativos deletados}} permanentemente", "permanently_deleted_assets_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}} permanentemente", "person": "Pessoa", + "person_hidden": "{name}{hidden, select, true { (oculto)} other {}}", + "photo_shared_all_users": "Parece que você compartilhou suas fotos com todos os usuários ou não tem nenhum usuário para compartilhar.", "photos": "Fotos", "photos_and_videos": "Fotos & Vídeos", "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}", @@ -891,41 +971,69 @@ "previous_memory": "Memória anterior", "previous_or_next_photo": "Foto anterior ou próxima", "primary": "Primário", + "privacy": "Privacidade", "profile_image_of_user": "Imagem de perfil de {user}", "profile_picture_set": "Foto de perfil definida.", "public_album": "Álbum público", "public_share": "Compartilhar Publicamente", + "purchase_account_info": "Apoiador", "purchase_activated_subtitle": "Agradecemos por apoiar o Immich e software de código aberto", + "purchase_activated_time": "Ativado em {date, date}", + "purchase_activated_title": "Sua chave foi ativada com sucesso", "purchase_button_activate": "Ativar", "purchase_button_buy": "Comprar", "purchase_button_buy_immich": "Comprar Immich", "purchase_button_never_show_again": "Nunca mostrar novamente", "purchase_button_reminder": "Relembrar-me daqui a 30 dias", - "purchase_individual_title": "Individual", + "purchase_button_remove_key": "Remover chave", + "purchase_button_select": "Selecionar", + "purchase_failed_activation": "Falha ao ativar! Verifique seu e-mail para obter a chave de produto correta!", + "purchase_individual_description_1": "Para uma pessoa", + "purchase_individual_description_2": "Status de apoiador", + "purchase_individual_title": "Particular", + "purchase_input_suggestion": "Tem uma chave de produto? Insira a chave abaixo", + "purchase_license_subtitle": "Compre Immich para apoiar o desenvolvimento contínuo do serviço", "purchase_lifetime_description": "Compra vitalícia", "purchase_option_title": "OPÇÕES DE COMPRA", "purchase_panel_info_1": "O desenvolvimento do Immich requer muito tempo e esforço, e temos engenheiros a tempo inteiro a trabalhar nele para melhorá-lo quanto possível. A nossa missão é para que o software de código aberto e práticas de negócio éticas se tornem numa fonte de rendimento sustentável para os desenvolvedores e criar um ecossistema que respeite a privacidade dos utilizadores e que ofereça alternativas reais a serviços cloud explorativos.", + "purchase_panel_info_2": "Como estamos comprometidos em não adicionar acesso pago, esta compra não lhe dará nenhum recurso adicional no Immich. Contamos com usuários como você para dar suporte ao desenvolvimento contínuo do Immich.", "purchase_panel_title": "Apoie o projeto", "purchase_per_server": "Por servidor", "purchase_per_user": "Por utilizador", "purchase_remove_product_key": "Remover chave de produto", + "purchase_remove_product_key_prompt": "Tem certeza de que deseja remover a chave do produto?", + "purchase_remove_server_product_key": "Remover chave do produto do servidor", + "purchase_remove_server_product_key_prompt": "Tem certeza de que deseja remover a chave do produto do servidor?", "purchase_server_description_1": "Para o servidor inteiro", + "purchase_server_description_2": "Status de apoiador", "purchase_server_title": "Servidor", "purchase_settings_server_activated": "A chave de produto para servidor é gerida pelo administrador", "range": "", + "rating": "Classificação por estrelas", + "rating_clear": "Limpar classificação", + "rating_count": "{contar, plural, um {# estrela} outro {# estrelas}}", + "rating_description": "Exibir a classificação exif no painel de informações", "raw": "", "reaction_options": "Opções de reação", "read_changelog": "Ler Novidades", + "reassign": "Reatribuir", + "reassigned_assets_to_existing_person": "Reatribuir {count, plural, one {# arquivo} other {# arquivos}} PARA {name, select, null {uma pessoa existente} other {{name}}}", + "reassigned_assets_to_new_person": "Reatribuir {count, plural, one {# arquivo} other {# arquivos}} a uma nova pessoa", + "reassing_hint": "Atribuir ativos selecionados a uma pessoa existente", "recent": "Recente", "recent_searches": "Pesquisas recentes", "refresh": "Atualizar", + "refresh_encoded_videos": "Atualizar vídeos codificados", "refresh_metadata": "Atualizar metadados", "refresh_thumbnails": "Atualizar miniaturas", "refreshed": "Atualizado", "refreshes_every_file": "Atualiza todos arquivos", + "refreshing_encoded_video": "Atualizando vídeo codificado", "refreshing_metadata": "A atualizar metadados", "regenerating_thumbnails": "A atualizar miniaturas", "remove": "Remover", + "remove_assets_album_confirmation": "Tem certeza que deseja remover {count, plural, one {# arquivo} other {# arquivos}} do álbum?", + "remove_assets_shared_link_confirmation": "Tem certeza que deseja remover {count, plural, one {# arquivo} other {# arquivos}} desse link compartilhado?", "remove_assets_title": "Remover arquivos?", "remove_custom_date_range": "Remover intervalo de datas personalizado", "remove_from_album": "Remover do álbum", @@ -934,7 +1042,9 @@ "remove_offline_files": "Remover arquivos offline", "remove_user": "Remover utilizador", "removed_api_key": "Removido a Chave de API: {name}", + "removed_from_archive": "Removido do arquivo", "removed_from_favorites": "Removido dos favoritos", + "removed_from_favorites_count": "{count, plural, other {Removido #}} dos favoritos", "rename": "Renomear", "repair": "Reparar", "repair_no_results_message": "Arquivos perdidos ou não rastreados aparecem aqui", @@ -947,15 +1057,18 @@ "reset_people_visibility": "Resetar pessoas ocultas", "reset_settings_to_default": "", "reset_to_default": "Repor predefinições", + "resolve_duplicates": "Resolver itens duplicados", "resolved_all_duplicates": "Todas duplicidades resolvidas", "restore": "Restaurar", "restore_all": "Restaurar tudo", "restore_user": "Restaurar utilizador", + "restored_asset": "Arquivo restaurado", "resume": "Continuar", "retry_upload": "Tentar carregar novamente", "review_duplicates": "Revisar duplicidade", "role": "Função", "role_editor": "Editor", + "role_viewer": "Visualizador", "save": "Guardar", "saved_api_key": "Chave de API salva", "saved_profile": "Perfil Salvo", @@ -976,6 +1089,8 @@ "search_city": "Pesquisar cidade...", "search_country": "Pesquisar país...", "search_for_existing_person": "Pesquisar por pessoas", + "search_no_people": "Nenhuma pessoa", + "search_no_people_named": "Nenhuma pessoa chamada \"{name}\"", "search_people": "Pesquisar pessoas", "search_places": "Pesquisar lugares", "search_state": "Pesquisar estado...", @@ -987,15 +1102,18 @@ "see_all_people": "Ver todas as pessoas", "select_album_cover": "Escolher capa do álbum", "select_all": "Selecionar todos", + "select_all_duplicates": "Selecionar todos os itens duplicados", "select_avatar_color": "Selecionar cor do avatar", "select_face": "Selecionar face", "select_featured_photo": "Selecionar foto principal", + "select_from_computer": "Selecionar do computador", "select_keep_all": "Marcar manter em todos", "select_library_owner": "Selecione o dono da biblioteca", "select_new_face": "Selecionar nova face", "select_photos": "Selecionar fotos", "select_trash_all": "Marcar lixo em todos", "selected": "Selecionados", + "selected_count": "{count, plural, other {# selecionado}}", "send_message": "Enviar mensagem", "send_welcome_email": "Enviar E-mail de boas vindas", "server": "Servidor", @@ -1017,12 +1135,16 @@ "shared_by_user": "Partilhado por {user}", "shared_by_you": "Compartilhado por você", "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opções de link compartilhado", "shared_links": "Links compartilhados", - "shared_photos_and_videos_count": "{assetCount} fotos & vídeos compartilhados.", + "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos compartilhados.}}", "shared_with_partner": "Compartilhado com {partner}", "sharing": "Compartilhar", + "sharing_enter_password": "Por favor, digite a senha para visualizar esta página.", "sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral", + "shift_to_permanent_delete": "Pressione ⇧ para excluir o arquivo permanentemente", "show_album_options": "Exibir opções do álbum", + "show_albums": "Mostrar álbuns", "show_all_people": "Mostrar todas as pessoas", "show_and_hide_people": "Mostrar & ocultar pessoas", "show_file_location": "Exibir local do arquivo", @@ -1037,6 +1159,8 @@ "show_person_options": "Exibir opções da pessoa", "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", + "show_supporter_badge": "Emblema de apoiador", + "show_supporter_badge_description": "Mostrar um emblema de apoiador", "shuffle": "Aleatório", "sign_out": "Sair", "sign_up": "Registrar", @@ -1046,13 +1170,17 @@ "slideshow_settings": "Opções de apresentação", "sort_albums_by": "Ordenar álbuns por...", "sort_created": "Data de criação", + "sort_items": "Número de itens", "sort_modified": "Data de modificação", "sort_oldest": "Foto mais antiga", "sort_recent": "Foto mais recente", "sort_title": "Título", "source": "Fonte", "stack": "Empilhar", + "stack_duplicates": "Empilhar duplicados", + "stack_select_one_photo": "Selecione uma foto principal para a pilha", "stack_selected_photos": "Empilhar fotos selecionadas", + "stacked_assets_count": "Empilhado {count, plural, one {# arquivo} other {# arquivos}}", "stacktrace": "Stacktrace", "start": "Início", "start_date": "Data inicial", @@ -1074,9 +1202,11 @@ "theme": "Tema", "theme_selection": "Selecionar tema", "theme_selection_description": "Defina automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", + "they_will_be_merged_together": "Eles serão mesclados", "time_based_memories": "Memórias baseada no tempo", "timezone": "Fuso horário", "to_archive": "Arquivar", + "to_change_password": "Alterar senha", "to_favorite": "Favorito", "to_login": "Iniciar sessão", "to_trash": "Lixo", @@ -1087,11 +1217,13 @@ "trash": "Lixeira", "trash_all": "Todos para o lixo", "trash_count": "Lixeira {count, number}", + "trash_delete_asset": "Excluir arquivo", "trash_no_results_message": "Fotos e vídeos enviados para o lixo aparecem aqui.", "trashed_items_will_be_permanently_deleted_after": "Os itens da lixeira são deletados permanentemente após {days, plural, one {# dia} other {# dias}}.", "type": "Tipo", "unarchive": "Desarquivar", "unarchived": "Restaurado do arquivo", + "unarchived_count": "{count, plural, other {Não arquivado #}}", "unfavorite": "Remover favorito", "unhide_person": "Exibir pessoa", "unknown": "Desconhecido", @@ -1101,25 +1233,34 @@ "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Conta OAuth desvinculada", "unnamed_album": "Álbum sem nome", + "unnamed_album_delete_confirmation": "Tem a certeza que pretende remover este album?", "unnamed_share": "Compartilhamento sem nome", "unsaved_change": "Alteração não guardada", "unselect_all": "Limpar seleção", + "unselect_all_duplicates": "Remover seleção de todos os duplicados", "unstack": "Desempilhar", + "unstacked_assets_count": "Desempilhar {count, plural, one {# arquivo} other {# arquivos}}", "untracked_files": "Arquivos não monitorados", "untracked_files_decription": "Estes arquivos não são monitorados pela aplicação. Podem ser resultados de falhas em uma movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema", "up_next": "A seguir", "updated_password": "Senha atualizada", "upload": "Carregar", "upload_concurrency": "Carregar simultâneo", + "upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver novos arquivos enviados.", "upload_progress": "Restante(s) {remaining, number} - Processado(s) {processed, number}/{total, number}", + "upload_skipped_duplicates": "Ignorado {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}", "upload_status_duplicates": "Duplicados", "upload_status_errors": "Erros", + "upload_status_uploaded": "Enviado", + "upload_success": "Upload realizado com sucesso, atualize a página para ver os novos ativos de upload.", "url": "URL", "usage": "Uso", "use_custom_date_range": "Usar um intervalo de datas personalizado", "user": "Utilizador", "user_id": "ID do utilizador", + "user_liked": "{user} gostou {type, select, photo {dessa foto} video {deste video} asset {deste arquivo} other {disto}}", "user_purchase_settings": "Compra", + "user_purchase_settings_description": "Gerencie sua compra", "user_role_set": "Definir {user} como {role}", "user_usage_detail": "Detalhes de uso do utilizador", "username": "Nome do utilizador", @@ -1128,6 +1269,8 @@ "validate": "Validar", "variables": "Variáveis", "version": "Versão", + "version_announcement_closing": "Seu amigo, Alex", + "version_announcement_message": "Olá amigo, há uma nova versão do aplicativo. Reserve um tempo para visitar as histórico de mudanças e garantir que suas configurações docker-compose.yml e .env estejam atualizadas para evitar qualquer configuração incorreta, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização do seu aplicativo automaticamente.", "video": "Vídeo", "video_hover_setting": "Reproduzir vídeo em miniatura quando passar por cima", "video_hover_setting_description": "Reproduzir vídeo em miniatura quando o mouse está sobre o item. Mesmo quando desativado, a reprodução ainda pode ser iniciada passando sobre o ícone.", @@ -1140,7 +1283,9 @@ "view_links": "Ver links", "view_next_asset": "Ver próximo ativo", "view_previous_asset": "Ver ativo anterior", + "view_stack": "Visualizar pilha", "viewer": "Visualizar", + "visibility_changed": "Visibilidade alterada para {count, plural, one {# pessoa} other {# pessoas}}", "waiting": "Aguardando", "warning": "Aviso", "week": "Semana", diff --git a/web/src/lib/i18n/pt_BR.json b/web/src/lib/i18n/pt_BR.json index ba0698d7c582d..acbe5f4f299cf 100644 --- a/web/src/lib/i18n/pt_BR.json +++ b/web/src/lib/i18n/pt_BR.json @@ -2,12 +2,12 @@ "about": "Sobre", "account": "Conta", "account_settings": "Configurações da Conta", - "acknowledge": "Confirmar", + "acknowledge": "Entendi", "action": "Ação", "actions": "Ações", "active": "Em execução", "activity": "Atividade", - "activity_changed": "A atividade está {enabled, select, true {enabled} other {disabled}}", + "activity_changed": "A atividade está {enabled, select, true {ativada} other {desativada}}", "add": "Adicionar", "add_a_description": "Adicionar uma descrição", "add_a_location": "Adicionar uma localização", @@ -95,7 +95,7 @@ "logging_level_description": "Quando ativado, qual nível de log usar.", "logging_settings": "Registros", "machine_learning_clip_model": "Modelo CLIP", - "machine_learning_clip_model_description": "O nome de um modelo CLIP listado aqui. Lembre-se de reexecutar a tarefa de 'Pesquisa Inteligente' para todas as imagens ao alterar o modelo.", + "machine_learning_clip_model_description": "O nome de um modelo CLIP listado aqui. Lembre-se de executar novamente a tarefa de 'Pesquisa Inteligente' para todas as imagens após alterar o modelo.", "machine_learning_duplicate_detection": "Detecção de duplicidade", "machine_learning_duplicate_detection_enabled": "Habilitar detecção de duplicidade", "machine_learning_duplicate_detection_enabled_description": "Se desativado, arquivos exatamente idênticos ainda serão desduplicados.", @@ -129,12 +129,13 @@ "map_enable_description": "Ativar recursos do mapa", "map_gps_settings": "Mapa e Configurações de GPS", "map_gps_settings_description": "Gerenciar Mapa e Configurações de GPS (Geocodificação Reversa)", + "map_implications": "O mapa depende de um serviço externo para funcionar (tiles.immich.cloud)", "map_light_style": "Tema Claro", "map_manage_reverse_geocoding_settings": "Gerenciar configurações de Geocodificação reversa", "map_reverse_geocoding": "Geocodificação reversa", "map_reverse_geocoding_enable_description": "Ativar geocodificação reversa", "map_reverse_geocoding_settings": "Configurações de geocodificação reversa", - "map_settings": "Configurações de mapa e GPS", + "map_settings": "Mapa", "map_settings_description": "Gerenciar configurações do mapa", "map_style_description": "URL para um tema de mapa style.json", "metadata_extraction_job": "Extrair metadados", @@ -249,7 +250,7 @@ "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Codecs de áudio aceitos", "transcoding_accepted_audio_codecs_description": "Selecione quais codecs de áudio não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", - "transcoding_accepted_containers": "containers aceitos", + "transcoding_accepted_containers": "Containers aceitos", "transcoding_accepted_containers_description": "Selecione quais formatos de contêiner não precisam ser remixados para MP4. Usado apenas para determinadas políticas de transcodificação.", "transcoding_accepted_video_codecs": "Codecs de vídeo aceitos", "transcoding_accepted_video_codecs_description": "Selecione quais codecs de vídeo não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", @@ -278,7 +279,7 @@ "transcoding_preferred_hardware_device": "Dispositivo de hardware preferido", "transcoding_preferred_hardware_device_description": "Aplica-se apenas a VAAPI e QSV. Define o nó dri usado para transcodificação de hardware.", "transcoding_preset_preset": "Predefinido (-preset)", - "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem arquivos menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de `mais rápidas`.", + "transcoding_preset_preset_description": "Velocidade de compressão. As opções mais lentas produzem arquivos menores e aumentam a qualidade. VP9 ignora as velocidades acima de 'mais rápida'.", "transcoding_reference_frames": "Quadros de referência", "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao compactar um determinado quadro. Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. 0 define esse valor automaticamente.", "transcoding_required_description": "Somente vídeos que não estejam em um formato aceito", @@ -310,7 +311,7 @@ "user_delete_delay": "A conta e os arquivos de {user} serão programados para exclusão permanente em {delay, plural, one {# dia} other {# dias}}.", "user_delete_delay_settings": "Excluir atraso", "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os arquivos de um usuário. A tarefa de exclusão de usuário é executada à meia-noite para verificar usuários que estão prontos para exclusão. As alterações nesta configuração serão avaliadas na próxima execução.", - "user_delete_immediately": "A conta e os arquivos de {user} serão postos na fila para exclusão permanente imediatamente.", + "user_delete_immediately": "A conta e os arquivos de {user} serão programados para exclusão permanente imediata.", "user_delete_immediately_checkbox": "Adicionar o usuário e seus ativos na fila para serem deletados imediatamente", "user_management": "Gerenciamento de usuários", "user_password_has_been_reset": "A senha do usuário foi redefinida:", @@ -320,7 +321,8 @@ "user_settings": "Configurações do Usuário", "user_settings_description": "Gerenciar configurações do usuário", "user_successfully_removed": "O usuário {email} foi removido com sucesso.", - "version_check_enabled_description": "Ativa verificações periódicas no GitHub para novas versões", + "version_check_enabled_description": "Ativa a verificação de versão", + "version_check_implications": "A verificação de versão depende de uma comunicação periódica com github.com", "version_check_settings": "Verificação de versão", "version_check_settings_description": "Ativar/desativar a notificação de nova versão", "video_conversion_job": "Transcodificar vídeos", @@ -336,7 +338,7 @@ "album_added": "Álbum adicionado", "album_added_notification_setting_description": "Receba uma notificação por e-mail quando você for adicionado a um álbum compartilhado", "album_cover_updated": "Capa do álbum atualizada", - "album_delete_confirmation": "Tem certeza de que deseja excluir o álbum {album}?\nSe este álbum for compartilhado, outros usuários não poderão mais acessá-lo.", + "album_delete_confirmation": "Tem certeza de que deseja excluir o álbum {album}?", "album_info_updated": "Informações do álbum atualizadas", "album_leave": "Sair do álbum?", "album_leave_confirmation": "Tem certeza de que deseja sair de {album}?", @@ -344,11 +346,11 @@ "album_options": "Opções de álbum", "album_remove_user": "Remover usuário?", "album_remove_user_confirmation": "Tem certeza de que deseja remover {user}?", - "album_share_no_users": "Parece que você compartilhou este álbum com todos os usuários ou não tem nenhum usuário para compartilhar com ele.", + "album_share_no_users": "Parece que você já compartilhou este álbum com todos os usuários ou não há nenhum usuário para compartilhar.", "album_updated": "Álbum atualizado", "album_updated_setting_description": "Receba uma notificação por e-mail quando um álbum compartilhado tiver novos recursos", - "album_user_left": "Saída de {album}", - "album_user_removed": "Usuário {user} removido", + "album_user_left": "Saiu do álbum {album}", + "album_user_removed": "Usuário {user} foi removido", "album_with_link_access": "Permitir que qualquer pessoa com o link veja as fotos e as pessoas neste álbum.", "albums": "Álbuns", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbuns}}", @@ -358,8 +360,9 @@ "all_videos": "Todos os vídeos", "allow_dark_mode": "Permitir modo escuro", "allow_edits": "Permitir edições", - "allow_public_user_to_download": "Permitir que usuários públicos façam download", - "allow_public_user_to_upload": "Permitir que usuários públicos enviem novos ativos", + "allow_public_user_to_download": "Permitir que usuários públicos baixem os arquivos", + "allow_public_user_to_upload": "Permitir que usuários públicos enviem novos arquivos", + "anti_clockwise": "Anti-horário", "api_key": "Chave de API", "api_key_description": "Este valor será mostrado apenas uma vez. Por favor, certifique-se de copiá-lo antes de fechar a janela.", "api_key_empty": "O nome da sua chave de API não deve estar vazio", @@ -368,8 +371,8 @@ "appears_in": "Aparece em", "archive": "Arquivados", "archive_or_unarchive_photo": "Arquivar ou desarquivar foto", - "archive_size": "Tamanho do Arquivo", - "archive_size_description": "Configure o tamanho do arquivo para downloads (em GiB)", + "archive_size": "Tamanho do arquivo", + "archive_size_description": "Configure o tamanho do arquivo para baixar (em GiB)", "archived": "Arquivado", "archived_count": "{count, plural, one {# Arquivado} other {# Arquivados}}", "are_these_the_same_person": "Essas pessoas são a mesma pessoa?", @@ -377,11 +380,11 @@ "asset_added_to_album": "Adicionado ao álbum", "asset_adding_to_album": "Adicionando ao álbum...", "asset_description_updated": "A descrição do ativo foi atualizada", - "asset_filename_is_offline": "O arquivo {filename} está offline", - "asset_has_unassigned_faces": "O arquivo tem rostos não atribuídos", + "asset_filename_is_offline": "O arquivo {filename} não está disponível", + "asset_has_unassigned_faces": "O arquivo tem rostos sem nomes", "asset_hashing": "Processando...", - "asset_offline": "Arquivo off-line", - "asset_offline_description": "Este arquivo está offline. O Immich não pode acessar sua localização de arquivo. Certifique-se de que o arquivo esteja disponível e depois escaneie novamente a biblioteca.", + "asset_offline": "Arquivo indisponível", + "asset_offline_description": "Este arquivo não está disponível. O Immich não pode acessar o local do arquivo. Certifique-se de que o arquivo esteja disponível e depois escaneie novamente a biblioteca.", "asset_skipped": "Ignorado", "asset_uploaded": "Carregado", "asset_uploading": "Carregando...", @@ -397,17 +400,17 @@ "assets_restore_confirmation": "Tem certeza de que deseja restaurar todos os seus arquivos na lixeira? Esta ação não pode ser desfeita!", "assets_restored_count": "{count, plural, one {# arquivo restaurado} other {# arquivos restaurados}}", "assets_trashed_count": "{count, plural, one {# arquivo movido para a lixeira} other {# arquivos movidos para a lixeira}}", - "assets_were_part_of_album_count": "{count, plural, one {O recurso estava} other {Os recursos estavam}} já fazendo parte do álbum", + "assets_were_part_of_album_count": "{count, plural, one {O arquivo já faz} other {Os arquivos já fazem}} parte do álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Voltar", "back_close_deselect": "Voltar, fechar ou desmarcar", "backward": "Para trás", "birthdate_saved": "Data de nascimento salva com sucesso", - "birthdate_set_description": "A data de nascimento é usada para calcular a idade desta pessoa na época de uma foto.", + "birthdate_set_description": "A data de nascimento é usada para calcular a idade da pessoa no momento em que a foto foi tirada.", "blurred_background": "Fundo desfocado", "build": "Versão de compilação", "build_image": "Imagem de compilação", - "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja deletar {count, plural, one {# arquivo duplicado} other {em massa # arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e deleta permanentemente todos as outras duplicidades. Você não pode reverter esta ação!", + "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja deletar {count, plural, one {# arquivo duplicado} other {em massa # arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e deleta permanentemente todos as outras duplicidades. Você não pode desfazer esta ação!", "bulk_keep_duplicates_confirmation": "Tem certeza de que deseja manter {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso resolverá todos os grupos duplicados sem excluir nada.", "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a lixeira {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso manterá o maior arquivo de cada grupo e moverá para a lixeira todas as outras duplicidades.", "buy": "Comprar o Immich", @@ -441,6 +444,7 @@ "clear_all_recent_searches": "Limpar todas as buscas recentes", "clear_message": "Limpar mensagem", "clear_value": "Limpar valor", + "clockwise": "Horário", "close": "Fechar", "collapse": "Recolher", "collapse_all": "Colapsar tudo", @@ -517,6 +521,8 @@ "do_not_show_again": "Não mostrar esta mensagem novamente", "done": "Feito", "download": "Baixar", + "download_include_embedded_motion_videos": "Vídeos inclusos", + "download_include_embedded_motion_videos_description": "Baixar os vídeos inclusos de uma foto em movimento em um arquivo separado", "download_settings": "Baixar", "download_settings_description": "Gerenciar configurações relacionadas a transferência de arquivos", "downloading": "Baixando", @@ -550,6 +556,10 @@ "edit_user": "Editar usuário", "edited": "Editado", "editor": "Editar", + "editor_close_without_save_prompt": "As alterações não serão salvas", + "editor_close_without_save_title": "Fechar editor?", + "editor_crop_tool_h2_aspect_ratios": "Proporções", + "editor_crop_tool_h2_rotation": "Rotação", "email": "E-mail", "empty": "", "empty_album": "", @@ -562,16 +572,16 @@ "error_loading_image": "Erro ao carregar a página", "error_title": "Erro - Algo deu errado", "errors": { - "cannot_navigate_next_asset": "Não é possível navegar para o próximo arquivo", - "cannot_navigate_previous_asset": "Não é possível navegar para o arquivo anterior", - "cant_apply_changes": "Não é possível aplicar modificações", - "cant_change_activity": "Não é possível {enabled, select, true {disable} other {enable}} atividade", - "cant_change_asset_favorite": "Não é possível mudar favorito para o arquivo", - "cant_change_metadata_assets_count": "Não é possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", + "cannot_navigate_next_asset": "Não foi possível navegar para o próximo arquivo", + "cannot_navigate_previous_asset": "Não foi possível navegar para o arquivo anterior", + "cant_apply_changes": "Não foi possível aplicar as alterações", + "cant_change_activity": "Não foi possível {enabled, select, true {desativar} other {habilitar}} a atividade", + "cant_change_asset_favorite": "Não foi possível mudar favorito para o arquivo", + "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", "cant_get_faces": "Não foi possível obter os rostos", - "cant_get_number_of_comments": "Não é possível obter o número de comentários", - "cant_search_people": "Não é possível procurar pessoas", - "cant_search_places": "Não é possível procurar locais", + "cant_get_number_of_comments": "Não foi possível obter o número de comentários", + "cant_search_people": "Não foi possível procurar pessoas", + "cant_search_places": "Não foi possível procurar locais", "cleared_jobs": "Tarefas eliminadas para: {job}", "error_adding_assets_to_album": "Erro ao adicionar arquivos para o álbum", "error_adding_users_to_album": "Erro ao adicionar usuários para o álbum", @@ -605,11 +615,11 @@ "unable_to_add_import_path": "Não foi possível adicionar o caminho de importação", "unable_to_add_partners": "Não foi possível adicionar parceiros", "unable_to_add_remove_archive": "Não é possível {archived, select, true {remove asset from} other {add asset to}} arquivar", - "unable_to_add_remove_favorites": "Não é possível {favorite, select, true {add asset to} other {remove asset from}} favoritos", - "unable_to_archive_unarchive": "Não é possível {archived, select, true {archive} other {unarchive}}", + "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar o arquivo aos} other {remover o arquivo dos}} favoritos", + "unable_to_archive_unarchive": "Não foi possível {archived, select, true {arquivar} other {desarquivar}}", "unable_to_change_album_user_role": "Não foi possível alterar a permissão do usuário no álbum", "unable_to_change_date": "Não foi possível alterar a data", - "unable_to_change_favorite": "Não é possível alterar o favorito para o arquivo", + "unable_to_change_favorite": "Não foi possível alterar o favorito para o arquivo", "unable_to_change_location": "Não foi possível alterar a localização", "unable_to_change_password": "Não foi possível alterar a senha", "unable_to_change_visibility": "Não foi possível alterar a visibilidade de {count, plural, one {# pessoa} other {# pessoas}}", @@ -630,7 +640,7 @@ "unable_to_delete_import_path": "Não foi possível deletar o caminho de importação", "unable_to_delete_shared_link": "Não foi possível deletar o link compartilhado", "unable_to_delete_user": "Não foi possível deletar o usuário", - "unable_to_download_files": "Não foi possível fazer download dos arquivos", + "unable_to_download_files": "Não foi possível baixar os arquivos", "unable_to_edit_exclusion_pattern": "Não foi possível editar o padrão de exclusão", "unable_to_edit_import_path": "Não foi possível editar o caminho de importação", "unable_to_empty_trash": "Não foi possível esvaziar a lixeira", @@ -648,7 +658,7 @@ "unable_to_log_out_device": "Não foi possível sair do dispositivo", "unable_to_login_with_oauth": "Não foi possível fazer login com OAuth", "unable_to_play_video": "Não foi possível reproduzir o vídeo", - "unable_to_reassign_assets_existing_person": "Não foi possível reatribuir arquivos para {name, select, null {an existing person} other {{name}}}", + "unable_to_reassign_assets_existing_person": "Não foi possível reatribuir arquivos a {name, select, null {uma pessoa} other {{name}}}", "unable_to_reassign_assets_new_person": "Não foi possível reatribuir arquivos a uma nova pessoa", "unable_to_refresh_user": "Não foi possível atualizar o usuário", "unable_to_remove_album_users": "Não foi possível remover usuários do álbum", @@ -663,7 +673,7 @@ "unable_to_repair_items": "Não foi possível reparar os itens", "unable_to_reset_password": "Não foi possível resetar a senha", "unable_to_resolve_duplicate": "Não foi possível resolver a duplicidade", - "unable_to_restore_assets": "Não foi possível restaurar o(s) arquivo(s)", + "unable_to_restore_assets": "Não foi possível restaurar", "unable_to_restore_trash": "Não foi possível restaurar itens da lixeira", "unable_to_restore_user": "Não foi possível restaurar usuário", "unable_to_save_album": "Não foi possível salvar o álbum", @@ -699,6 +709,7 @@ "expired": "Expirou", "expires_date": "Expira em {date}", "explore": "Explorar", + "explorer": "Explorar", "export": "Exportar", "export_as_json": "Exportar como JSON", "extension": "Extensão", @@ -720,12 +731,13 @@ "filter_people": "Filtrar pessoas", "find_them_fast": "Encontre pelo nome em uma pesquisa", "fix_incorrect_match": "Corrigir correspondência incorreta", + "folders": "Pastas", "force_re-scan_library_files": "Força escanear novamente todos os arquivos da biblioteca", "forward": "Para frente", "general": "Geral", "get_help": "Obter Ajuda", "getting_started": "Primeiros passos", - "go_back": "Retornar", + "go_back": "Voltar", "go_to_search": "Ir para a pesquisa", "go_to_share_page": "Ir para a página de compartilhamento", "group_albums_by": "Agrupar álbuns por...", @@ -743,16 +755,16 @@ "host": "Host", "hour": "Hora", "image": "Imagem", - "image_alt_text_date": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {date}", - "image_alt_text_date_1_person": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1} em {date}", - "image_alt_text_date_2_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1} e {person2} em {date}", - "image_alt_text_date_3_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1}, {person2}, e {person3} em {date}", - "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1}, {person2}, e {additionalCount, number} outros em {date}", - "image_alt_text_date_place": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} em {date}", - "image_alt_text_date_place_1_person": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1} em {date}", - "image_alt_text_date_place_2_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1} e {person2} em {date}", - "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", - "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1}, {person2}, e {additionalCount, number} outros em {date}", + "image_alt_text_date": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1} em {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1} e {person2} em {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1}, {person2}, e {person3} em {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1}, {person2}, e outras {additionalCount, number} em {date}", + "image_alt_text_date_place": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} em {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1} em {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1} e {person2} em {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {additionalCount, number} outros em {date}", "image_alt_text_people": "{count, plural, =1 {com {person1}} =2 {com {person1} e {person2}} =3 {com {person1}, {person2}, e {person3}} other {com {person1}, {person2} e outras {others, number} pessoas}}", "image_alt_text_place": "em {city}, {country}", "image_taken": "{isVideo, select, true {Gravado} other {Fotografado}}", @@ -912,6 +924,7 @@ "ok": "Ok", "oldest_first": "Mais antigo primeiro", "onboarding": "Integração", + "onboarding_privacy_description": "As seguintes funções opcionais dependem de serviços externos e podem ser desabilitadas a qualquer momento nas configurações de administração.", "onboarding_theme_description": "Escolha um tema de cores para sua instância. Você pode alterar isso posteriormente em suas configurações.", "onboarding_welcome_description": "Vamos configurar sua instância com algumas configurações comuns.", "onboarding_welcome_user": "Bem-vindo, {user}", @@ -985,6 +998,7 @@ "previous_memory": "Memória anterior", "previous_or_next_photo": "Foto anterior ou próxima", "primary": "Primário", + "privacy": "Privacidade", "profile_image_of_user": "Imagem do perfil de {user}", "profile_picture_set": "Foto de perfil definida.", "public_album": "Álbum público", @@ -1008,8 +1022,8 @@ "purchase_license_subtitle": "Compre o Immich para apoiar o desenvolvimento contínuo do serviço", "purchase_lifetime_description": "Compra vitalícia", "purchase_option_title": "OPÇÕES DE COMPRA", - "purchase_panel_info_1": "Construir o Immich leva muito tempo e esforço, e temos engenheiros dedicados trabalhando nele para torná-lo o melhor possível. Nossa missão é que programas de código aberto e as práticas empresariais éticas se tornem uma fonte de receita sustentável para os desenvolvedores e criar um ecossistema que respeite a privacidade, oferecendo alternativas reais aos serviços de nuvem exploratórios.", - "purchase_panel_info_2": "Como estamos comprometidos em não adicionar bloqueios de pagamento, esta compra não lhe concederá recursos adicionais no Immich. Contamos com usuários como você para apoiar o desenvolvimento contínuo do Immich.", + "purchase_panel_info_1": "Construir o Immich leva muito tempo e esforço. Temos engenheiros trabalhando em tempo integral para torná-lo o melhor possível. Nossa missão é fazer com que programas de código aberto e práticas empresariais éticas se tornem uma fonte de renda sustentável para os desenvolvedores e também criar um ecossistema que respeite a privacidade, oferecendo alternativas reais aos serviços de nuvem exploratórios.", + "purchase_panel_info_2": "Como estamos comprometidos em não adicionar funções bloqueadas por compras, esta compra não lhe concederá nenhum recurso adicional no Immich. Nós contamos com usuários como você para apoiar o desenvolvimento contínuo do Immich.", "purchase_panel_title": "Apoiar o projeto", "purchase_per_server": "Por servidor", "purchase_per_user": "Por usuário", @@ -1022,11 +1036,13 @@ "purchase_server_title": "Servidor", "purchase_settings_server_activated": "A chave do produto para servidor é gerenciada pelo administrador", "range": "", + "rating": "Estrelas", + "rating_description": "Exibir os metadados de classificação (estrelas) no painel de informações", "raw": "", "reaction_options": "Opções de reação", "read_changelog": "Ler Novidades", "reassign": "Reatribuir", - "reassigned_assets_to_existing_person": "{count, plural, one {# arquivo reatribuído} other {# arquivos reatribuídos}} a {name, select, null {an existing person} other {{name}}}", + "reassigned_assets_to_existing_person": "{count, plural, one {# arquivo reatribuído} other {# arquivos reatribuídos}} a {name, select, null {uma pessoa} other {{name}}}", "reassigned_assets_to_new_person": "{count, plural, one {# arquivo reatribuído} other {# arquivos reatribuídos}} a uma nova pessoa", "reassing_hint": "Atribuir arquivos selecionados a uma pessoa existente", "recent": "Recente", @@ -1126,8 +1142,8 @@ "send_message": "Enviar mensagem", "send_welcome_email": "Enviar E-mail de boas vindas", "server": "Servidor", - "server_offline": "Servidor Fora do Ar", - "server_online": "Servidor no Ar", + "server_offline": "Servidor Indisponível", + "server_online": "Servidor Disponível", "server_stats": "Status do servidor", "server_version": "Versão do servidor", "set": "Definir", @@ -1144,14 +1160,16 @@ "shared_by_user": "Compartilhado por {user}", "shared_by_you": "Compartilhado por você", "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opções do link compartilhado", "shared_links": "Links compartilhados", - "shared_photos_and_videos_count": "{assetCount, plural, one {# foto e vídeo compartilhados.} other {# fotos e vídeos compartilhados.}}", + "shared_photos_and_videos_count": "{assetCount, plural, one {# arquivo compartilhado.} other {# arquivos compartilhados.}}", "shared_with_partner": "Compartilhado com {partner}", "sharing": "Compartilhar", "sharing_enter_password": "Digite a senha para visualizar esta página.", "sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral", "shift_to_permanent_delete": "pressione ⇧ para excluir permanentemente o arquivo", "show_album_options": "Exibir opções do álbum", + "show_albums": "Exibir álbuns", "show_all_people": "Mostrar todas as pessoas", "show_and_hide_people": "Mostrar & ocultar pessoas", "show_file_location": "Exibir local do arquivo", @@ -1167,7 +1185,7 @@ "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", "show_supporter_badge": "Insígnia de Contribuidor", - "show_supporter_badge_description": "Mostrar uma insígnia de contribuidor", + "show_supporter_badge_description": "Mostrar a insígnia de contribuidor", "shuffle": "Aleatório", "sign_out": "Sair", "sign_up": "Registrar", @@ -1184,6 +1202,8 @@ "sort_title": "Título", "source": "Fonte", "stack": "Empilhar", + "stack_duplicates": "Empilhar duplicados", + "stack_select_one_photo": "Selecione uma foto principal para a pilha", "stack_selected_photos": "Empilhar fotos selecionadas", "stacked_assets_count": "{count, plural, one {# arquivo empilhado} other {# arquivos empilhados}}", "stacktrace": "Stacktrace", @@ -1241,7 +1261,7 @@ "unnamed_share": "Compartilhamento sem nome", "unsaved_change": "Alteração não salva", "unselect_all": "Limpar seleção", - "unselect_all_duplicates": "Deselecionar todas as duplicatas", + "unselect_all_duplicates": "Desselecionar todas as duplicatas", "unstack": "Desempilhar", "unstacked_assets_count": "{count, plural, one {# arquivo não empilhado} other {# arquivos não empilhados}}", "untracked_files": "Arquivos não monitorados", @@ -1251,7 +1271,7 @@ "upload": "Carregar", "upload_concurrency": "Carregar simultâneo", "upload_errors": "Envio concluído com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos arquivos carregados.", - "upload_progress": "{remaining, number} processando - {processed, number}/{total, number} já processados.", + "upload_progress": "{remaining, number} processando - {processed, number}/{total, number} já processados", "upload_skipped_duplicates": "{count, plural, one {# arquivo duplicado foi ignorado} other {# arquivos duplicados foram ignorados}}", "upload_status_duplicates": "Duplicados", "upload_status_errors": "Erros", @@ -1259,13 +1279,13 @@ "upload_success": "Carregado com sucesso, atualize a página para ver os novos arquivos.", "url": "URL", "usage": "Uso", - "use_custom_date_range": "Usar intervalo de datas personalizado invés", + "use_custom_date_range": "Usar intervalo de datas personalizado", "user": "Usuário", "user_id": "ID do usuário", "user_license_settings": "Licença", "user_license_settings_description": "Gerenciar sua licença", - "user_liked": "{user} curtiu {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", - "user_purchase_settings": "Compra", + "user_liked": "{user} curtiu {type, select, photo {a foto} video {o vídeo} asset {o arquivo} other {isso}}", + "user_purchase_settings": "Comprar", "user_purchase_settings_description": "Gerenciar sua compra", "user_role_set": "Definir {user} como {role}", "user_usage_detail": "Detalhes de uso do usuário", @@ -1276,14 +1296,14 @@ "variables": "Variáveis", "version": "Versão", "version_announcement_closing": "De seu amigo, Alex", - "version_announcement_message": "Olá, amigo, há uma nova versão do aplicativo disponível. Por favor, visite com calma a página notas da versão e certifique-se de que a configuração do docker-compose.yml, e do .env estejam atualizadas para evitar configurações incorretas, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização automática do aplicativo.", + "version_announcement_message": "Olá amigo! Uma nova versão do aplicativo está disponível. Para evitar configurações incorretas, por favor verifique com calma a página de notas da versão e certifique-se que os arquivos docker-compose.yml e .env estão configurados corretamente, principalmente se você usa o WatchTower ou qualquer outro mecanismo que faça atualizações automáticas.", "video": "Vídeo", "video_hover_setting": "Reproduzir miniatura do vídeo ao passar o mouse", "video_hover_setting_description": "Reproduzir a miniatura do vídeo ao passar o mouse sobre o item. Mesmo quando desativado, a reprodução pode ser iniciada ao passar o mouse sobre o ícone de reprodução.", "videos": "Vídeos", "videos_count": "{count, plural, one {# Vídeo} other {# Vídeos}}", "view": "Ver", - "view_album": "Exibir álbum", + "view_album": "Ver álbum", "view_all": "Ver tudo", "view_all_users": "Ver todos usuários", "view_links": "Ver links", @@ -1291,7 +1311,7 @@ "view_previous_asset": "Ver arquivo anterior", "view_stack": "Exibir Pilha", "viewer": "Visualizar", - "visibility_changed": "Visibilidade alterada para {count, plural, one {# pessoa} other {# pessoas}}", + "visibility_changed": "A visibilidade de {count, plural, one {# pessoa foi alterada} other {# pessoas foram alteradas}}", "waiting": "Aguardando", "warning": "Aviso", "week": "Semana", diff --git a/web/src/lib/i18n/ro.json b/web/src/lib/i18n/ro.json index eaa2f025955ff..8fbf23be0f4cf 100644 --- a/web/src/lib/i18n/ro.json +++ b/web/src/lib/i18n/ro.json @@ -129,20 +129,22 @@ "map_enable_description": "Activare hartă", "map_gps_settings": "Setări Hartă & GPS", "map_gps_settings_description": "Gestionare setări Hartă & GPS (localizare inversă)", + "map_implications": "Caracteristica hărții se bazează pe un serviciu extern de planșe (tiles.immich.cloud)", "map_light_style": "Mod deschis", "map_manage_reverse_geocoding_settings": "Gestionare setări Localizare Inversă", "map_reverse_geocoding": "Localizare Inversă", "map_reverse_geocoding_enable_description": "Activați geocodarea inversă", "map_reverse_geocoding_settings": "Setări geocodare inversă", - "map_settings": "Setări Hartă", + "map_settings": "Hartă", "map_settings_description": "Gestionare setări hartă", "map_style_description": "URL-ul style.json către o temă pentru hartă", "metadata_extraction_job": "Extragere metadata", "metadata_extraction_job_description": "Extragere informații metadata din fiecare fișier cum ar fi localizare GPS, rezoluție, etc", "migration_job": "Migrare", - "migration_job_description": "", + "migration_job_description": "Migrați miniaturile pentru elemente și fețe la cea mai recentă structură de foldere", "no_paths_added": "Nicio cale adăugată", "no_pattern_added": "Niciun tipar adăugat", + "note_apply_storage_label_previous_assets": "Notă: Pentru a aplica Eticheta de Stocare la elementele încărcate anterior, executați", "note_cannot_be_changed_later": "NOTĂ: Nu se va mai putea modifica ulterior!", "note_unlimited_quota": "Notă: Introduceți 0 pentru cotă nelimitată", "notification_email_from_address": "De la adresa", @@ -172,7 +174,7 @@ "oauth_issuer_url": "Emitentul URL", "oauth_mobile_redirect_uri": "URI de redirecționare mobilă", "oauth_mobile_redirect_uri_override": "Înlocuire URI de redirecționare mobilă", - "oauth_mobile_redirect_uri_override_description": "Activați când „app.immich:/” este un URI de redirecționare nevalid.", + "oauth_mobile_redirect_uri_override_description": "Activați atunci când furnizorul OAuth nu permite un URI mobil, precum '{callback}'", "oauth_profile_signing_algorithm": "Algoritm de semnare a profilului", "oauth_profile_signing_algorithm_description": "Algoritm folosit pentru a semna profilul utilizatorului.", "oauth_scope": "Domeniul de aplicare", @@ -180,12 +182,12 @@ "oauth_settings_description": "Gestionați setările de conectare OAuth", "oauth_settings_more_details": "Pentru mai multe detalii despre aceastǎ funcționalitate, verificǎ documentația.", "oauth_signing_algorithm": "Algoritm de semnare", - "oauth_storage_label_claim": "", + "oauth_storage_label_claim": "Revendicare eticheta de stocare", "oauth_storage_label_claim_description": "Setați automat eticheta de stocare a utilizatorului la valoarea acestei revendicări.", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", - "oauth_storage_quota_default": "", - "oauth_storage_quota_default_description": "", + "oauth_storage_quota_claim": "Revendicare cotă de stocare", + "oauth_storage_quota_claim_description": "Setează automat cota de stocare a utilizatorului la valoarea acestei cereri.", + "oauth_storage_quota_default": "Cota implicită de stocare (GiB)", + "oauth_storage_quota_default_description": "Cota în GiB ce urmează a fi utilizată atunci când nu este furnizată nicio solicitare (introduceți 0 pentru o cotă nelimitată).", "offline_paths": "Cǎi invalide", "offline_paths_description": "Acestea pot fi rezultate în urma ștergerii manuale a fișierelor ce nu fac parte dintr-o bibliotecǎ externǎ.", "password_enable_description": "Autentificare cu email și parolǎ", @@ -194,9 +196,12 @@ "paths_validated_successfully": "Toate cǎile au fost validate cu succes", "quota_size_gib": "Spațiu de stocare alocat (GiB)", "refreshing_all_libraries": "Bibliotecile sunt în curs de reîmprospǎtare", + "registration": "Înregistrare administratori", "registration_description": "Deoarece sunteți primul utilizator de pe sistem, veți fi desemnat ca administrator și sunteți responsabil pentru sarcinile administrative, iar utilizatorii suplimentari vor fi creați de dumneavoastra.", "removing_offline_files": "Eliminarea fișierelor offline", "repair_all": "Reparǎ toate", + "repair_matched_items": "{count, plural, one {Potrivit # obiect} other {Potrivite # obiecte}}", + "repaired_items": "{count, plural, one {Reparat # obiect} other {Reparate # obiecte}}", "require_password_change_on_login": "Obligǎ utilizatorul sǎ își schimbe parola la prima autentificare", "reset_settings_to_default": "Reseteazǎ setǎrile la valorile implicite", "reset_settings_to_recent_saved": "Reseteazǎ setǎrile la valorile salvate recent", @@ -209,9 +214,11 @@ "server_settings_description": "Gestioneazǎ setǎrile serverului", "server_welcome_message": "Mesaj de bun-venit", "server_welcome_message_description": "Un mesaj ce este afișat pe pagina de autentificare.", - "sidecar_job_description": "", + "sidecar_job": "Metadate Sidecar", + "sidecar_job_description": "Descoperirea sau sincronizarea metadatelor sidecar din sistemul de fișiere", "slideshow_duration_description": "Numǎrul de secunde pentru afișarea fiecǎrei imagini", - "smart_search_job_description": "", + "smart_search_job_description": "Rulați machine learning pe active pentru a sprijini căutarea inteligentă", + "storage_template_date_time_description": "Momentul creării activului este utilizat pentru informațiile privind data și ora", "storage_template_enable_description": "", "storage_template_hash_verification_enabled": "", "storage_template_hash_verification_enabled_description": "", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 1a55ab009dd43..6aef25ac1d333 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -129,6 +129,7 @@ "map_enable_description": "Включить функции карты", "map_gps_settings": "Настройки карты и GPS", "map_gps_settings_description": "Управление настройками карты и GPS (обратный геокодинг)", + "map_implications": "Функция отображения зависит от внешнего сервиса плиток (tiles.immich.cloud)", "map_light_style": "Светлый стиль", "map_manage_reverse_geocoding_settings": "Управление настройками Обратного геокодирования", "map_reverse_geocoding": "Обратное Геокодирование", @@ -173,7 +174,7 @@ "oauth_issuer_url": "URL-адрес эмитента", "oauth_mobile_redirect_uri": "URI редиректа для мобильных", "oauth_mobile_redirect_uri_override": "Перенаправление URI для мобильных устройств", - "oauth_mobile_redirect_uri_override_description": "Включите, если «app.immich:/» не подходит в качестве URI редиректа.", + "oauth_mobile_redirect_uri_override_description": "Включите, если поставщик OAuth не разрешает использование мобильного URI, например, '{callback}'", "oauth_profile_signing_algorithm": "Алгоритм подписи профиля", "oauth_profile_signing_algorithm_description": "Алгоритм, используемый для входа в профиль пользователя.", "oauth_scope": "Разрешения", @@ -320,7 +321,8 @@ "user_settings": "Пользовательские настройки", "user_settings_description": "Управление настройками пользователей", "user_successfully_removed": "Пользователь {email} был успешно удален.", - "version_check_enabled_description": "Включить периодические запросы к GitHub для проверки наличия новых версий", + "version_check_enabled_description": "Включить проверку наличия новых версий", + "version_check_implications": "Функция проверки версии зависит от периодического взаимодействия с github.com", "version_check_settings": "Проверка версии", "version_check_settings_description": "Включить/отключить уведомление о новой версии", "video_conversion_job": "Перекодирование видео", @@ -336,7 +338,8 @@ "album_added": "Альбом добавлен", "album_added_notification_setting_description": "Получать уведомление по электронной почте, когда вы добавлены к общему альбому", "album_cover_updated": "Обложка альбома обновлена", - "album_delete_confirmation": "Вы уверены, что хотите удалить альбом {album}?\nЕсли этот альбом общий, то другие пользователи не смогут получить к нему доступ.", + "album_delete_confirmation": "Вы уверены, что хотите удалить альбом {album}?", + "album_delete_confirmation_description": "Если альбом был общим, другие пользователи больше не смогут получить к нему доступ.", "album_info_updated": "Информация об альбоме обновлена", "album_leave": "Покинуть альбом?", "album_leave_confirmation": "Вы уверены, что хотите покинуть {album}?", @@ -519,6 +522,8 @@ "do_not_show_again": "Не показывать это сообщение в дальнейшем", "done": "Готово", "download": "Скачать", + "download_include_embedded_motion_videos": "Встроенные видео", + "download_include_embedded_motion_videos_description": "Включить видео, встроенные в живые фото, в виде отдельного файла", "download_settings": "Скачивание", "download_settings_description": "Управление настройками скачивания объектов", "downloading": "Загрузка", @@ -705,6 +710,7 @@ "expired": "Срок действия истек", "expires_date": "Срок действия до {date}", "explore": "Просмотр", + "explorer": "Проводник", "export": "Экспортировать", "export_as_json": "Экспорт в JSON", "extension": "Расширение", @@ -726,6 +732,7 @@ "filter_people": "Фильтр по людям", "find_them_fast": "Быстро найдите их по имени с помощью поиска", "fix_incorrect_match": "Исправить неправильное соответствие", + "folders": "Папки", "force_re-scan_library_files": "Принудительное повторное сканирование всех файлов библиотеки", "forward": "Переслать", "general": "Общие", @@ -918,6 +925,7 @@ "ok": "ОК", "oldest_first": "Сначала старые", "onboarding": "Начало работы", + "onboarding_privacy_description": "Следующие (необязательные) функции зависят от внешних сервисов и могут быть отключены в любое время в настройках администрирования.", "onboarding_theme_description": "Выберите цветовую тему. Вы можете изменить ее позже в настройках.", "onboarding_welcome_description": "Давайте настроим ваш экземпляр с некоторыми общими параметрами.", "onboarding_welcome_user": "Добро пожаловать, {user}", @@ -991,6 +999,7 @@ "previous_memory": "Предыдущее воспоминание", "previous_or_next_photo": "Предыдущая или следующая фотография", "primary": "Главное", + "privacy": "Конфиденциальность", "profile_image_of_user": "Изображение профиля {user}", "profile_picture_set": "Установлена картинка профиля.", "public_album": "Публичный альбом", @@ -1029,6 +1038,8 @@ "purchase_settings_server_activated": "Ключ продукта сервера управляется администратором", "range": "", "rating": "Рейтинг звёзд", + "rating_clear": "Очистить рейтинг", + "rating_count": "{count, plural, one {# звезда} other {# звезд}}", "rating_description": "Показывать рейтинг exif в панели информации", "raw": "", "reaction_options": "Опции реакций", @@ -1152,6 +1163,7 @@ "shared_by_user": "Владелец: {user}", "shared_by_you": "Вы поделились", "shared_from_partner": "Фото от {partner}", + "shared_link_options": "Параметры общих ссылок", "shared_links": "Общие ссылки", "shared_photos_and_videos_count": "{assetCount, plural, other {# поделился фото и видео.}}", "shared_with_partner": "Совместно с {partner}", @@ -1249,6 +1261,7 @@ "unlink_oauth": "Отключить OAuth", "unlinked_oauth_account": "Отключить аккаунт OAuth", "unnamed_album": "Альбом без названия", + "unnamed_album_delete_confirmation": "Вы уверены, что хотите удалить этот альбом?", "unnamed_share": "Общий доступ без названия", "unsaved_change": "Не сохраненное изменение", "unselect_all": "Снять всё", diff --git a/web/src/lib/i18n/sl.json b/web/src/lib/i18n/sl.json index bf8c55e5c4da7..ccd488174b8f8 100644 --- a/web/src/lib/i18n/sl.json +++ b/web/src/lib/i18n/sl.json @@ -7,6 +7,7 @@ "actions": "Dejanja", "active": "Aktivno", "activity": "Aktivnost", + "activity_changed": "Aktivnost {enabled, select, true {omogočena} other {onemogočena}}", "add": "Dodaj", "add_a_description": "Dodaj opis", "add_a_location": "Dodaj lokacijo", @@ -29,6 +30,7 @@ "add_exclusion_pattern_description": "Dodajte vzorec izključitev. Globiranje z uporabo *, ** in ? je podprto. Če želite prezreti vse datoteke v katerem koli imeniku z imenom \"Raw\", uporabite \"**/Raw/**\". Če želite prezreti vse datoteke, ki se končajo na \".tif\", uporabite \"**/*.tif\". Če želite prezreti absolutno pot, uporabite \"/pot/za/ignoriranje/**\".", "authentication_settings": "Nastavitve preverjanja pristnosti", "authentication_settings_description": "Upravljanje gesel, OAuth in drugih nastavitev preverjanja pristnosti", + "authentication_settings_disable_all": "Ali zares želite onemogočiti vse prijavne metode? Prijava bo popolnoma onemogočena.", "authentication_settings_reenable": "Ponovno omogoči z uporabo Server Command.", "background_task_job": "Opravila v ozadju", "check_all": "Označi vse", diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index 1c7b66df01b7e..c9bad371ca2db 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -129,6 +129,7 @@ "map_enable_description": "Омогућите карактеристике мапе", "map_gps_settings": "Мап & ГПС подешавања", "map_gps_settings_description": "Управљајте поставкама мапе и ГПС-а (обрнуто геокодирање)", + "map_implications": "Функција мапе се ослања на екстерну услугу плочица (tiles.immich.cloud)", "map_light_style": "Светли стил", "map_manage_reverse_geocoding_settings": "Управљајте подешавањима Обрнуто геокодирање", "map_reverse_geocoding": "Обрнуто геокодирање", @@ -173,7 +174,7 @@ "oauth_issuer_url": "УРЛ издавача", "oauth_mobile_redirect_uri": "УРИ за преусмеравање мобилних уређаја", "oauth_mobile_redirect_uri_override": "Замена УРИ-ја мобилног преусмеравања", - "oauth_mobile_redirect_uri_override_description": "Омогући када је 'app.immich:/' nevažeći URI za preusmeravanje.", + "oauth_mobile_redirect_uri_override_description": "Омогући када ОАuth добављач (provider) не дозвољава мобилни URI, као што је '{callback}'", "oauth_profile_signing_algorithm": "Алгоритам за потписивање профила", "oauth_profile_signing_algorithm_description": "Алгоритам који се користи за потписивање корисничког профила.", "oauth_scope": "Обим", @@ -320,7 +321,8 @@ "user_settings": "Подешавања корисника", "user_settings_description": "Управљајте корисничким подешавањима", "user_successfully_removed": "Корисник {email} је успешно уклоњен.", - "version_check_enabled_description": "Омогућите периодичне захтеве GitHub-u за проверу нових издања", + "version_check_enabled_description": "Омогућите проверу нових издања", + "version_check_implications": "Функција провере верзије се ослања на периодичну комуникацију са github.com", "version_check_settings": "Провера верзије", "version_check_settings_description": "Омогућите/oneмогућите обавештење о новој верзији", "video_conversion_job": "Транскодирање видео записа", @@ -336,7 +338,8 @@ "album_added": "Албум додан", "album_added_notification_setting_description": "Прими обавештење е-поштом кад будеш додан у дељен албум", "album_cover_updated": "Омот албума ажуриран", - "album_delete_confirmation": "Да ли стварно желите да избришете албум {album}?\nАко се овај албум дели, други корисници више неће моћи да му приступе.", + "album_delete_confirmation": "Да ли стварно желите да избришете албум {album}?", + "album_delete_confirmation_description": "Ако се овај албум дели, други корисници више неће моћи да му приступе.", "album_info_updated": "Информација албума ажурирана", "album_leave": "Напустити албум?", "album_leave_confirmation": "Да ли стварно желите да напустите {album}?", @@ -360,6 +363,7 @@ "allow_edits": "Дозволи уређење", "allow_public_user_to_download": "Дозволите јавном кориснику да преузме (download-uje)", "allow_public_user_to_upload": "Дозволи јавном кориснику да отпреми (уплоад-ује)", + "anti_clockwise": "У смеру супротном од казаљке на сату", "api_key": "АПИ кључ (key)", "api_key_description": "Ова вредност ће бити приказана само једном. Обавезно копирајте пре него што затворите прозор.", "api_key_empty": "Име вашег АПИ кључа не би требало да буде празно", @@ -441,6 +445,7 @@ "clear_all_recent_searches": "Обришите све недавне претраге", "clear_message": "Обриши поруку", "clear_value": "Јасна вредност", + "clockwise": "У смеру казаљке", "close": "Затвори", "collapse": "Скупи", "collapse_all": "Скупи све", @@ -517,6 +522,8 @@ "do_not_show_again": "Не прикажи поново ову поруку", "done": "Урађено", "download": "Преузми", + "download_include_embedded_motion_videos": "Уграђени видео снимци", + "download_include_embedded_motion_videos_description": "Укључите видео записе уграђене у фотографије у покрету као засебну датотеку", "download_settings": "Преузимање", "download_settings_description": "Управљајте подешавањима везаним за преузимање датотека", "downloading": "Преузимање у току", @@ -550,6 +557,10 @@ "edit_user": "Уреди корисника", "edited": "Уређено", "editor": "Urednik", + "editor_close_without_save_prompt": "Промене неће бити сачуване", + "editor_close_without_save_title": "Затворити уређивач?", + "editor_crop_tool_h2_aspect_ratios": "Пропорције (aspect ratios)", + "editor_crop_tool_h2_rotation": "Ротација", "email": "Е-пошта", "empty": "", "empty_album": "Isprazni album", @@ -699,6 +710,7 @@ "expired": "Истекло", "expires_date": "Истиче {date}", "explore": "Истражите", + "explorer": "Претраживач (Explorer)", "export": "Извези", "export_as_json": "Извези ЈСОН", "extension": "Екстензија (Extension)", @@ -720,6 +732,7 @@ "filter_people": "Филтрирање особа", "find_them_fast": "Брзо их пронађите по имену помоћу претраге", "fix_incorrect_match": "Исправите нетачно подударање", + "folders": "Фасцикле (Folders)", "force_re-scan_library_files": "Принудно поново скенирајте све датотеке библиотеке", "forward": "Напред", "general": "Генерално", @@ -749,6 +762,10 @@ "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} снимили {person1}, {person2}, и {person3} {date}", "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} снимили {person1}, {person2}, и {additionalCount, number} осталих {date}", "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} са {person1} {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} са {person1} и {person2} {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} са {person1}, {person2}, и {person3} {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} снимљено у {city}, {country} са {person1}, {person2}, и {additionalCount, number} других {date}", "image_alt_text_people": "{count, plural, =1 {са {person1}} =2 {са {person1} и {person2}} =3 {са {person1}, {person2}, и {person3}} other {са {person1}, {person2}, и {others, number} остали}}", "image_alt_text_place": "у {city}, {country}", "image_taken": "{isVideo, select, true {Видео запис снимљен} other {Фотографија усликана}}", @@ -908,6 +925,7 @@ "ok": "Ок", "oldest_first": "Најстарије прво", "onboarding": "Приступање (Онбоардинг)", + "onboarding_privacy_description": "Следеће (опционе) функције се ослањају на спољне услуге и могу се онемогућити у било ком тренутку у подешавањима администрације.", "onboarding_theme_description": "Изаберите тему боја за свој налог. Ово можете касније да промените у подешавањима.", "onboarding_welcome_description": "Хајде да подесимо вашу инстанцу са неким уобичајеним подешавањима.", "onboarding_welcome_user": "Добродошли, {user}", @@ -981,6 +999,7 @@ "previous_memory": "Prethodno сећање", "previous_or_next_photo": "Prethodna или следећа фотографија", "primary": "Примарна (Primary)", + "privacy": "Приватност", "profile_image_of_user": "Слика профила од корисника {user}", "profile_picture_set": "Профилна слика постављена.", "public_album": "Јавни албум", @@ -1019,6 +1038,8 @@ "purchase_settings_server_activated": "Кључем производа сервера управља администратор", "range": "", "rating": "Оцена звездица", + "rating_clear": "Обриши оцену", + "rating_count": "{count, plural, one {# звезда} other {# звезде}}", "rating_description": "Прикажите exif оцену у инфо панелу", "raw": "", "reaction_options": "Опције реакције", @@ -1142,6 +1163,7 @@ "shared_by_user": "Дели {user}", "shared_by_you": "Ви делите", "shared_from_partner": "Слике од {partner}", + "shared_link_options": "Опције дељене везе", "shared_links": "Дељене везе", "shared_photos_and_videos_count": "{assetCount, plural, other {# дељене фотографије и видео записе.}}", "shared_with_partner": "Дели се са {partner}", @@ -1217,7 +1239,7 @@ "to_login": "Пријава", "to_trash": "Смеће", "toggle_settings": "Намести подешавања", - "toggle_theme": "Намести теме", + "toggle_theme": "Намести тамну тему", "toggle_visibility": "Namesti vidljivost", "total_usage": "Укупна употреба", "trash": "Отпад", @@ -1239,6 +1261,7 @@ "unlink_oauth": "Прекини везу са Oauth-om", "unlinked_oauth_account": "Опозвана веза OAuth налога", "unnamed_album": "Неименовани албум", + "unnamed_album_delete_confirmation": "Да ли сте сигурни да желите да избришете овај албум?", "unnamed_share": "Неименовано делење", "unsaved_change": "Несачувана промена", "unselect_all": "Поништи све", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index 5741354bdecbd..c54ee88f95fdc 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -129,6 +129,7 @@ "map_enable_description": "Omogućite karakteristike mape", "map_gps_settings": "Map & GPS podešavanja", "map_gps_settings_description": "Upravljajte postavkama mape i GPS-a (obrnuto geokodiranje)", + "map_implications": "Funkcija mape se oslanja na eksternu uslugu pločica (tiles.immich.cloud)", "map_light_style": "Svetli stil", "map_manage_reverse_geocoding_settings": "Upravljajte podešavanjima Obrnuto geokodiranje", "map_reverse_geocoding": "Obrnuto geokodiranje", @@ -173,7 +174,7 @@ "oauth_issuer_url": "URL izdavača", "oauth_mobile_redirect_uri": "URI za preusmeravanje mobilnih uređaja", "oauth_mobile_redirect_uri_override": "Zamena URI-ja mobilnog preusmeravanja", - "oauth_mobile_redirect_uri_override_description": "Omogući kada je 'app.immich:/' nevažeći URI za preusmeravanje.", + "oauth_mobile_redirect_uri_override_description": "Omogući kada OAuth dobavljač (provider) ne dozvoljava mobilni URI, kao što je '{callback}'", "oauth_profile_signing_algorithm": "Algoritam za potpisivanje profila", "oauth_profile_signing_algorithm_description": "Algoritam koji se koristi za potpisivanje korisničkog profila.", "oauth_scope": "Obim", @@ -320,7 +321,8 @@ "user_settings": "Podešavanja korisnika", "user_settings_description": "Upravljajte korisničkim podešavanjima", "user_successfully_removed": "Korisnik {email} je uspešno uklonjen.", - "version_check_enabled_description": "Omogućite periodične zahteve GitHub-u za proveru novih izdanja", + "version_check_enabled_description": "Omogućite proveru novih izdanja", + "version_check_implications": "Funkcija provere verzije se oslanja na periodičnu komunikaciju sa github.com", "version_check_settings": "Provera verzije", "version_check_settings_description": "Omogućite/onemogućite obaveštenje o novoj verziji", "video_conversion_job": "Transkodiranje video zapisa", @@ -336,7 +338,8 @@ "album_added": "Album dodan", "album_added_notification_setting_description": "Primi obaveštenje e-poštom kad budeš dodan u deljen album", "album_cover_updated": "Omot albuma ažuriran", - "album_delete_confirmation": "Da li stvarno želite da izbrišete album {album}?\nAko se ovaj album deli, drugi korisnici više neće moći da mu pristupe.", + "album_delete_confirmation": "Da li stvarno želite da izbrišete album {album}?", + "album_delete_confirmation_description": "Ako se ovaj album deli, drugi korisnici više neće moći da mu pristupe.", "album_info_updated": "Informacija albuma ažurirana", "album_leave": "Napustiti album?", "album_leave_confirmation": "Da li stvarno želite da napustite {album}?", @@ -360,6 +363,7 @@ "allow_edits": "Dozvoli uređenje", "allow_public_user_to_download": "Dozvolite javnom korisniku da preuzme (download-uje)", "allow_public_user_to_upload": "Dozvoli javnom korisniku da otpremi (upload-uje)", + "anti_clockwise": "U smeru suprotnom od kazaljke na satu", "api_key": "API ključ (key)", "api_key_description": "Ova vrednost će biti prikazana samo jednom. Obavezno kopirajte pre nego što zatvorite prozor.", "api_key_empty": "Ime vašeg API ključa ne bi trebalo da bude prazno", @@ -441,6 +445,7 @@ "clear_all_recent_searches": "Obrišite sve nedavne pretrage", "clear_message": "Obriši poruku", "clear_value": "Jasna vrednost", + "clockwise": "U smeru kazaljke", "close": "Zatvori", "collapse": "Skupi", "collapse_all": "Skupi sve", @@ -517,6 +522,8 @@ "do_not_show_again": "Ne prikaži ponovo ovu poruku", "done": "Urađeno", "download": "Preuzmi", + "download_include_embedded_motion_videos": "Ugrađeni video snimci", + "download_include_embedded_motion_videos_description": "Uključite video zapise ugrađene u fotografije u pokretu kao zasebnu datoteku", "download_settings": "Preuzimanje", "download_settings_description": "Upravljajte podešavanjima vezanim za preuzimanje datoteka", "downloading": "Preuzimanje u toku", @@ -550,6 +557,10 @@ "edit_user": "Uredi korisnika", "edited": "Uređeno", "editor": "Urednik", + "editor_close_without_save_prompt": "Promene neće biti sačuvane", + "editor_close_without_save_title": "Zatvoriti uređivač?", + "editor_crop_tool_h2_aspect_ratios": "Proporcije (aspect ratios)", + "editor_crop_tool_h2_rotation": "Rotacija", "email": "E-pošta", "empty": "", "empty_album": "Isprazni album", @@ -699,6 +710,7 @@ "expired": "Isteklo", "expires_date": "Ističe {date}", "explore": "Istražite", + "explorer": "Pretraživač (Explorer)", "export": "Izvezi", "export_as_json": "Izvezi JSON", "extension": "Ekstenzija (Extension)", @@ -720,6 +732,7 @@ "filter_people": "Filtriranje osoba", "find_them_fast": "Brzo ih pronađite po imenu pomoću pretrage", "fix_incorrect_match": "Ispravite netačno podudaranje", + "folders": "Fascikle (Folders)", "force_re-scan_library_files": "Prinudno ponovo skenirajte sve datoteke biblioteke", "forward": "Napred", "general": "Generalno", @@ -912,6 +925,7 @@ "ok": "Ok", "oldest_first": "Najstarije prvo", "onboarding": "Pristupanje (Onboarding)", + "onboarding_privacy_description": "Sledeće (opcione) funkcije se oslanjaju na spoljne usluge i mogu se onemogućiti u bilo kom trenutku u podešavanjima administracije.", "onboarding_theme_description": "Izaberite temu boja za svoj nalog. Ovo možete kasnije da promenite u podešavanjima.", "onboarding_welcome_description": "Hajde da podesimo vašu instancu sa nekim uobičajenim podešavanjima.", "onboarding_welcome_user": "Dobrodošli, {user}", @@ -985,6 +999,7 @@ "previous_memory": "Prethodno sećanje", "previous_or_next_photo": "Prethodna ili sledeća fotografija", "primary": "Primarna (Primary)", + "privacy": "Privatnost", "profile_image_of_user": "Slika profila od korisnika {user}", "profile_picture_set": "Profilna slika postavljena.", "public_album": "Javni album", @@ -1023,6 +1038,8 @@ "purchase_settings_server_activated": "Ključem proizvoda servera upravlja administrator", "range": "", "rating": "Ocena zvezdica", + "rating_clear": "Obriši ocenu", + "rating_count": "{count, plural, one {# zvezda} other {# zvezde}}", "rating_description": "Prikažite exif ocenu u info panelu", "raw": "", "reaction_options": "Opcije reakcije", @@ -1146,6 +1163,7 @@ "shared_by_user": "Deli {user}", "shared_by_you": "Vi delite", "shared_from_partner": "Slike od {partner}", + "shared_link_options": "Opcije deljene veze", "shared_links": "Deljene veze", "shared_photos_and_videos_count": "{assetCount, plural, other {# deljene fotografije i video zapise.}}", "shared_with_partner": "Deli se sa {partner}", @@ -1221,7 +1239,7 @@ "to_login": "Prijava", "to_trash": "Smeće", "toggle_settings": "Namesti podešavanja", - "toggle_theme": "Namesti teme", + "toggle_theme": "Namesti tamnu temu", "toggle_visibility": "Namesti vidljivost", "total_usage": "Ukupna upotreba", "trash": "Otpad", @@ -1243,6 +1261,7 @@ "unlink_oauth": "Prekini vezu sa Oauth-om", "unlinked_oauth_account": "Opozvana veza OAuth naloga", "unnamed_album": "Neimenovani album", + "unnamed_album_delete_confirmation": "Da li ste sigurni da želite da izbrišete ovaj album?", "unnamed_share": "Neimenovano delenje", "unsaved_change": "Nesačuvana promena", "unselect_all": "Poništi sve", diff --git a/web/src/lib/i18n/sv.json b/web/src/lib/i18n/sv.json index 3eec79b61506f..6bd9d9b72ee27 100644 --- a/web/src/lib/i18n/sv.json +++ b/web/src/lib/i18n/sv.json @@ -7,7 +7,7 @@ "actions": "Händelser", "active": "Aktiva", "activity": "Aktivitet", - "activity_changed": "Aktiviteten är {aktiverad, välj, sant {aktiverad} annat {inaktiverad}}", + "activity_changed": "Aktiviteten är {enabled, select, true {aktiverad} other {inaktiverad}}", "add": "Lägg till", "add_a_description": "Lägg till en beskrivning", "add_a_location": "Lägg till en plats", @@ -129,12 +129,13 @@ "map_enable_description": "Aktivera kartfunktioner", "map_gps_settings": "Karta & GPS Inställningar", "map_gps_settings_description": "Ändra kartor & GPS (Omvänd geokodning) inställningar", + "map_implications": "Kartfunktionen är beroende av en extern kartbitstjänst (tiles.immich.cloud)", "map_light_style": "Ljus stil", "map_manage_reverse_geocoding_settings": "Hantera inställningar för Omvänd geokodning", "map_reverse_geocoding": "Omvänd Geokodning", "map_reverse_geocoding_enable_description": "Aktivera omvänd geokodning", "map_reverse_geocoding_settings": "Inställningar för omvänd geokodning", - "map_settings": "Kartinställningar", + "map_settings": "Karta", "map_settings_description": "Hantera kartinställningar", "map_style_description": "URL till en style.json-karto tema", "metadata_extraction_job": "Extrahera metadata", @@ -157,7 +158,7 @@ "notification_email_setting_description": "Inställningar för att skicka epostnotiser", "notification_email_test_email": "Skicka test-epost", "notification_email_test_email_failed": "Misslyckades med att skicka test-epost, undersök dina värden", - "notification_email_test_email_sent": "Ett test-epostmeddelande has skickats till {epost}. Kolla din inkorg.", + "notification_email_test_email_sent": "Ett testmail har skickats till {email}. Kontrollera din inkorg.", "notification_email_username_description": "Användarnamn att använda vid autentisering med epost-servern", "notification_enable_email_notifications": "Aktivera epost-notiser", "notification_settings": "Notisinställningar", @@ -181,12 +182,12 @@ "oauth_settings_description": "Hantera OAuth-logininställningar", "oauth_settings_more_details": "För ytterligare detaljer om denna funktion, se dokumentationen.", "oauth_signing_algorithm": "Signeringsalgoritm", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", + "oauth_storage_label_claim": "Användaranknuten lagringsetikett", + "oauth_storage_label_claim_description": "Sätter automatiskt angiven användares lagringsetikett.", + "oauth_storage_quota_claim": "Användaranknuten lagringskvot", + "oauth_storage_quota_claim_description": "Sätter automatiskt angiven användares lagringskvot.", "oauth_storage_quota_default": "Standardlagringskvot (GiB)", - "oauth_storage_quota_default_description": "", + "oauth_storage_quota_default_description": "Kvot i GiB som används när ingen fordran angetts (Ange 0 för obegränsad kvot).", "offline_paths": "Offline-sökvägar", "offline_paths_description": "Dessa resultat kan bero på manuell borttagning av filer som inte är en del av ett externt bibliotek.", "password_enable_description": "Logga in med epost och lösenord", @@ -197,23 +198,36 @@ "refreshing_all_libraries": "Samtliga bibliotek uppdateras", "registration": "Administratörsregistrering", "registration_description": "Du utses till administratör eftersom du är systemets första användare. Du ansvarar för administration och kan skapa ytterligare användare.", - "removing_offline_files": "Tar Bort Offline-Filer", + "removing_offline_files": "Tar bort offline-filer", "repair_all": "Reparera alla", + "repair_matched_items": "Matchade {antal, plural, ett {# föremål} övriga {# föremål}}", + "repaired_items": "Reparerade {count, plural, one {# item} other {# items}}", + "require_password_change_on_login": "Kräv av användaren att byta lösenord vid första inloggning", "reset_settings_to_default": "Återställ inställningar till standard", + "reset_settings_to_recent_saved": "Återställ inställningar till de senaste sparade", + "scanning_library_for_changed_files": "Scannar bibliotek efter ändrade filer", "scanning_library_for_new_files": "Skannar biblioteket efter nya filer", + "send_welcome_email": "Skicka välkomstmail", "server_external_domain_settings": "Extern domän", "server_external_domain_settings_description": "Domän för publikt delade länkar, inklusive http(s)://", "server_settings": "Serverinställningar", "server_settings_description": "Hantera serverinställningar", "server_welcome_message": "Välkomstmeddelande", "server_welcome_message_description": "Ett meddelande som visas på inloggningssidan.", - "sidecar_job_description": "", + "sidecar_job": "Medföljande metadata", + "sidecar_job_description": "Upptäck eller synkronisera medföljande metadata från filsystemet", "slideshow_duration_description": "Antal sekunder att visa varje bild", - "smart_search_job_description": "", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", + "smart_search_job_description": "Kör maskininlärning på objekt för att stödja smart sökning", + "storage_template_date_time_description": "Tidsstämpel för resursens skapande används för datum och tidsinformation", + "storage_template_date_time_sample": "Exempeltid {date}", + "storage_template_enable_description": "Aktivera mallmotor för lagring", + "storage_template_hash_verification_enabled": "Hash-verifiering aktiverat", "storage_template_hash_verification_enabled_description": "Aktiverar hash-verifiering, deaktiviera inte om du inte är säker på implikationerna", - "storage_template_migration_job": "", + "storage_template_migration_info": "Ändringar i mall gäller endast nya resurser. För att retoaktivt tillämpa mallen på tidigare uppladdade resurser kör {job}.", + "storage_template_migration_job": "Lagringsmall migreringsjobb", + "storage_template_more_details": "För mer information om den här funktionen se Lagringsmall och dess konsekvenser", + "storage_template_onboarding_description": "Vid aktivering organiserar denna funktion automatiskt filer baserat på en användardefinierad mall. På grunda av stabilitetsproblem är denna funktion avstängd som standard, för mer information se dokumentation.", + "storage_template_path_length": "Uppskattad längdbegränsning på sökväg: {length, number}/{limit, number}", "storage_template_settings": "Lagringsmall", "storage_template_settings_description": "", "system_settings": "Systeminställningar", @@ -221,22 +235,26 @@ "theme_custom_css_settings_description": "", "theme_settings": "Temainställningar", "theme_settings_description": "Hantera anpassningar av webbgränssnittet för Immich", - "thumbnail_generation_job_description": "", + "these_files_matched_by_checksum": "Dessa filer matchas av deras kontrollsummor", + "thumbnail_generation_job": "Generera Miniatyrer", + "thumbnail_generation_job_description": "Generera stora, små och suddiga miniatyrer för varje objekt, samt för varje person", "transcode_policy_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", + "transcoding_acceleration_api": "Accelerations-API", + "transcoding_acceleration_api_description": "API som kommer att interagera med din enhet för att accelerera omkodning. Inställning är 'best effort': vid fel kommer den att återgå till mjukvarubaserad omkodning. VP9 kan fungera eller inte, beroende på din hårdvara.", "transcoding_acceleration_nvenc": "NVENC (kräver NVIDIA GPU)", - "transcoding_acceleration_qsv": "", + "transcoding_acceleration_qsv": "Quick Sync (kräver 7 generationens Intel CPU eller senare)", "transcoding_acceleration_rkmpp": "RKMPP (bara med Rockchip SOCs)", "transcoding_acceleration_vaapi": "VAAPI", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", + "transcoding_accepted_audio_codecs": "Accepterade ljud-codecs", + "transcoding_accepted_audio_codecs_description": "Välj vilka ljud-codecs som inte behöver omkodas. Används endast för vissa omkodningspolicyer.", + "transcoding_accepted_containers": "Accepterade behållare", + "transcoding_accepted_video_codecs": "Accepterade video-codecs", + "transcoding_accepted_video_codecs_description": "Välj vilka video-codecs som inte behöver omkodas. Används endast för vissa omkodningspolicyer.", "transcoding_advanced_options_description": "Val som de flesta användare inte bör behöva ändra", - "transcoding_audio_codec": "", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", + "transcoding_audio_codec": "Ljud-codec", + "transcoding_audio_codec_description": "Opus är bästa kvalitetsvalet, men är inte lika kompatibelt med äldre enheter eller mjukvara.", + "transcoding_bitrate_description": "Videor som är i högre än max bithastighet eller inte i ett accepterat format", + "transcoding_codecs_learn_more": "För att läsa mer om terminologin här se FFmpeg-dokumentationen för H.264 kodek, HEVC kodek och VP9 kodek.", "transcoding_constant_quality_mode": "", "transcoding_constant_quality_mode_description": "", "transcoding_constant_rate_factor": "", @@ -246,17 +264,17 @@ "transcoding_hardware_acceleration_description": "", "transcoding_hardware_decoding": "Hårdvaruavkodning", "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", + "transcoding_hevc_codec": "HEVC-codec", "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", + "transcoding_max_b_frames_description": "Högre värden förbättrar kompressionseffektiviteten, men saktar ner kodningen. Kan vara inkompatibel med hårdvaruacceleration på äldre enheter. 0 avaktiverar B-frames, medan -1 anger detta värde automatiskt.", + "transcoding_max_bitrate": "Max bithastighet", "transcoding_max_bitrate_description": "", "transcoding_max_keyframe_interval": "Max nyckelbildruteintervall", "transcoding_max_keyframe_interval_description": "", "transcoding_optimal_description": "", "transcoding_preferred_hardware_device": "", "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", + "transcoding_preset_preset": "Förinställning (-preset)", "transcoding_preset_preset_description": "", "transcoding_reference_frames": "", "transcoding_reference_frames_description": "", @@ -831,7 +849,8 @@ "total_usage": "Total användning", "trash": "Papperskorg", "trash_all": "", - "trash_no_results_message": "", + "trash_no_results_message": "Borttagna foton och videor kommer att visas här.", + "trashed_items_will_be_permanently_deleted_after": "Borttagna objekt kommer att tas bort permanent efter {days, plural, one {# dag} other {# dagar}}.", "type": "Typ", "unarchive": "Ångra arkivering", "unarchived": "", @@ -843,35 +862,40 @@ "unlimited": "Obegränsat", "unlink_oauth": "", "unlinked_oauth_account": "", + "unsaved_change": "Osparade ändringar", "unselect_all": "", "unstack": "Stapla Av", "up_next": "", - "updated_password": "", + "updated_password": "Lösenordet har uppdaterats", "upload": "Ladda upp", "upload_concurrency": "", "upload_status_duplicates": "Dubbletter", "upload_status_errors": "Fel", - "url": "", - "usage": "", + "url": "URL", + "usage": "Användning", "user": "Användare", - "user_id": "", + "user_id": "Användar-ID", + "user_purchase_settings": "Köp", + "user_purchase_settings_description": "Hantera dina köp", "user_usage_detail": "", - "username": "", + "username": "Användarnamn", "users": "Användare", "utilities": "Verktyg", "validate": "Validera", "variables": "Variabler", "version": "Version", + "version_announcement_closing": "Din vän, Alex", "video": "Video", "video_hover_setting_description": "", "videos": "Videor", "videos_count": "{count, plural, one {# Video} other {# Videor}}", "view": "Visa", + "view_album": "Visa Album", "view_all": "Visa alla", "view_all_users": "Visa alla användare", "view_links": "Visa länkar", - "view_next_asset": "", - "view_previous_asset": "", + "view_next_asset": "Visa nästa objekt", + "view_previous_asset": "Visa föregående objekt", "viewer": "", "waiting": "Väntar", "warning": "Varning", @@ -881,5 +905,6 @@ "year": "År", "years_ago": "{years, plural, one {# år} other {# år}} sedan", "yes": "Ja", + "you_dont_have_any_shared_links": "Du har inga delade länkar", "zoom_image": "Zooma bild" } diff --git a/web/src/lib/i18n/ta.json b/web/src/lib/i18n/ta.json index 543bfda2cded1..ec3f27124bdc2 100644 --- a/web/src/lib/i18n/ta.json +++ b/web/src/lib/i18n/ta.json @@ -1,4 +1,5 @@ { + "about": "விபரம்", "account": "கணக்கு", "account_settings": "கணக்கு அமைவுகள்", "acknowledge": "ஒப்புக்கொள்கிறேன்", @@ -6,6 +7,7 @@ "actions": "செயல்கள்", "active": "செயல்பாட்டில்", "activity": "செயல்பாடுகள்", + "activity_changed": "செயல்பாடு {இயக்கப்பட்டது, தேர்ந்தெடு, சரி {இயக்கப்பட்டது} மற்றது {முடக்கப்பட்டது}}", "add": "சேர்", "add_a_description": "விவரம் சேர்", "add_a_location": "இடத்தை சேர்க்கவும்", @@ -25,11 +27,11 @@ "added_to_favorites": "விருப்பங்களில் (பேவரிட்ஸ்) சேர்க்கப்பட்டது", "added_to_favorites_count": "விருப்பங்களில் (பேவரிட்ஸ்) {count} சேர்க்கப்பட்டது", "admin": { - "add_exclusion_pattern_description": "", + "add_exclusion_pattern_description": "விலக்கு வடிவங்களைச் சேர்க்கவும். *, **, மற்றும் ? ஆதரிக்கப்படுகிறது. \"Raw\" என்ற பெயரிடப்பட்ட எந்த கோப்பகத்திலும் உள்ள எல்லா கோப்புகளையும் புறக்கணிக்க, \"**/Raw/**\" ஐப் பயன்படுத்தவும். \".tif\" இல் முடியும் எல்லா கோப்புகளையும் புறக்கணிக்க, \"**/*.tif\" ஐப் பயன்படுத்தவும். ஒரு முழுமையான பாதையை புறக்கணிக்க, \"/path/to/ignore/**\" ஐப் பயன்படுத்தவும்.", "authentication_settings": "அடையாள உறுதிப்படுத்தல் அமைப்புகள் (செட்டிங்ஸ்)", "authentication_settings_description": "கடவுச்சொல், OAuth, மற்றும் பிற அடையாள அமைப்புகள்", "authentication_settings_disable_all": "எல்லா உள்நுழைவு முறைகளையும் நிச்சயமாக முடக்க விரும்புகிறீர்களா? உள்நுழைவு முற்றிலும் முடக்கப்படும்.", - "authentication_settings_reenable": "மீண்டும் இயக்க, சர்வர் கட்டளை பயன்படுத்தவும்", + "authentication_settings_reenable": "மீண்டும் இயக்க, சர்வர் கட்டளை பயன்படுத்தவும்.", "background_task_job": "பின்னணி பணிகள்", "check_all": "அனைத்தையும் தேர்ந்தெடு", "cleared_jobs": "முடித்த வேலைகள்: {job}", @@ -47,7 +49,7 @@ "face_detection": "முகம் கண்டறிதல்", "face_detection_description": "இயந்திர கற்றலைப் பயன்படுத்தி சொத்துக்களில் உள்ள முகங்களைக் கண்டறியவும். வீடியோக்களுக்கு, சிறுபடம் மட்டுமே கருதப்படுகிறது. \"அனைத்து\" (மறு-) அனைத்து சொத்துகளையும் செயலாக்குகிறது. இதுவரை செயலாக்கப்படாத புகைப்பட சொத்துக்களை \"காணவில்லை\" வரிசைப்படுத்துகிறது. முகம் கண்டறிதல் முடிந்ததும், கண்டறியப்பட்ட முகங்கள், ஏற்கனவே இருக்கும் அல்லது புதிய நபர்களாகக் குழுவாக்கப்பட்டு, முக அடையாளத்திற்காக வரிசையில் நிறுத்தப்படும்.", "facial_recognition_job_description": "நபர்களின் முகங்களைக் குழு கண்டறிந்தது. முகம் கண்டறிதல் முடிந்ததும் இந்தப் படி இயங்கும். அனைத்து முகங்களையும் \"அனைத்து\" (மறு-) கொத்துகள். \"காணவில்லை\" என்பது நபர் நியமிக்கப்படாத முகங்களை வரிசைப்படுத்துகிறது.", - "failed_job_command": "", + "failed_job_command": "பணிக்கான கட்டளை {command} தோல்வியடைந்தது: {job}", "force_delete_user_warning": "எச்சரிக்கை: இது பயனரையும் அனைத்து புகைப்பட சொத்துகளையும் உடனடியாக அகற்றும். இதை செயல்தவிர்க்க முடியாது மற்றும் புகைப்படங்களை மீட்டெடுக்க முடியாது.", "forcing_refresh_library_files": "அனைத்து லைப்ரரி புகைப்படங்களையும் கட்டாயப்படுத்தி புதுப்பிக்கவும்", "image_format_description": "WebP, JPEG ஐ விட சிறிய கோப்புகளை உருவாக்குகிறது, ஆனால் குறியாக்கம் செய்ய மெதுவாக உள்ளது.", diff --git a/web/src/lib/i18n/th.json b/web/src/lib/i18n/th.json index d7348f37e28ae..19496b423843f 100644 --- a/web/src/lib/i18n/th.json +++ b/web/src/lib/i18n/th.json @@ -223,7 +223,7 @@ "storage_template_migration": "การย้ายเทมเพลตที่เก็บข้อมูล", "storage_template_migration_description": "ใช้{template}ปัจจุบันกับสื่อที่อัพโหลดก่อนหน้านี้", "storage_template_migration_job": "", - "storage_template_settings": "", + "storage_template_settings": "เทมเพลตการจัดเก็บข้อมูล", "storage_template_settings_description": "", "system_settings": "การตั้งค่าระบบ", "theme_custom_css_settings": "CSS กําหนดเอง", diff --git a/web/src/lib/i18n/tr.json b/web/src/lib/i18n/tr.json index 2960af9ff5d23..7bf59d84dbad2 100644 --- a/web/src/lib/i18n/tr.json +++ b/web/src/lib/i18n/tr.json @@ -27,17 +27,17 @@ "added_to_favorites": "Favorilere eklendi", "added_to_favorites_count": "{count, number} fotoğraf favorilere eklendi", "admin": { - "add_exclusion_pattern_description": "Dışlama desenleri ekleyin. *, ** ve ? kullanılarak globbing desteklenir. Herhangi bir \"Raw\" adlı dizindeki tüm dosyaları yoksaymak için \"**/Raw/**\" kullanın. \".tif\" ile biten tüm dosyaları yoksaymak için \"**/*.tif\" kullanın. Mutlak yolu yoksaymak için \"/path/to/ignore/**\" kullanın.", - "authentication_settings": "Yetkilendirme ayarları", + "add_exclusion_pattern_description": "Dışlama desenleri ekleyin. *, ** ve ? kullanılarak Globbing (temsili yer doldurucu karakter) desteklenir. Farzedelim \"Raw\" adlı bir dizininiz var, içinde ki tüm dosyaları yoksaymak için \"**/Raw/**\" şeklinde yazabilirsiniz. \".tif\" ile biten tüm dosyaları yoksaymak için \"**/*.tif\" yazabilirsiniz. Mutlak yolu yoksaymak için \"/yoksayılacak/olan/yol/**\" şeklinde yazabilirsiniz.", + "authentication_settings": "Yetkilendirme Ayarları", "authentication_settings_description": "Şifre, OAuth, ve diğer yetkilendirme ayarlarını yönet", "authentication_settings_disable_all": "Tüm giriş yöntemlerini devre dışı bırakmak istediğinize emin misiniz? Giriş yapma fonksiyonu tamamen devre dışı bırakılacak.", "authentication_settings_reenable": "Yeniden aktif etmek için Sunucu Komutu'nu kullanın.", - "background_task_job": "Arka plan görevleri", - "check_all": "Hepsini kontrol et", + "background_task_job": "Arka Plan Görevleri", + "check_all": "Hepsini Kontrol Et", "cleared_jobs": "{job} için işler temizlendi", - "config_set_by_file": "Ayarlar şuan için config dosyası tarafından ayarlandı", + "config_set_by_file": "Ayarlar şuanda config dosyası tarafından ayarlanmıştır", "confirm_delete_library": "{library} kütüphanesini silmek istediğinize emin misiniz?", - "confirm_delete_library_assets": "Bu kütüphaneyi silmek istediğinize emin misiniz? Bu işlem {count, plural, one {# contained asset} other {all # contained assets}} tane varlığı Immich'den silecek ve bu işlem geri alınamaz. Silinen dosyalar diskten silinmeyecek.", + "confirm_delete_library_assets": "Bu kütüphaneyi silmek istediğinize emin misiniz? Bu işlem {count, plural, one {# tane varlığı} other {all # tane varlığı}} Immich'den silecek ve bu işlem geri alınamaz. Silinen dosyalar diskten silinmeyecek.", "confirm_email_below": "Onaylamak için aşağıya {email} yazın", "confirm_reprocess_all_faces": "Tüm yüzleri tekrardan işlemek istediğinize emin misiniz? Bu işlem isimlendirilmiş insanları da silecek.", "confirm_user_password_reset": "{user} adlı kullanıcının şifresini sıfırlamak istediğinize emin misiniz?", @@ -46,10 +46,10 @@ "duplicate_detection_job_description": "Benzer fotoğrafları bulmak için makine öğrenmesini çalıştır. Bu işlem Akıllı Arama'ya bağlıdır", "exclusion_pattern_description": "Kütüphaneyi tararken dosya ve klasörleri görmezden gelmek için dışlama desenlerini kullanabilirsiniz. RAW dosyaları gibi bazı dosya ve klasörleri içe aktarmak istemediğinizde bu seçeneği kullanabilirsiniz.", "external_library_created_at": "Dış kütüphane ({date} tarihinde oluşturuldu.)", - "external_library_management": "Dış kütüphane yönetimi", + "external_library_management": "Dış Kütüphane Yönetimi", "face_detection": "Yüz tarama", - "face_detection_description": "Makine öğrenmesini kullanarak medyalardaki yüzleri bulun. Videolar için sadece önizleme görüntüleri kullanılacak. \"All\" tüm medyaları tekrardan işler. \"Missing\" daha önce işlenmemiş medyaları işlenmeleri için sıraya koyar. Tespit edilen yüzler yüz tarama işlemi tamamlandıktan sonra Yüz Tanıma için sıraya koyulacak ve kişiler olarak gruplandırılacak.", - "facial_recognition_job_description": "Tespit edilen yüzleri gruplandır. Bu işlem, yüz tanıma işlemi tamamlandıktan sonra çalışır. \"All\" tüm yüzleri gruplandırır. \"Missing\" ise tespit edilen fakat kişi atanmamış olan yüzleri sıraya koyar.", + "face_detection_description": "Makine öğrenmesini kullanarak içeriklerinizde ki yüzleri bulun. Videolar için sadece önizleme görüntüleri kullanılacak. \"Hepsi\" seçeneği tüm medyaları tekrardan işler. \"İşlenmemiş\" daha önceden işlenmemiş içerikleri işlenmeleri için sıraya koyar. Tespit edilen yüzler yüz tarama işlemi tamamlandıktan sonra Yüz Tanıma için sıraya koyulacak ve kişiler olarak gruplandırılacak.", + "facial_recognition_job_description": "Tespit edilen yüzleri gruplandır. Bu işlem, yüz tanıma işlemi tamamlandıktan sonra çalışır. \"Hepsi\" tüm yüzleri gruplandırır. \"İşlenmemiş\" ise tespit edilen fakat kişi atanmamış olan yüzleri sıraya koyar.", "failed_job_command": "{job} işi için {command} komutu başarısız", "force_delete_user_warning": "UYARI: Bu işlem kullanıcıyı ve bütün verilerini silecek. Bu işlem geri alınamaz ve silinen veriler geri kurtarılamaz.", "forcing_refresh_library_files": "Tüm kütüphane dosyaları yenileniyor", @@ -128,6 +128,7 @@ "map_enable_description": "Harita ayarlarını etkinleştir", "map_gps_settings": "Harita & GPS Ayarları", "map_gps_settings_description": "Harita Yönetimi & GPS (Ters Jeokodlama) Ayarları", + "map_implications": "Harita özelliği, harici bir döşeme hizmetine (tiles.immich.cloud) bağlıdır", "map_light_style": "Açık mod", "map_manage_reverse_geocoding_settings": "Coğrafi Kodlama ayarlarını yönet", "map_reverse_geocoding": "Coğrafi Kodlama", @@ -257,7 +258,7 @@ "transcoding_bitrate_description": "Videolar maksimum bir oranından yürksek ya da kabul edilir bir formatta değil", "transcoding_codecs_learn_more": "Buradaki terminolojiyi öğrenmek için FFmpeg dokümantasyonlarına bakabilirsiniz: H.264, HEVC ve VP9.", "transcoding_constant_quality_mode": "Sabit kalite modu", - "transcoding_constant_quality_mode_description": "", + "transcoding_constant_quality_mode_description": "ICQ, CQP'den daha iyidir, ancak bazı donanım hızlandırma cihazları bu modu desteklemez. Bu seçeneğin ayarlanması, kalite tabanlı kodlama kullanırken belirtilen modu tercih eder. ICQ'yu desteklemediği için NVENC tarafından göz ardı edilir.", "transcoding_constant_rate_factor": "Sabit oran faktörü (-SOF)", "transcoding_constant_rate_factor_description": "Video kalite seviyesi. Tipik değerler H.264 için 23, HEVC için 28, VP9 için 31 ve AV1 için 35'tir. Daha düşük değerler daha iyi kalite sağlar, ancak daha büyük dosyalar üretir.", "transcoding_disabled_description": "Videoları dönüştürmeyin, bazı istemcilerde oynatma bozulabilir", @@ -272,16 +273,16 @@ "transcoding_max_bitrate_description": "Maksimum bit hızı ayarlamak, kaliteye küçük bir maliyetle dosya boyutlarını daha öngörülebilir hale getirebilir.", "transcoding_max_keyframe_interval": "", "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", + "transcoding_optimal_description": "Hedef çözünürlükten yüksek veya kabul edilen formatta olmayan videolar", "transcoding_preferred_hardware_device": "Tercih edilen donanım cihazı", "transcoding_preferred_hardware_device_description": "Sadece VAAPI ve QSV için uygulanır. Donanım kod çevrimi için DRI Node ayarlar.", "transcoding_preset_preset": "", "transcoding_preset_preset_description": "Sıkıştırma hızı. Daha yavaş olan ayarlar belirli bitrate ayarları için daha küçük ve daha kaliteli dosya üretir. VP9 ayarı 'daha hızlı' ayarının üstündeki ayarları görmezden gelir.", "transcoding_reference_frames": "Referans kareler", "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", + "transcoding_required_description": "Yalnızca kabul edilen formatta olmayan videolar", + "transcoding_settings": "Video Dönüştürme Ayarları", + "transcoding_settings_description": "Video dosyalarının çözünürlük ve kodlama bilgilerini yönetir", "transcoding_target_resolution": "Hedef çözünürlük", "transcoding_target_resolution_description": "Daha yüksek çözünürlükler daha fazla detayı koruyabilir fakat işlemesi daha uzun sürer, dosya boyutu daha yüksek olur ve uygulamanın akıcılığını etkileyebilir.", "transcoding_temporal_aq": "", @@ -289,9 +290,9 @@ "transcoding_threads": "İş Parçacıkları", "transcoding_threads_description": "", "transcoding_tone_mapping": "Ton-haritalama", - "transcoding_tone_mapping_description": "", + "transcoding_tone_mapping_description": "HDR videoların SDR'ye dönüştürülürken görünümünü korumayı amaçlar. Her algoritma renk, detay ve parlaklık için farklı dengeleme yapar. Hable detayları korur, Mobius renkleri korur ve Reinhard parlaklığı korur.", "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", + "transcoding_tone_mapping_npl_description": "Renkler, bu parlaklıkta bir ekran için normal görünecek şekilde ayarlanacaktır. Karşıt olarak, daha düşük değerler videonun parlaklığını artırır ve tersi de geçerlidir çünkü ekranın parlaklığını telafi eder. 0 bu değeri otomatik olarak ayarlar.", "transcoding_transcode_policy": "", "transcoding_transcode_policy_description": "", "transcoding_two_pass_encoding": "", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index 0b8241d89edcc..00137c37e4409 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -1,7 +1,7 @@ { "about": "Про програму", "account": "Обліковий запис", - "account_settings": "Налаштування Облікового запису", + "account_settings": "Налаштування профілю", "acknowledge": "Прийняти", "action": "Дія", "actions": "Дії", @@ -47,7 +47,7 @@ "duplicate_detection_job_description": "Запустити машинне навчання на активах для виявлення схожих зображень. Залежить від інтелектуального пошуку", "exclusion_pattern_description": "Шаблони виключень дозволяють ігнорувати файли та папки під час сканування вашої бібліотеки. Це корисно, якщо у вас є папки, які містять файли, які ви не хочете імпортувати, наприклад, RAW-файли.", "external_library_created_at": "Зовнішня бібліотека (створена {date})", - "external_library_management": "Управління Зовнішньою Бібліотекою", + "external_library_management": "Керування зовнішніми бібліотеками", "face_detection": "Виявлення обличчя", "face_detection_description": "Виявлення обличчя на активах з використанням машинного навчання. Для відео розглядається лише ескіз. Опція \"Усі\" повторно обробляє всі активи. Опція \"Відсутні\" ставить в чергу активи, які ще не були оброблені. Виявлені обличчя будуть поставлені в чергу для визначення обличчя після завершення виявлення обличчя, групуючи їх в існуючих або нових людей.", "facial_recognition_job_description": "Групувати виявлені обличчя у людей. Цей крок виконується після завершення виявлення обличчя. Опція \"Усі\" перегруповує всі обличчя. Опція \"Відсутні\" ставить в чергу обличчя, які ще не мають призначеної особи.", @@ -129,12 +129,13 @@ "map_enable_description": "Увімкнути функції мапи", "map_gps_settings": "Налаштування карти та GPS", "map_gps_settings_description": "Керування налаштуваннями карти та GPS (зворотний геокодинг)", + "map_implications": "Функція карти використовує зовнішній сервіс плиток (tiles.immich.cloud)", "map_light_style": "Світлий стиль", "map_manage_reverse_geocoding_settings": "Керувати налаштуваннями зворотного геокодування", "map_reverse_geocoding": "Зворотне геокодування", "map_reverse_geocoding_enable_description": "Увімкнути зворотне геокодування", "map_reverse_geocoding_settings": "Налаштування зворотного геокодування", - "map_settings": "Налаштування Мапи", + "map_settings": "Мапа", "map_settings_description": "Управління налаштуваннями мапи", "map_style_description": "URL до теми мапи у форматі style.json", "metadata_extraction_job": "Витягнути метадані", @@ -209,7 +210,7 @@ "send_welcome_email": "Надіслати лист з вітанням", "server_external_domain_settings": "Зовнішній домен", "server_external_domain_settings_description": "Домен для публічних загальнодоступних посилань, включаючи http(s)://", - "server_settings": "Налаштування Серверу", + "server_settings": "Налаштування сервера", "server_settings_description": "Керування налаштуваннями сервера", "server_welcome_message": "Вітальне повідомлення", "server_welcome_message_description": "Повідомлення, яке відображається на сторінці входу.", @@ -278,11 +279,11 @@ "transcoding_preferred_hardware_device": "Переважний апаратний пристрій", "transcoding_preferred_hardware_device_description": "Застосовується тільки до VAAPI і QSV. Встановлює вузол DRI, який використовується для апаратного транскодування.", "transcoding_preset_preset": "Параметр (-preset)", - "transcoding_preset_preset_description": "Швидкість стиснення. Повільніше предустановки створюють менші файли і підвищують якість при встановленні певного бітрейту. VP9 ігнорує швидкості вище `faster`.", + "transcoding_preset_preset_description": "Швидкість стиснення. Повільніші пресети створюють менші файли і підвищують якість при певному бітрейті. VP9 ігнорує швидкості вище 'швидше'.", "transcoding_reference_frames": "Основні кадри", "transcoding_reference_frames_description": "Кількість кадрів, на які посилається при стисненні даного кадру. Вищі значення покращують ефективність стиснення, але збільшують час кодування. Значення 0 автоматично налаштовує це значення.", "transcoding_required_description": "Лише відео, що не у прийнятому форматі", - "transcoding_settings": "Налаштування Транскодування Відео", + "transcoding_settings": "Налаштування транскодування відео", "transcoding_settings_description": "Керування роздільною здатністю та кодуванням відеофайлів", "transcoding_target_resolution": "Роздільна здатність", "transcoding_target_resolution_description": "Вищі роздільні здатності можуть зберігати більше деталей, але займають більше часу на кодування, мають більші розміри файлів і можуть зменшити швидкість роботи додатку.", @@ -312,7 +313,7 @@ "user_delete_delay_settings_description": "Кількість днів після видалення для остаточного видалення акаунта користувача та його ресурсів. Задача видалення користувача запускається опівночі для перевірки користувачів, готових до видалення. Зміни цього налаштування будуть оцінені під час наступного виконання.", "user_delete_immediately": "Акаунт та ресурси користувача {user} будуть негайно поставлені в чергу на остаточне видалення.", "user_delete_immediately_checkbox": "Поставити користувача та ресурси в чергу для негайного видалення", - "user_management": "Управління користувачами", + "user_management": "Керування користувачами", "user_password_has_been_reset": "Пароль користувача було скинуто:", "user_password_reset_description": "Будь ласка, надайте користувачеві тимчасовий пароль і повідомте йому, що він повинен буде змінити пароль при наступному вході.", "user_restore_description": "Акаунт {user} буде відновлено.", @@ -320,7 +321,8 @@ "user_settings": "Налаштування користувача", "user_settings_description": "Керування налаштуваннями користувачів", "user_successfully_removed": "Користувача з електронною поштою {email} успішно видалено.", - "version_check_enabled_description": "Увімкнення періодичних запитів до GitHub для перевірки нових випусків", + "version_check_enabled_description": "Увімкнути перевірку версії", + "version_check_implications": "Функція перевірки версії залежить від періодичної комунікації з github.com", "version_check_settings": "Перевірка версії", "version_check_settings_description": "Увімкнути/вимкнути сповіщення про нову версію", "video_conversion_job": "Перекодувати відео", @@ -336,7 +338,8 @@ "album_added": "Альбом додано", "album_added_notification_setting_description": "Отримувати повідомлення по електронній пошті, коли вас додають до спільного альбому", "album_cover_updated": "Обкладинка альбому оновлена", - "album_delete_confirmation": "Ви впевнені, що хочете видалити альбом {album}?\nЯкщо цей альбом є спільним, інші користувачі більше не зможуть отримувати до нього доступ.", + "album_delete_confirmation": "Ви впевнені, що хочете видалити альбом {album}?", + "album_delete_confirmation_description": "Якщо альбом був спільним, інші користувачі не зможуть отримати доступ до нього.", "album_info_updated": "Інформація про альбом оновлена", "album_leave": "Залишити альбом?", "album_leave_confirmation": "Ви впевнені, що хочете залишити альбом {album}?", @@ -360,6 +363,7 @@ "allow_edits": "Дозволити редагування", "allow_public_user_to_download": "Дозволити публічному користувачеві завантажувати файли", "allow_public_user_to_upload": "Дозволити публічним користувачам завантажувати", + "anti_clockwise": "Проти годинникової стрілки", "api_key": "Ключ API", "api_key_description": "Це значення буде показане лише один раз. Будь ласка, обов'язково скопіюйте його перед закриттям вікна.", "api_key_empty": "Назва вашого ключа API не може бути порожньою", @@ -440,6 +444,7 @@ "clear_all_recent_searches": "Очистити всі останні пошукові запити", "clear_message": "Очистити повідомлення", "clear_value": "Очистити значення", + "clockwise": "По годинниковій стрілці", "close": "Закрити", "collapse": "Згорнути", "collapse_all": "Згорнути все", @@ -516,6 +521,8 @@ "do_not_show_again": "Не показувати це повідомлення знову", "done": "Готово", "download": "Скачати", + "download_include_embedded_motion_videos": "Вбудовані відео", + "download_include_embedded_motion_videos_description": "Включати відео, вбудовані в рухомі фотографії, як окремий файл", "download_settings": "Скачати", "download_settings_description": "Керування налаштуваннями, пов'язаними з завантаженням ресурсів", "downloading": "Скачування", @@ -548,7 +555,11 @@ "edit_title": "Редагувати заголовок", "edit_user": "Редагувати користувача", "edited": "Відредаговано", - "editor": "", + "editor": "Редактор", + "editor_close_without_save_prompt": "Зміни не будуть збережені", + "editor_close_without_save_title": "Закрити редактор?", + "editor_crop_tool_h2_aspect_ratios": "Пропорції зображення", + "editor_crop_tool_h2_rotation": "Орієнтація", "email": "Електронна пошта", "empty": "", "empty_album": "", @@ -698,6 +709,7 @@ "expired": "Закінчився термін дії", "expires_date": "Термін дії закінчується {date}", "explore": "Дослідити", + "explorer": "Провідник", "export": "Експортувати", "export_as_json": "Експорт в JSON", "extension": "Розширення", @@ -719,6 +731,7 @@ "filter_people": "Фільтр по людях", "find_them_fast": "Швидко знаходьте їх за назвою за допомогою пошуку", "fix_incorrect_match": "Виправити неправильний збіг", + "folders": "Папки", "force_re-scan_library_files": "Примусово пересканувати всі файли бібліотеки", "forward": "Переслати", "general": "Загальні", @@ -911,6 +924,7 @@ "ok": "ОК", "oldest_first": "Спочатку найстарші", "onboarding": "Введення", + "onboarding_privacy_description": "Наступні (необов'язкові) функції залежать від зовнішніх сервісів і можуть бути вимкнені в будь-який час у налаштуваннях адміністрації.", "onboarding_theme_description": "Виберіть колірну тему для свого екземпляра. Ви можете змінити її пізніше в налаштуваннях.", "onboarding_welcome_description": "Давайте налаштуємо ваш екземпляр за допомогою деяких загальних параметрів.", "onboarding_welcome_user": "Ласкаво просимо, {user}", @@ -927,7 +941,7 @@ "other": "Інше", "other_devices": "Інші пристрої", "other_variables": "Інші змінні", - "owned": "У власності", + "owned": "Власні", "owner": "Власник", "partner": "Партнер", "partner_can_access": "{partner} має доступ", @@ -983,6 +997,7 @@ "previous_memory": "Попередній спогад", "previous_or_next_photo": "Попередня або наступна фотографія", "primary": "Головне", + "privacy": "Конфіденційність", "profile_image_of_user": "Зображення профілю {user}", "profile_picture_set": "Зображення профілю встановлено.", "public_album": "Публічний альбом", @@ -1021,6 +1036,9 @@ "purchase_settings_server_activated": "Ключ продукту сервера керується адміністратором", "range": "", "rating": "Зоряний рейтинг", + "rating_clear": "Очистити рейтинг", + "rating_count": "{count, plural, one {# зірка} few {# зірки} many {# зірок} other {# зірок}}", + "rating_description": "Показувати рейтинг EXIF в інформаційній панелі", "raw": "", "reaction_options": "Опції реакції", "read_changelog": "Прочитати зміни в оновленні", @@ -1125,8 +1143,8 @@ "send_message": "Надіслати повідомлення", "send_welcome_email": "Надішліть вітальний лист", "server": "Сервер", - "server_offline": "Сервер відключено", - "server_online": "Сервер підключено", + "server_offline": "Сервер офлайн", + "server_online": "Сервер онлайн", "server_stats": "Статистика сервера", "server_version": "Версія сервера", "set": "Встановіть", @@ -1143,6 +1161,7 @@ "shared_by_user": "Спільний доступ з {user}", "shared_by_you": "Ви поділились", "shared_from_partner": "Фото від {partner}", + "shared_link_options": "Опції спільних посилань", "shared_links": "Спільні посилання", "shared_photos_and_videos_count": "{assetCount, plural, other {# спільні фотографії та відео.}}", "shared_with_partner": "Спільно з {partner}", @@ -1151,6 +1170,7 @@ "sharing_sidebar_description": "Відображати посилання на загальний доступ у бічній панелі", "shift_to_permanent_delete": "натисніть ⇧ щоб видалити об'єкт назавжди", "show_album_options": "Показати параметри альбому", + "show_albums": "Показувати альбоми", "show_all_people": "Показати всіх людей", "show_and_hide_people": "Показати та приховати людей", "show_file_location": "Показати розташування файлу", @@ -1179,10 +1199,12 @@ "sort_items": "Кількість елементів", "sort_modified": "Дата зміни", "sort_oldest": "Старі фото", - "sort_recent": "Нещодавні фото", + "sort_recent": "Нещодавні", "sort_title": "Заголовок", "source": "Джерело", "stack": "Стек", + "stack_duplicates": "Групувати дублікати", + "stack_select_one_photo": "Вибрати одне основне фото для групи", "stack_selected_photos": "Сгрупувати обрані фотографії", "stacked_assets_count": "Згруповано {count, plural, one {# ресурс} few {# ресурси} many {# ресурсів} other {# ресурсів}}", "stacktrace": "Стек викликів", @@ -1194,7 +1216,7 @@ "stop_photo_sharing": "Припинити надання ваших знімків?", "stop_photo_sharing_description": "{partner} більше не матиме доступу до ваших фотографій.", "stop_sharing_photos_with_user": "Припинити ділитися своїми фотографіями з цим користувачем", - "storage": "Місце для зберігання", + "storage": "Сховище", "storage_label": "Мітка для зберігання", "storage_usage": "{used} з {available} доступних", "submit": "Підтвердити", @@ -1237,6 +1259,7 @@ "unlink_oauth": "Від'єднайте OAuth", "unlinked_oauth_account": "Відключити акаунт OAuth", "unnamed_album": "Альбом без назви", + "unnamed_album_delete_confirmation": "Ви впевнені, що бажаєте видалити цей альбом?", "unnamed_share": "Спільний доступ без назви", "unsaved_change": "Незбережена зміна", "unselect_all": "Зняти все", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index 58fb4a85f35b3..78d19e11d9dc5 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -129,6 +129,7 @@ "map_enable_description": "Bật tính năng bản đồ", "map_gps_settings": "Bản đồ & GPS", "map_gps_settings_description": "Quản lý cài đặt Bản đồ & GPS (Mã hóa địa lý ngược)", + "map_implications": "Tính năng bản đồ phụ thuộc vào dịch vụ thẻ bản đồ bên ngoài (tiles.immich.cloud)", "map_light_style": "Giao diện sáng", "map_manage_reverse_geocoding_settings": "Quản lý cài đặt Mã hóa địa lý ngược", "map_reverse_geocoding": "Mã hoá địa lý ngược (Reverse Geocoding)", @@ -173,7 +174,7 @@ "oauth_issuer_url": "Địa chỉ nhà cung cấp OAuth", "oauth_mobile_redirect_uri": "URI chuyển hướng trên thiết bị di động", "oauth_mobile_redirect_uri_override": "Ghi đè URI chuyển hướng cho thiết bị di động", - "oauth_mobile_redirect_uri_override_description": "Bật khi 'app.immich:/' là URI chuyển hướng không hợp lệ.", + "oauth_mobile_redirect_uri_override_description": "Bật khi nhà cung cấp OAuth không cho phép URI di động, như '{callback}'", "oauth_profile_signing_algorithm": "Thuật toán ký vào hồ sơ người dùng", "oauth_profile_signing_algorithm_description": "Thuật toán được sử dụng để ký vào hồ sơ người dùng.", "oauth_scope": "Phạm vi", @@ -222,7 +223,7 @@ "storage_template_enable_description": "Bật công cụ mẫu lưu trữ", "storage_template_hash_verification_enabled": "Bật xác minh băm", "storage_template_hash_verification_enabled_description": "Bật xác minh băm, không tắt tính năng này trừ khi bạn chắc chắn về các rủi ro có thể xảy ra", - "storage_template_migration": "Dịch chuyển mẫu lưu trữ", + "storage_template_migration": "Di chuyển mẫu lưu trữ", "storage_template_migration_description": "Áp dụng {template} hiện tại cho các ảnh đã được tải lên trước đây", "storage_template_migration_info": "Các thay đổi mẫu chỉ áp dụng cho các ảnh mới. Để áp dụng lại mẫu cho các ảnh đã được tải lên trước đây, hãy chạy {job}.", "storage_template_migration_job": "Tác vụ di chuyển mẫu lưu trữ", @@ -320,7 +321,8 @@ "user_settings": "Người dùng", "user_settings_description": "Quản lý cài đặt người dùng", "user_successfully_removed": "Người dùng {email} đã được xóa thành công.", - "version_check_enabled_description": "Bật gửi yêu cầu định kỳ đến GitHub để kiểm tra các bản phát hành mới", + "version_check_enabled_description": "Bật kiểm tra phiên bản", + "version_check_implications": "Tính năng kiểm tra phiên bản yêu cầu kết nối thường xuyên đến github.com", "version_check_settings": "Kiểm tra phiên bản", "version_check_settings_description": "Bật/tắt thông báo phiên bản mới", "video_conversion_job": "Chuyển mã video", @@ -336,7 +338,8 @@ "album_added": "Đã thêm album", "album_added_notification_setting_description": "Nhận thông báo qua email khi bạn được thêm vào một album chia sẻ", "album_cover_updated": "Đã cập nhật ảnh bìa album", - "album_delete_confirmation": "Bạn có chắc chắn muốn xóa album {album} không?\nNếu album này đang được chia sẻ, các người dùng khác sẽ không còn truy cập được nữa.", + "album_delete_confirmation": "Bạn có chắc chắn muốn xóa album {album} không?", + "album_delete_confirmation_description": "Nếu album này được chia sẻ, các người dùng khác sẽ không còn truy cập được nữa.", "album_info_updated": "Đã cập nhật thông tin album", "album_leave": "Rời album?", "album_leave_confirmation": "Bạn có chắc chắn muốn rời khỏi {album} không?", @@ -360,6 +363,7 @@ "allow_edits": "Cho phép chỉnh sửa", "allow_public_user_to_download": "Cho phép người dùng công khai tải xuống", "allow_public_user_to_upload": "Cho phép người dùng công khai tải lên", + "anti_clockwise": "Xoay trái", "api_key": "Khóa API", "api_key_description": "Giá trị này chỉ được hiển thị một lần. Vui lòng sao chép nó trước khi đóng cửa sổ.", "api_key_empty": "Tên khóa API của bạn không được để trống", @@ -369,7 +373,7 @@ "archive": "Lưu trữ", "archive_or_unarchive_photo": "Lưu trữ hoặc huỷ lưu trữ ảnh", "archive_size": "Kích thước gói nén", - "archive_size_description": "Cấu hình kích thước cho các tập tin nén tải về (đơn vị GiB)", + "archive_size_description": "Cấu hình kích thước nén cho các tập tin tải xuống (đơn vị GiB)", "archived": "", "archived_count": "{count, plural, other {Đã lưu trữ # mục}}", "are_these_the_same_person": "Đây có phải cùng một người không?", @@ -440,10 +444,11 @@ "clear_all_recent_searches": "Xóa tất cả tìm kiếm gần đây", "clear_message": "Xóa tin nhắn", "clear_value": "Xóa giá trị", + "clockwise": "Xoay phải", "close": "Đóng", "collapse": "Thu gọn", "collapse_all": "Thu gọn tất cả", - "color_theme": "Giao diện màu", + "color_theme": "Chủ đề màu sắc", "comment_deleted": "Bình luận đã bị xóa", "comment_options": "Tùy chọn bình luận", "comments_and_likes": "Bình luận & lượt thích", @@ -480,7 +485,7 @@ "created": "Đã tạo", "current_device": "Thiết bị hiện tại", "custom_locale": "Ngôn ngữ và khu vực tùy chỉnh", - "custom_locale_description": "Định dạng ngày tháng và số dựa trên ngôn ngữ và khu vực", + "custom_locale_description": "Định dạng ngày và số dựa trên ngôn ngữ và khu vực", "dark": "Tối", "date_after": "Ngày sau", "date_and_time": "Ngày và giờ", @@ -490,7 +495,7 @@ "day": "Ngày", "deduplicate_all": "Xóa tất cả mục trùng lặp", "default_locale": "Ngôn ngữ và khu vực mặc định", - "default_locale_description": "Định dạng ngày tháng và số dựa trên ngôn ngữ của trình duyệt của bạn", + "default_locale_description": "Định dạng ngày và số dựa trên ngôn ngữ trình duyệt của bạn", "delete": "Xóa", "delete_album": "Xóa album", "delete_api_key_prompt": "Bạn có chắc chắn muốn xóa khóa API này không?", @@ -506,7 +511,7 @@ "direction": "Hướng", "disabled": "Tắt", "disallow_edits": "Không cho phép chỉnh sửa", - "discover": "Khám phá", + "discover": "Tìm", "dismiss_all_errors": "Bỏ qua tất cả lỗi", "dismiss_error": "Bỏ qua lỗi", "display_options": "Tùy chọn hiển thị", @@ -516,6 +521,8 @@ "do_not_show_again": "Không hiển thị thông báo này nữa", "done": "Xong", "download": "Tải xuống", + "download_include_embedded_motion_videos": "Các video nhúng", + "download_include_embedded_motion_videos_description": "Gồm các video được nhúng trong ảnh chuyển động thành một tập tin riêng", "download_settings": "Tải xuống", "download_settings_description": "Quản lý cài đặt liên quan đến việc tải ảnh xuống", "downloading": "Đang tải xuống", @@ -548,7 +555,11 @@ "edit_title": "Chỉnh sửa tiêu đề", "edit_user": "Chỉnh sửa người dùng", "edited": "Đã chỉnh sửa", - "editor": "", + "editor": "Trình chỉnh sửa", + "editor_close_without_save_prompt": "Những thay đổi sẽ không được lưu", + "editor_close_without_save_title": "Đóng trình chỉnh sửa?", + "editor_crop_tool_h2_aspect_ratios": "Tỷ lệ khung hình", + "editor_crop_tool_h2_rotation": "Xoay", "email": "Email", "empty": "", "empty_album": "", @@ -698,6 +709,7 @@ "expired": "Hết hạn", "expires_date": "Hết hạn vào {date}", "explore": "Khám phá", + "explorer": "Khám phá", "export": "Xuất", "export_as_json": "Xuất dưới dạng JSON", "extension": "Phần mở rộng", @@ -719,6 +731,7 @@ "filter_people": "Lọc người", "find_them_fast": "Tìm nhanh bằng tên với tìm kiếm", "fix_incorrect_match": "Sửa lỗi trùng khớp không chính xác", + "folders": "Thư mục", "force_re-scan_library_files": "Yêu cầu quét lại tất cả các tập tin thư viện", "forward": "Tiến về phía trước", "general": "Chung", @@ -883,6 +896,7 @@ "ok": "Đồng ý", "oldest_first": "Cũ nhất trước", "onboarding": "Hướng dẫn sử dụng", + "onboarding_privacy_description": "Các tính năng (tùy chọn) sau đây phụ thuộc vào các dịch vụ bên ngoài và có thể bị tắt bất kỳ lúc nào trong cài đặt quản trị.", "onboarding_theme_description": "Chọn chủ đề màu sắc cho tài khoản riêng của bạn. Bạn có thể thay đổi điều này sau trong cài đặt của bạn.", "onboarding_welcome_description": "Hãy thiết lập tài khoản riêng của bạn với một số cài đặt cơ bản.", "onboarding_welcome_user": "Chào mừng, {user}", @@ -945,7 +959,7 @@ "places": "Địa điểm", "play": "Phát", "play_memories": "Phát kỷ niệm", - "play_motion_photo": "Phát ảnh động", + "play_motion_photo": "Phát ảnh chuyển động", "play_or_pause_video": "Phát hoặc tạm dừng video", "point": "", "port": "Cổng", @@ -955,6 +969,7 @@ "previous_memory": "Kỷ niệm trước", "previous_or_next_photo": "Ảnh trước hoặc sau", "primary": "Chính", + "privacy": "Bảo mật", "profile_image_of_user": "Ảnh đại diệncủa {user}", "profile_picture_set": "Ảnh đại diện đã được đặt.", "public_album": "Album công khai", @@ -968,7 +983,7 @@ "purchase_button_buy_immich": "Mua Immich", "purchase_button_never_show_again": "Không hiển thị lại", "purchase_button_reminder": "Nhắc tôi trong 30 ngày", - "purchase_button_remove_key": "Xoá khóa", + "purchase_button_remove_key": "Xóa khóa", "purchase_button_select": "Chọn", "purchase_failed_activation": "Kích hoạt thất bại! Vui lòng kiểm tra email của bạn để biết khóa sản phẩm chính xác!", "purchase_individual_description_1": "Dành cho cá nhân", @@ -983,9 +998,9 @@ "purchase_panel_title": "Hỗ trợ dự án", "purchase_per_server": "Mỗi máy chủ", "purchase_per_user": "Mỗi người dùng", - "purchase_remove_product_key": "Xoá khóa sản phẩm", + "purchase_remove_product_key": "Xóa khóa sản phẩm", "purchase_remove_product_key_prompt": "Bạn có chắc chắn muốn xoá khóa sản phẩm?", - "purchase_remove_server_product_key": "Xoá khóa sản phẩm máy chủ", + "purchase_remove_server_product_key": "Xóa khóa sản phẩm máy chủ", "purchase_remove_server_product_key_prompt": "Bạn có chắc chắn muốn xoá khóa sản phẩm máy chủ?", "purchase_server_description_1": "Dành cho toàn bộ máy chủ", "purchase_server_description_2": "Trạng thái người hỗ trợ", @@ -993,6 +1008,8 @@ "purchase_settings_server_activated": "Khóa sản phẩm máy chủ được quản lý bởi quản trị viên", "range": "", "rating": "Xếp hạng sao", + "rating_clear": "Xóa đánh giá", + "rating_count": "{count, plural, one {# sao} other {# sao}}", "rating_description": "Hiển thị xếp hạng ảnh trong bảng thông tin", "raw": "", "reaction_options": "Tùy chọn phản ứng", @@ -1012,16 +1029,16 @@ "refreshing_encoded_video": "Đang làm mới video đã mã hóa", "refreshing_metadata": "Đang làm mới metadata", "regenerating_thumbnails": "Đang tạo lại hình thu nhỏ", - "remove": "Xoá", + "remove": "Xóa", "remove_assets_album_confirmation": "Bạn có chắc chắn muốn xoá {count, plural, one {# mục} other {# mục}} khỏi album?", "remove_assets_shared_link_confirmation": "Bạn có chắc chắn muốn xoá {count, plural, one {# mục} other {# mục}} khỏi liên kết chia sẻ này?", - "remove_assets_title": "Xoá mục?", + "remove_assets_title": "Xóa mục?", "remove_custom_date_range": "Bỏ chọn khoảng ngày tùy chỉnh", - "remove_from_album": "Xoá khỏi album", - "remove_from_favorites": "Xoá khỏi Mục yêu thích", - "remove_from_shared_link": "Xoá khỏi liên kết chia sẻ", + "remove_from_album": "Xóa khỏi album", + "remove_from_favorites": "Xóa khỏi Mục yêu thích", + "remove_from_shared_link": "Xóa khỏi liên kết chia sẻ", "remove_offline_files": "Loại bỏ tập tin ngoại tuyến", - "remove_user": "Xoá người dùng", + "remove_user": "Xóa người dùng", "removed_api_key": "Khóa API đã xóa: {name}", "removed_from_archive": "Đã xoá khỏi Kho lưu trữ", "removed_from_favorites": "Đã xoá khỏi Mục yêu thích", @@ -1163,7 +1180,7 @@ "stack_selected_photos": "Nhóm các ảnh đã chọn", "stacked_assets_count": "Đã nhóm {count, plural, one {# mục} other {# mục}}", "stacktrace": "Thông tin chi tiết lỗi", - "start": "Bắt đầu", + "start": "Chạy", "start_date": "Ngày bắt đầu", "state": "Tỉnh", "status": "Trạng thái", @@ -1180,9 +1197,9 @@ "swap_merge_direction": "Đổi hướng hợp nhất", "sync": "Đồng bộ", "template": "Mẫu", - "theme": "Giao diện", - "theme_selection": "Giao diện tổng thể", - "theme_selection_description": "Tự động đặt giao diện sáng hoặc tối dựa trên tùy chọn hệ thống của trình duyệt của bạn", + "theme": "Chủ đề", + "theme_selection": "Chủ đề tổng thể", + "theme_selection_description": "Tự động đặt chủ đề sáng hoặc tối dựa trên tùy chọn hệ thống của trình duyệt của bạn", "they_will_be_merged_together": "Chúng sẽ được hợp nhất với nhau", "time_based_memories": "Kỷ niệm dựa trên thời gian", "timezone": "Múi giờ", @@ -1190,14 +1207,14 @@ "to_change_password": "Đổi mật khẩu", "to_favorite": "Yêu thích", "to_login": "Đăng nhập", - "to_trash": "Xoá", + "to_trash": "Xóa", "toggle_settings": "Chuyển đổi cài đặt", - "toggle_theme": "Chuyển đổi giao diện", + "toggle_theme": "Chuyển đổi chủ đề tối", "toggle_visibility": "", "total_usage": "Tổng dung lượng đã sử dụng", "trash": "Thùng rác", - "trash_all": "Xoá hết", - "trash_count": "Xoá {count, number} mục", + "trash_all": "Xóa hết", + "trash_count": "Xóa {count, number} mục", "trash_delete_asset": "Chuyển vào thùng rác/Xóa vĩnh viễn", "trash_no_results_message": "Ảnh và video đã bị xoá sẽ hiển thị ở đây.", "trashed_items_will_be_permanently_deleted_after": "Các mục đã xóa sẽ bị xóa vĩnh viễn sau {days, plural, one {# ngày} other {# ngày}}.", @@ -1214,6 +1231,7 @@ "unlink_oauth": "Huỷ liên kết OAuth", "unlinked_oauth_account": "Đã huỷ liên kết tài khoản OAuth", "unnamed_album": "Album chưa đặt tên", + "unnamed_album_delete_confirmation": "Bạn có chắc chắn muốn xóa album này không?", "unnamed_share": "Chia sẻ chưa đặt tên", "unsaved_change": "Thay đổi chưa lưu", "unselect_all": "Bỏ chọn tất cả", diff --git a/web/src/lib/i18n/zh_Hant.json b/web/src/lib/i18n/zh_Hant.json index f0787bd5b316d..a133b5ad1c948 100644 --- a/web/src/lib/i18n/zh_Hant.json +++ b/web/src/lib/i18n/zh_Hant.json @@ -24,8 +24,8 @@ "add_to_album": "加入相簿", "add_to_shared_album": "加入共享相簿", "added_to_archive": "已加入封存", - "added_to_favorites": "新增至收藏", - "added_to_favorites_count": "已新增 {count, number} 個項目至收藏", + "added_to_favorites": "已加入收藏", + "added_to_favorites_count": "已把 {count, number} 個項目加入收藏", "admin": { "add_exclusion_pattern_description": "新增排除規則。支援使用「*」、「 **」、「?」來匹配字串。如果要排除所有名稱為「Raw」的檔案或目錄,請使用「**/Raw/**」。如果要排除所有「.tif」結尾的檔案,請使用「**/*.tif」。如果要排除某個絕對路徑,請使用「/path/to/ignore/**」。", "authentication_settings": "驗證設定", @@ -36,7 +36,7 @@ "check_all": "全選", "cleared_jobs": "已清除 {job} 的任務", "config_set_by_file": "目前的設定已透過設定檔案設置", - "confirm_delete_library": "您確定要刪除 {library} 嗎?", + "confirm_delete_library": "確定要刪除「{library}」(圖庫)嗎?", "confirm_delete_library_assets": "您確定要刪除此圖庫嗎?這將從 Immich 中刪除{count, plural, one {個項目} other {個項目}},且無法復原。檔案仍會保留在硬碟中。", "confirm_email_below": "請在底下輸入 {email} 來確認", "confirm_reprocess_all_faces": "您確定要重新處理所有面孔嗎?這將清除已命名的面孔。", @@ -44,7 +44,7 @@ "crontab_guru": "", "disable_login": "停用登入", "disabled": "已禁用", - "duplicate_detection_job_description": "運行機器學習以檢測相似圖像。此功能仰賴智慧搜尋", + "duplicate_detection_job_description": "對檔案執行機器學習來偵測相似圖片。(此功能仰賴智慧搜尋)", "exclusion_pattern_description": "排除規則讓您在掃描資料庫時忽略特定文件和文件夾。用於當您有不想導入的文件(例如 RAW 文件)或文件夾。", "external_library_created_at": "外部圖庫(於 {date} 建立)", "external_library_management": "外部圖庫管理", @@ -61,14 +61,14 @@ "image_prefer_wide_gamut_setting_description": "使用 Display P3 來製作縮圖。這可以更好地保留廣色域圖片的鮮豔度,但在舊版瀏覽器或舊設備上,圖片可能會顯示不同。sRGB 圖片會維持 sRGB 以避免顏色變化。", "image_preview_format": "預覽格式", "image_preview_resolution": "預覽解析度", - "image_preview_resolution_description": "檢視單張照片和機器學習時用。高解析度可以保留更多細節,但會增加編碼時間、增加檔案大小、降低應用軟體的流暢度。", + "image_preview_resolution_description": "觀賞單張照片及機器學習時用。較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", "image_quality": "品質", "image_quality_description": "圖片品質從1到100,數值越高代表品質越好但檔案也越大,此選項影響預覽和縮圖圖片。", "image_settings": "圖片設定", - "image_settings_description": "管理生成圖片的品質和解析度", + "image_settings_description": "管理產生圖片的品質和解析度", "image_thumbnail_format": "縮圖格式", "image_thumbnail_resolution": "縮圖解析度", - "image_thumbnail_resolution_description": "檢視多張照片時用(時間軸、相冊等⋯)。高解析度可以保留更多細節,但會增加編碼時間、增加檔案大小、降低應用軟體的流暢度。", + "image_thumbnail_resolution_description": "觀賞多張照片時(時間軸、相簿等)用。較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", "job_concurrency": "{job}並行", "job_not_concurrency_safe": "這個任務並行並不安全。", "job_settings": "任務設定", @@ -77,9 +77,9 @@ "jobs_delayed": "{jobCount, plural, other {# 項任務延遲}}", "jobs_failed": "{jobCount, plural, other {# 項}}任務失敗", "library_created": "已建立圖庫:{library}", - "library_cron_expression": "Cron 表達式", - "library_cron_expression_description": "以 cron 格式設定掃描時段。詳細資訊請參考 Crontab Guru", - "library_cron_expression_presets": "現成的 Cron 表達式", + "library_cron_expression": "Cron 運算式", + "library_cron_expression_description": "以 Cron 格式設定掃描時段。詳細資訊請參閱 Crontab Guru", + "library_cron_expression_presets": "現成的 Cron 運算式", "library_deleted": "圖庫已刪除", "library_import_path_description": "選取要載入的資料夾。以掃描資料夾(含子資料夾)內的影像和影片。", "library_scanning": "定期掃描", @@ -96,8 +96,8 @@ "logging_settings": "記錄檔", "machine_learning_clip_model": "CLIP 模型", "machine_learning_clip_model_description": "CLIP 模型 名稱列表。更換模型後須對所有影像重新執行「智慧搜尋」。", - "machine_learning_duplicate_detection": "重複檢測", - "machine_learning_duplicate_detection_enabled": "啟用重複檢測", + "machine_learning_duplicate_detection": "重複項目偵測", + "machine_learning_duplicate_detection_enabled": "啟用重複項目偵測", "machine_learning_duplicate_detection_enabled_description": "即使停用,完全一樣的素材仍會被忽略。", "machine_learning_duplicate_detection_setting_description": "用 CLIP 向量比對潛在重複", "machine_learning_enabled": "啟用機器學習", @@ -125,16 +125,17 @@ "machine_learning_url_description": "機器學習伺服器的網址", "manage_concurrency": "管理並行", "manage_log_settings": "管理日誌設定", - "map_dark_style": "深色模式", + "map_dark_style": "深色樣式", "map_enable_description": "啟用地圖功能", "map_gps_settings": "地圖與 GPS 設定", "map_gps_settings_description": "管理地圖和 GPS(逆向地理編碼)設定", - "map_light_style": "淺色模式", + "map_implications": "地圖功能依賴外部平貼服務(tiles.immich.cloud)", + "map_light_style": "淺色樣式", "map_manage_reverse_geocoding_settings": "管理逆向地理編碼設定", "map_reverse_geocoding": "逆向地理編碼", "map_reverse_geocoding_enable_description": "啟用逆向地理編碼", "map_reverse_geocoding_settings": "逆向地理編碼設定", - "map_settings": "地圖設定", + "map_settings": "地圖", "map_settings_description": "管理地圖設定", "map_style_description": "地圖主題(style.json)的網址", "metadata_extraction_job": "擷取元資料", @@ -143,16 +144,16 @@ "migration_job_description": "將照片和人臉的縮圖遷移到最新的文件夾結構", "no_paths_added": "未添加路徑", "no_pattern_added": "未添加pattern", - "note_apply_storage_label_previous_assets": "注意:若要將存儲標籤應用於先前上傳的圖片,請運行", - "note_cannot_be_changed_later": "註:這將無法更改!", + "note_apply_storage_label_previous_assets": "註:要將儲存標籤用於先前上傳的檔案,請執行", + "note_cannot_be_changed_later": "註:之後就無法更改嘍!", "note_unlimited_quota": "註:輸入 0 表示不限制配額", - "notification_email_from_address": "發出電郵", - "notification_email_from_address_description": "寄出人電郵,例如:\"Immich 相片伺服器 \"", - "notification_email_host_description": "電郵伺服器主機位址 (e.g. smtp.immich.app)", + "notification_email_from_address": "寄件地址", + "notification_email_from_address_description": "寄件者電子郵件地址(例:Immich Photo Server )", + "notification_email_host_description": "電子郵件伺服器主機(例:smtp.immich.app)", "notification_email_ignore_certificate_errors": "忽略憑證錯誤", "notification_email_ignore_certificate_errors_description": "忽略 TLS 憑證驗證錯誤(不建議)", "notification_email_password_description": "以電子郵件伺服器驗證身份時的密碼", - "notification_email_port_description": "電郵伺服器端口(例如 25、465 或 587)", + "notification_email_port_description": "電子郵件伺服器埠口(如: 25、465 或 587)", "notification_email_sent_test_email_button": "傳送測試電子郵件並儲存", "notification_email_setting_description": "發送電子郵件通知的設置", "notification_email_test_email": "傳送測試電子郵件", @@ -160,20 +161,20 @@ "notification_email_test_email_sent": "測試電子郵件已發送至 {email}。請檢查您的收件箱。", "notification_email_username_description": "以電子郵件伺服器驗證身份時的使用者名稱", "notification_enable_email_notifications": "啟用電子郵件通知", - "notification_settings": "通知設定", + "notification_settings": "通知", "notification_settings_description": "管理通知設置,包括電子郵件通知", "oauth_auto_launch": "自動啟動", "oauth_auto_launch_description": "導覽至登入頁面後自動進行 OAuth 登入流程", "oauth_auto_register": "自動註冊", "oauth_auto_register_description": "使用 OAuth 登錄後自動註冊新用戶", "oauth_button_text": "按鈕文字", - "oauth_client_id": "用戶端識別碼", - "oauth_client_secret": "用戶端密碼", + "oauth_client_id": "客戶端 ID", + "oauth_client_secret": "客戶端密鑰", "oauth_enable_description": "用 OAuth 登入", "oauth_issuer_url": "簽發者網址", "oauth_mobile_redirect_uri": "移動端重定向 URI", "oauth_mobile_redirect_uri_override": "移動端重定向 URI 覆蓋", - "oauth_mobile_redirect_uri_override_description": "當 'app.immich:/' 是無效的重定向 URI 時啟用。", + "oauth_mobile_redirect_uri_override_description": "當 OAuth 提供者不允許使用行動 URI(如「{callback}」)時啟用", "oauth_profile_signing_algorithm": "用戶檔簽名算法", "oauth_profile_signing_algorithm_description": "用於簽署用戶檔的算法。", "oauth_scope": "範圍", @@ -181,8 +182,8 @@ "oauth_settings_description": "管理 OAuth 登入設定", "oauth_settings_more_details": "欲瞭解此功能,請參閱文件。", "oauth_signing_algorithm": "簽名算法", - "oauth_storage_label_claim": "儲存標記宣告", - "oauth_storage_label_claim_description": "自動將使用者的儲存標記定爲此宣告之值。", + "oauth_storage_label_claim": "儲存標籤宣告", + "oauth_storage_label_claim_description": "自動將使用者的儲存標籤定爲此宣告之值。", "oauth_storage_quota_claim": "儲存配額宣告", "oauth_storage_quota_claim_description": "自動將使用者的儲存配額定爲此宣告之值。", "oauth_storage_quota_default": "預設儲存配額(GiB)", @@ -195,28 +196,28 @@ "paths_validated_successfully": "所有路徑驗證成功", "quota_size_gib": "配額(GiB)", "refreshing_all_libraries": "正在重新整理所有圖庫", - "registration": "管理員註冊", + "registration": "管理者註冊", "registration_description": "由於您是本系統的首位使用者,因此將您指派爲負責管理本系統的管理者,其他使用者須由您協助建立帳號。", "removing_offline_files": "移除離線檔案中", "repair_all": "全部糾正", "repair_matched_items": "有 {count, plural, other {# 個項目相符}}", "repaired_items": "已糾正 {count, plural, other {# 個項目}}", "require_password_change_on_login": "要求使用者在首次登入時更改密碼", - "reset_settings_to_default": "重置設置為默認值", - "reset_settings_to_recent_saved": "重置設置為最近保存的設置", + "reset_settings_to_default": "將設定重設回預設", + "reset_settings_to_recent_saved": "已設回最後儲存的設定", "scanning_library_for_changed_files": "正在掃描資料庫以檢查文件變更", "scanning_library_for_new_files": "正在掃描資料庫以檢查新文件", "send_welcome_email": "傳送歡迎電子郵件", "server_external_domain_settings": "外部網域", "server_external_domain_settings_description": "公開分享鏈結的網域(包含「http(s)://」)", - "server_settings": "伺服器設定", + "server_settings": "伺服器", "server_settings_description": "管理伺服器設定", "server_welcome_message": "歡迎訊息", "server_welcome_message_description": "在登入頁面顯示的訊息。", "sidecar_job": "側接元資料", "sidecar_job_description": "從檔案系統探索或同步側接(Sidecar)元資料", "slideshow_duration_description": "每張圖片放映的秒數", - "smart_search_job_description": "對檔案運行機器學習以用於智能搜尋", + "smart_search_job_description": "對檔案執行機器學習,以利智慧搜尋", "storage_template_date_time_description": "檔案的創建時戳會用於判斷時間資訊", "storage_template_date_time_sample": "時間樣式 {date}", "storage_template_enable_description": "啟用存儲模板引擎", @@ -230,16 +231,16 @@ "storage_template_onboarding_description": "啟用此功能後,將根據用戶自定義的模板自動組織文件。由於穩定性問題,此功能已默認關閉。欲了解更多信息,請參閱 文檔。", "storage_template_path_length": "大致路徑長度限制:{length, number}/{limit, number}", "storage_template_settings": "存儲模板", - "storage_template_settings_description": "管理上傳檔案的文件夾結構和文件名", - "storage_template_user_label": "{label} 是用戶的存儲標籤", + "storage_template_settings_description": "管理上傳檔案的資料夾結構和檔名", + "storage_template_user_label": "{label} 是使用者的儲存標籤", "system_settings": "系統設定", "theme_custom_css_settings": "自訂 CSS", - "theme_custom_css_settings_description": "層疊樣式表(CSS)允許自定義 Immich 的設計。", - "theme_settings": "主題設定", - "theme_settings_description": "管理 Immich 網頁界面的自定義設置", + "theme_custom_css_settings_description": "可以用層疊樣式表(CSS)來自訂 Immich 的設計。", + "theme_settings": "主題", + "theme_settings_description": "自訂 Immich 的網頁界面", "these_files_matched_by_checksum": "這些檔案的核對和(Checksum)是相符的", - "thumbnail_generation_job": "生成縮圖", - "thumbnail_generation_job_description": "為每個資產生成大、小和模糊的縮圖,並為每個人生成縮圖", + "thumbnail_generation_job": "產生縮圖", + "thumbnail_generation_job_description": "爲每個檔案產生大、小及模糊縮圖,也爲每位人物產生縮圖", "transcode_policy_description": "", "transcoding_acceleration_api": "加速 API", "transcoding_acceleration_api_description": "該 API 將用您的設備加速轉碼。設置是“盡力而為”:如果失敗,它將退回到軟件轉碼。VP9 轉碼是否可行取決於您的硬件。", @@ -262,7 +263,7 @@ "transcoding_constant_quality_mode_description": "ICQ 比 CQP 更好,但某些硬件加速設備不支持此模式。設置此選項時,會在使用基於質量的編碼時偏好指定的模式。由於 NVENC 不支持 ICQ,此選項對其無效。", "transcoding_constant_rate_factor": "恆定速率因子(-crf)", "transcoding_constant_rate_factor_description": "視頻質量級別。典型值為 H.264 的 23、HEVC 的 28、VP9 的 31 和 AV1 的 35。數值越低,質量越高,但會產生較大的文件。", - "transcoding_disabled_description": "不要轉碼任何視頻,可能會導致某些客戶端無法播放", + "transcoding_disabled_description": "不轉碼影片,可能會讓某些客戶端無法正常播放", "transcoding_hardware_acceleration": "硬體加速", "transcoding_hardware_acceleration_description": "實驗性功能;速度更快,但在相同比特率下質量較低", "transcoding_hardware_decoding": "硬體解碼", @@ -274,7 +275,7 @@ "transcoding_max_bitrate_description": "設置最大比特率可以使文件大小更具可預測性,但會稍微降低質量。在 720p 分辨率下,典型值為 VP9 或 HEVC 的 2600k,或 H.264 的 4500k。設置為 0 則禁用此功能。", "transcoding_max_keyframe_interval": "最大關鍵幀間隔", "transcoding_max_keyframe_interval_description": "設置關鍵幀之間的最大幀距。較低的值會降低壓縮效率,但可以改善尋找時間,並可能改善快速運動場景中的質量。0 會自動設置此值。", - "transcoding_optimal_description": "分辨率高於目標或格式不被接受的視頻", + "transcoding_optimal_description": "高於目標解析度或格式不被支援的影片", "transcoding_preferred_hardware_device": "首選硬件設備", "transcoding_preferred_hardware_device_description": "僅適用於 VAAPI 和 QSV。設置用於硬件轉碼的 DRI 節點。", "transcoding_preset_preset": "預設值(-preset)", @@ -282,10 +283,10 @@ "transcoding_reference_frames": "參考幀數", "transcoding_reference_frames_description": "壓縮給定幀時參考的幀數。較高的值可以提高壓縮效率,但會降低編碼速度。0 會自動設置此值。", "transcoding_required_description": "僅限於格式不被接受的視頻", - "transcoding_settings": "影片轉碼設定", + "transcoding_settings": "影片轉碼", "transcoding_settings_description": "管理影片的解析度和編碼資訊", "transcoding_target_resolution": "目標解析度", - "transcoding_target_resolution_description": "較高的解析度可以保留更多細節,但編碼所需時間更長,文件大小也會增加,並可能降低應用程序的響應速度。", + "transcoding_target_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", "transcoding_temporal_aq": "時間自適應量化(Temporal AQ)", "transcoding_temporal_aq_description": "僅適用於 NVENC。提高高細節、低運動場景的質量。可能與舊設備不兼容。", "transcoding_threads": "線程數量", @@ -300,11 +301,11 @@ "transcoding_two_pass_encoding_setting_description": "使用雙通道編碼以產生更高質量的編碼視頻。當啟用最大比特率時(對 H.264 和 HEVC 有效),此模式使用基於最大比特率的比特率範圍,並忽略 CRF。對於 VP9,如果禁用最大比特率,可以使用 CRF。", "transcoding_video_codec": "視頻編解碼器", "transcoding_video_codec_description": "VP9 具有高效能和網頁兼容性,但轉碼時間較長。HEVC 性能相似,但網頁兼容性較低。H.264 兼容性廣泛且轉碼速度快,但生成的文件較大。AV1 是最有效的編解碼器,但在舊設備上支持度不足。", - "trash_enabled_description": "啟用垃圾箱功能", + "trash_enabled_description": "啟用垃圾桶功能", "trash_number_of_days": "日數", - "trash_number_of_days_description": "永久刪除之前,檔案於垃圾箱中保留的日數", - "trash_settings": "垃圾箱設置", - "trash_settings_description": "管理垃圾箱設置", + "trash_number_of_days_description": "永久刪除之前,將檔案保留在垃圾桶中的日數", + "trash_settings": "垃圾桶", + "trash_settings_description": "管理垃圾桶設定", "untracked_files": "未被追蹤的檔案", "untracked_files_description": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", "user_delete_delay": "{user} 的帳戶和資產將安排在 {delay, plural, one {# 天} other {# 天}} 後進行永久刪除。", @@ -315,28 +316,30 @@ "user_management": "使用者管理", "user_password_has_been_reset": "使用者密碼已重設:", "user_password_reset_description": "請提供使用者臨時密碼,並告知下次登入時需要更改密碼。", - "user_restore_description": "{user} 的帳戶將被恢復。", - "user_restore_scheduled_removal": "恢復用戶 - 預定於 {date, date, long} 移除", - "user_settings": "使用者設定", + "user_restore_description": "{user} 的帳號將被還原。", + "user_restore_scheduled_removal": "還原使用者 - 預定於 {date, date, long} 移除", + "user_settings": "使用者", "user_settings_description": "管理使用者設定", "user_successfully_removed": "已成功移除 {email}(使用者)。", - "version_check_enabled_description": "啟用定期向 GitHub 發送請求以檢查新版本", + "version_check_enabled_description": "啟用版本檢查", + "version_check_implications": "版本檢查功能會定期與 github.com 通訊", "version_check_settings": "版本檢查", - "version_check_settings_description": "啟用/禁用新版本通知", - "video_conversion_job": "轉碼視頻", - "video_conversion_job_description": "轉碼視頻以提高瀏覽器和設備的兼容性" + "version_check_settings_description": "啟用 / 停用新版本通知", + "video_conversion_job": "轉碼影片", + "video_conversion_job_description": "對影片轉碼,相容更多瀏覽器和裝置" }, - "admin_email": "管理員電子郵件", + "admin_email": "管理者電子郵件", "admin_password": "管理者密碼", "administration": "管理", "advanced": "進階", - "age_months": "年齡 {months, plural, one {# 個月} other {# 個月}}", - "age_year_months": "年齡 1 年,{months, plural, one {# 個月} other {# 個月}}", - "age_years": "{years, plural, other {年齡 #}}", - "album_added": "已新增相簿", + "age_months": "{months, plural, other {# 個月大}}", + "age_year_months": "1 歲,{months, plural, other {# 個月}}", + "age_years": "{years, plural, other {# 歲}}", + "album_added": "加入相簿時", "album_added_notification_setting_description": "當我被加入共享相簿時,用電子郵件通知我", "album_cover_updated": "已更新相簿封面", - "album_delete_confirmation": "確定要刪除「{album}」(相簿)嗎?\n如果已分享此相簿,其他使用者就無法再存取。", + "album_delete_confirmation": "確定要刪除「{album}」(相簿)嗎?", + "album_delete_confirmation_description": "如果已分享此相簿,其他使用者就無法再存取這本相簿了。", "album_info_updated": "已更新相簿資訊", "album_leave": "離開相簿?", "album_leave_confirmation": "您確定要離開 {album} 嗎?", @@ -345,7 +348,7 @@ "album_remove_user": "移除使用者?", "album_remove_user_confirmation": "確定要移除 {user} 嗎?", "album_share_no_users": "看來您與所有使用者共享了這本相簿,或沒有其他使用者可供分享。", - "album_updated": "已更新相簿", + "album_updated": "更新相簿時", "album_updated_setting_description": "當共享相簿有新檔案時,用電子郵件通知我", "album_user_left": "已離開 {album}", "album_user_removed": "已移除 {user}", @@ -356,15 +359,16 @@ "all_albums": "所有相簿", "all_people": "所有人", "all_videos": "所有視頻", - "allow_dark_mode": "允許黑暗模式", + "allow_dark_mode": "允許深色模式", "allow_edits": "允許編輯", "allow_public_user_to_download": "開放給使用者下載", "allow_public_user_to_upload": "開放讓使用者上傳", + "anti_clockwise": "逆時針", "api_key": "API 金鑰", "api_key_description": "此值僅顯示一次。請確保在關閉窗口之前複製它。", "api_key_empty": "您的 API 金鑰名稱不能爲空", "api_keys": "API 金鑰", - "app_settings": "應用設置", + "app_settings": "應用程式設定", "appears_in": "出現在", "archive": "封存", "archive_or_unarchive_photo": "封存或取消封存照片", @@ -382,48 +386,48 @@ "asset_hashing": "Hashing中...", "asset_offline": "檔案離線", "asset_offline_description": "此檔案己離線。Immich 無法訪問其文件位置。請確保資產可用,然後重新掃描資料庫。", - "asset_skipped": "跳過", + "asset_skipped": "已略過", "asset_uploaded": "已上傳", - "asset_uploading": "上傳中...", + "asset_uploading": "上傳中…", "assets": "檔案", "assets_added_count": "已添加 {count, plural, one {# 個資產} other {# 個資產}}", "assets_added_to_album_count": "已將 {count, plural, other {# 個檔案}}加入相簿", "assets_added_to_name_count": "已將 {count, plural, other {# 個檔案}}加入{hasName, select, true {{name}} other {新相簿}}", "assets_count": "{count, plural, one {# 個檔案} other {# 個檔案}}", - "assets_moved_to_trash_count": "已將 {count, plural, one {# 個檔案} other {# 個檔案}} 移到垃圾箱", + "assets_moved_to_trash_count": "已將 {count, plural, other {# 個檔案}}丟進垃圾桶", "assets_permanently_deleted_count": "已永久刪除 {count, plural, one {# 個檔案} other {# 個檔案}}", "assets_removed_count": "已移除 {count, plural, one {# 個檔案} other {# 個檔案}}", - "assets_restore_confirmation": "您確定要恢復所有垃圾箱中的檔案嗎?此操作無法撤銷!", - "assets_restored_count": "已恢復 {count, plural, one {# 個檔案} other {# 個檔案}}", - "assets_trashed_count": "{count, plural, one {# 個檔案} other {# 個檔案}} 已放入垃圾箱", + "assets_restore_confirmation": "確定要還原所有丟掉的檔案嗎?此步驟無法取消喔!", + "assets_restored_count": "已還原 {count, plural, other {# 個檔案}}", + "assets_trashed_count": "已丟掉 {count, plural, other {# 個檔案}}", "assets_were_part_of_album_count": "{count, plural, one {檔案已} other {檔案已}} 是相冊的一部分", "authorized_devices": "授權裝置", "back": "后退", - "back_close_deselect": "返回、關閉或取消選擇", + "back_close_deselect": "返回、關閉及取消選取", "backward": "倒轉", - "birthdate_saved": "已成功保存出生日期", - "birthdate_set_description": "出生日期會用於計算此人在照片拍攝時的年齡。", + "birthdate_saved": "出生日期儲存成功", + "birthdate_set_description": "出生日期會用來計算此人拍照時的歲數。", "blurred_background": "模糊背景", "build": "建置編號", "build_image": "建置映像", "bulk_delete_duplicates_confirmation": "您確定要批量刪除 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 嗎?這將保留每組中的最大檔案,並永久刪除所有其他重複項。此操作無法撤銷!", "bulk_keep_duplicates_confirmation": "您確定要保留 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 嗎?這將解決所有重複組而不刪除任何內容。", - "bulk_trash_duplicates_confirmation": "您確定要批量將 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 移到垃圾箱嗎?這將保留每組中最大的檔案,並將所有其他重複項放入垃圾箱。", - "buy": "購買 Immich", + "bulk_trash_duplicates_confirmation": "確定要一次丟掉 {count, plural, other {# 個重複的檔案}}嗎?這樣每組重複的檔案中,最大的會留下來,其它的會被丟進垃圾桶。", + "buy": "購置 Immich", "camera": "相機", "camera_brand": "相機品牌", "camera_model": "相機型號", "cancel": "取消", "cancel_search": "取消搜尋", "cannot_merge_people": "無法合併人物", - "cannot_undo_this_action": "您無法撤銷此操作!", + "cannot_undo_this_action": "此步驟無法取消喔!", "cannot_update_the_description": "無法更新描述", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", "change_date": "更改日期", - "change_expiration_time": "更改有效期限", + "change_expiration_time": "更改失效期限", "change_location": "更改位置", "change_name": "改名", "change_name_successfully": "改名成功", @@ -440,17 +444,19 @@ "clear_all_recent_searches": "清除所有最近的搜尋", "clear_message": "清除訊息", "clear_value": "清除值", + "clockwise": "順時針", "close": "關閉", "collapse": "折疊", "collapse_all": "全部折疊", + "color": "顏色", "color_theme": "色彩主題", "comment_deleted": "評論已刪除", "comment_options": "評論選項", "comments_and_likes": "評論與讚好", "comments_are_disabled": "評論已禁用", - "confirm": "确定", + "confirm": "確認", "confirm_admin_password": "確認管理者密碼", - "confirm_delete_shared_link": "您確定要刪除這個共享鏈接嗎?", + "confirm_delete_shared_link": "確定要刪除這條分享鏈結嗎?", "confirm_password": "確認密碼", "contain": "包含", "context": "情境", @@ -458,7 +464,7 @@ "copied_image_to_clipboard": "圖片已複製到剪貼簿。", "copied_to_clipboard": "已複製到剪貼簿!", "copy_error": "複製錯誤", - "copy_file_path": "複製文件路徑", + "copy_file_path": "複製檔案路徑", "copy_image": "複製圖片", "copy_link": "複製鏈結", "copy_link_to_clipboard": "將鏈結複製到剪貼簿", @@ -467,30 +473,32 @@ "country": "國家", "cover": "封面", "covers": "封面", - "create": "创建", + "create": "建立", "create_album": "建立相簿", - "create_library": "創建圖庫", + "create_library": "建立圖庫", "create_link": "建立鏈結", "create_link_to_share": "建立分享鏈結", "create_link_to_share_description": "允許任何擁有鏈接的人查看所選的照片", "create_new_person": "創建新人物", "create_new_person_hint": "將選定的檔案分配給新人物", "create_new_user": "建立新使用者", + "create_tag": "建立標記", + "create_tag_description": "建立新的標記。若要建立巢狀標記,請輸入完整的標記路徑(包括正斜線 / )。", "create_user": "建立使用者", "created": "建立於", "current_device": "此裝置", - "custom_locale": "自定義區域設定", - "custom_locale_description": "根據語言和地區格式化日期和數字", + "custom_locale": "自訂區域", + "custom_locale_description": "依語言和區域設定日期和數字格式", "dark": "深色", "date_after": "日期之後", - "date_and_time": "日期和时间", + "date_and_time": "日期與時間", "date_before": "日期之前", - "date_of_birth_saved": "出生日期已成功保存", + "date_of_birth_saved": "出生日期儲存成功", "date_range": "日期範圍", "day": "日", "deduplicate_all": "刪除所有重複項目", - "default_locale": "默認區域設定", - "default_locale_description": "根據您的瀏覽器區域設定格式化日期和數字", + "default_locale": "預設區域", + "default_locale_description": "依瀏覽器區域設定日期和數字格式", "delete": "删除", "delete_album": "刪除相簿", "delete_api_key_prompt": "您確定要刪除這個 API Key嗎?", @@ -499,6 +507,8 @@ "delete_library": "刪除圖庫", "delete_link": "刪除鏈結", "delete_shared_link": "刪除分享鏈結", + "delete_tag": "刪除標記", + "delete_tag_confirmation_prompt": "確定要刪除「{tagName}」(標記)嗎?", "delete_user": "刪除使用者", "deleted_shared_link": "已刪除分享鏈結", "description": "描述", @@ -512,10 +522,12 @@ "display_options": "顯示選項", "display_order": "顯示順序", "display_original_photos": "顯示原始照片", - "display_original_photos_setting_description": "當網頁兼容原始照片時,偏好查看照片時顯示原始檔案而非縮略圖。這可能會導致照片顯示速度變慢。", + "display_original_photos_setting_description": "在網頁與原始檔案相容的情況下,查看檔案時優先顯示原始檔案而非縮圖。這可能會讓照片顯示速度變慢。", "do_not_show_again": "不再顯示此訊息", "done": "完成", "download": "下載", + "download_include_embedded_motion_videos": "嵌入影片", + "download_include_embedded_motion_videos_description": "把嵌入動態照片的影片作爲單獨的檔案包含在內", "download_settings": "下載", "download_settings_description": "管理與檔案下載相關的設定", "downloading": "下載中", @@ -545,15 +557,20 @@ "edit_location": "编辑位置信息", "edit_name": "編輯名稱", "edit_people": "編輯人物", + "edit_tag": "編輯標記", "edit_title": "編輯標題", "edit_user": "編輯使用者", "edited": "己編輯", - "editor": "", + "editor": "編輯器", + "editor_close_without_save_prompt": "編輯過的內容不會儲存起來", + "editor_close_without_save_title": "要關閉編輯器嗎?", + "editor_crop_tool_h2_aspect_ratios": "長寬比", + "editor_crop_tool_h2_rotation": "旋轉", "email": "電子郵件", "empty": "", "empty_album": "", - "empty_trash": "清空回收站", - "empty_trash_confirmation": "您確定要清空垃圾桶嗎?這將永久刪除 Immich 中所有垃圾桶中的檔案。\n您不能撤銷這個操作!", + "empty_trash": "清空垃圾桶", + "empty_trash_confirmation": "確定要清空垃圾桶嗎?這會永久刪除 Immich 垃圾桶中所有的檔案。\n此步驟無法取消喔!", "enable": "啟用", "enabled": "己啟用", "end_date": "結束日期", @@ -576,7 +593,7 @@ "error_adding_users_to_album": "將使用者加入相簿時出錯", "error_deleting_shared_user": "刪除共享使用者時出錯", "error_downloading": "下載 {filename} 時出錯", - "error_hiding_buy_button": "隱藏購買按鈕時出錯", + "error_hiding_buy_button": "隱藏購置按鈕時出錯", "error_removing_assets_from_album": "從相簿中移除檔案時出錯了,請到控制臺瞭解詳情", "error_selecting_all_assets": "選擇所有檔案時出錯", "exclusion_pattern_already_exists": "此排除模式已存在。", @@ -590,7 +607,7 @@ "failed_to_load_people": "無法載入人物", "failed_to_remove_product_key": "無法移除產品密鑰", "failed_to_stack_assets": "無法堆疊檔案", - "failed_to_unstack_assets": "無法解除堆疊資產", + "failed_to_unstack_assets": "無法解除堆疊檔案", "import_path_already_exists": "此匯入路徑已存在。", "incorrect_email_or_password": "電子郵件或密碼有誤", "paths_validation_failed": "{paths, plural, one {# 個路徑} other {# 個路徑}} 驗證失敗", @@ -604,7 +621,7 @@ "unable_to_add_import_path": "無法添加匯入路徑", "unable_to_add_partners": "無法添加夥伴", "unable_to_add_remove_archive": "無法{archived, select, true {從封存中移除檔案} other {將檔案加入封存}}", - "unable_to_add_remove_favorites": "無法 {favorite, select, true {將檔案添加至} other {從中移除檔案}} 收藏夾", + "unable_to_add_remove_favorites": "無法將檔案{favorite, select, true {加入收藏} other {從收藏中移除}}", "unable_to_archive_unarchive": "無法{archived, select, true {封存} other {取消封存}}", "unable_to_change_album_user_role": "無法更改相簿使用者的角色", "unable_to_change_date": "無法更改日期", @@ -618,7 +635,7 @@ "unable_to_connect": "無法連接", "unable_to_connect_to_server": "無法連接到伺服器", "unable_to_copy_to_clipboard": "無法複製到剪貼板,請確保您以 https 存取該頁面", - "unable_to_create_admin_account": "無法建立管理員帳戶", + "unable_to_create_admin_account": "無法建立管理者帳號", "unable_to_create_api_key": "無法建立新的 API 金鑰", "unable_to_create_library": "無法建立資料庫", "unable_to_create_user": "無法建立使用者", @@ -662,9 +679,9 @@ "unable_to_repair_items": "無法糾正項目", "unable_to_reset_password": "無法重設密碼", "unable_to_resolve_duplicate": "無法解決重複項", - "unable_to_restore_assets": "無法恢復檔案", - "unable_to_restore_trash": "無法恢復垃圾桶內容", - "unable_to_restore_user": "無法恢復使用者", + "unable_to_restore_assets": "無法還原檔案", + "unable_to_restore_trash": "無法還原垃圾桶中的項目", + "unable_to_restore_user": "無法還原使用者", "unable_to_save_album": "無法儲存相簿", "unable_to_save_api_key": "無法儲存 API 金鑰", "unable_to_save_date_of_birth": "無法儲存出生日期", @@ -676,7 +693,7 @@ "unable_to_set_feature_photo": "無法設置特色照片", "unable_to_set_profile_picture": "無法設置個人頭像", "unable_to_submit_job": "無法提交作業", - "unable_to_trash_asset": "無法將檔案移至垃圾桶", + "unable_to_trash_asset": "無法將檔案丟進垃圾桶", "unable_to_unlink_account": "無法對帳號取消連接", "unable_to_update_album_cover": "無法更新相簿封面", "unable_to_update_album_info": "無法更新相簿資訊", @@ -694,10 +711,11 @@ "exif": "Exif", "exit_slideshow": "退出幻燈片", "expand_all": "展開全部", - "expire_after": "有效時間", + "expire_after": "失效時間", "expired": "已過期", - "expires_date": "有效期限:{date}", + "expires_date": "失效期限:{date}", "explore": "探索", + "explorer": "探測器", "export": "匯出", "export_as_json": "匯出 JSON", "extension": "副檔名", @@ -711,6 +729,8 @@ "feature": "", "feature_photo_updated": "特色照片已更新", "featurecollection": "", + "features": "功能", + "features_setting_description": "管理應用程式功能", "file_name": "檔名", "file_name_or_extension": "檔名或副檔名", "filename": "檔案名稱", @@ -718,6 +738,8 @@ "filter_people": "篩選人物", "find_them_fast": "搜尋名稱,快速找人", "fix_incorrect_match": "修復不相符的", + "folders": "資料夾", + "folders_feature_description": "以資料夾瀏覽檔案系統中的照片和影片", "force_re-scan_library_files": "強制重新掃描所有資料庫檔案", "forward": "順序", "general": "一般", @@ -746,7 +768,7 @@ "image_alt_text_date_2_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1} 和 {person2} 一同於 {date} 拍攝", "image_alt_text_date_3_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1}、{person2} 和 {person3} 一同於 {date} 拍攝", "image_alt_text_date_4_or_more_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1}、{person2} 和其他 {additionalCount, number} 人於 {date} 拍攝", - "image_alt_text_date_place": "{isVideo, select, true {影片} other {圖片}} 於 {city}、{country},{date} 拍攝", + "image_alt_text_date_place": "{date}在 {country} - {city} 拍攝的{isVideo, select, true {影片} other {圖片}}", "image_alt_text_date_place_1_person": "{isVideo, select, true {影片} other {圖片}} 於 {city}、{country},與 {person1} 一同在 {date} 拍攝", "image_alt_text_date_place_2_people": "{isVideo, select, true {影片} other {圖片}} 在 {city}、{country},與 {person1} 和 {person2} 一同於 {date} 拍攝", "image_alt_text_date_place_3_people": "{isVideo, select, true {影片} other {圖片}} 在 {city}、{country},與 {person1}、{person2} 和 {person3} 一同於 {date} 拍攝", @@ -765,7 +787,7 @@ "info": "資訊", "interval": { "day_at_onepm": "每天下午 1 點", - "hours": "每 {hours, plural, one {小時} other {{hours, number} 小時}}", + "hours": "每 {hours, plural, other {{hours, number} 小時}}", "night_at_midnight": "每晚午夜", "night_at_twoam": "每晚凌晨 2 點" }, @@ -802,7 +824,7 @@ "login": "登入", "login_has_been_disabled": "已停用登入功能。", "logout_all_device_confirmation": "您確定要登出所有裝置嗎?", - "logout_this_device_confirmation": "您確定要登出這個裝置嗎?", + "logout_this_device_confirmation": "要登出這臺裝置嗎?", "longitude": "經度", "look": "樣貌", "loop_videos": "重播影片", @@ -811,7 +833,7 @@ "manage_shared_links": "管理分享鏈結", "manage_sharing_with_partners": "管理與夥伴的分享", "manage_the_app_settings": "管理應用程式設定", - "manage_your_account": "管理您的帳戶", + "manage_your_account": "管理您的帳號", "manage_your_api_keys": "管理您的 API 金鑰", "manage_your_devices": "管理已登入的裝置", "manage_your_oauth_connection": "管理您的 OAuth 連接", @@ -822,7 +844,7 @@ "matches": "相符", "media_type": "媒體類型", "memories": "回憶", - "memories_setting_description": "管理您在回憶中顯示的內容", + "memories_setting_description": "管理您的回憶中顯示的內容", "memory": "回憶", "memory_lane_title": "回憶長廊{title}", "menu": "選單", @@ -838,11 +860,11 @@ "model": "型號", "month": "月", "more": "更多", - "moved_to_trash": "已移至垃圾桶", + "moved_to_trash": "已丟進垃圾桶", "my_albums": "我的相簿", "name": "名稱", "name_or_nickname": "名稱或暱稱", - "never": "永遠", + "never": "永不失效", "new_album": "新相簿", "new_api_key": "新的 API 金鑰", "new_password": "新密碼", @@ -861,7 +883,7 @@ "no_duplicates_found": "沒發現重複項目。", "no_exif_info_available": "沒有可用的 Exif 資訊", "no_explore_results_message": "上傳更多照片以利探索。", - "no_favorites_message": "將最喜愛的項目添加至收藏夾,以便快速找到您的最佳照片和影片", + "no_favorites_message": "加入收藏,加速尋找影像", "no_libraries_message": "建立外部圖庫來查看您的照片和影片", "no_name": "無名", "no_places": "沒有地點", @@ -869,7 +891,7 @@ "no_results_description": "試試同義詞或更通用的關鍵字吧", "no_shared_albums_message": "建立相簿分享照片和影片", "not_in_any_album": "不在任何相簿中", - "note_apply_storage_label_to_previously_uploaded assets": "注意:要將存儲標籤應用於先前上傳的檔案,請運行", + "note_apply_storage_label_to_previously_uploaded assets": "註:要將儲存標籤用於先前上傳的檔案,請執行", "note_unlimited_quota": "註:輸入 0 表示不限制配額", "notes": "提示", "notification_toggle_setting_description": "啟用電子郵件通知", @@ -882,15 +904,16 @@ "ok": "確定", "oldest_first": "由舊至新", "onboarding": "入門指南", - "onboarding_theme_description": "選擇顏色主題。您可以稍後在設定中更改此選項。", - "onboarding_welcome_description": "讓我們為您的伺服器架構一些常見的設置。", + "onboarding_privacy_description": "以下(可選)功能依賴外部服務,可隨時在管理設定中停用。", + "onboarding_theme_description": "幫實例選色彩主題。之後也可以在設定中更改。", + "onboarding_welcome_description": "在啟用實例前先做一些基本的設定。", "onboarding_welcome_user": "歡迎,{user}", "online": "在線", "only_favorites": "僅顯示己收藏", "only_refreshes_modified_files": "只重新整理修改過的檔案", "open_in_map_view": "開啟地圖檢視", "open_in_openstreetmap": "用 OpenStreetMap 開啟", - "open_the_search_filters": "打開搜尋過濾器", + "open_the_search_filters": "開啟搜尋篩選器", "options": "選項", "or": "或", "organize_your_library": "整理您的圖庫", @@ -923,6 +946,7 @@ "pending": "待處理", "people": "人物", "people_edits_count": "編輯了 {count, plural, one {# 位人士} other {# 位人士}}", + "people_feature_description": "以人物分組瀏覽照片和影片", "people_sidebar_description": "在側邊欄顯示「人物」的連結", "permanent_deletion_warning": "永久刪除警告", "permanent_deletion_warning_setting_description": "在永久刪除檔案時顯示警告", @@ -943,7 +967,7 @@ "places": "地點", "play": "播放", "play_memories": "播放回憶", - "play_motion_photo": "播放動態相片", + "play_motion_photo": "播放動態照片", "play_or_pause_video": "播放或暫停影片", "point": "", "port": "埠口", @@ -951,47 +975,50 @@ "preview": "預覽", "previous": "上一張", "previous_memory": "上一張回憶", - "previous_or_next_photo": "上一張或下一張照片", + "previous_or_next_photo": "上、下一張照片", "primary": "首要", + "privacy": "隱私", "profile_image_of_user": "{user} 的個人資料圖片", "profile_picture_set": "已設定個人資料圖片。", "public_album": "公開相簿", "public_share": "公開分享", "purchase_account_info": "擁護者", - "purchase_activated_subtitle": "感謝您對 Immich 及開源軟體的支持", + "purchase_activated_subtitle": "感謝您對 Immich 及開源軟體的支援", "purchase_activated_time": "於 {date, date} 啟用", "purchase_activated_title": "金鑰成功啟用了", "purchase_button_activate": "啟用", - "purchase_button_buy": "購買", - "purchase_button_buy_immich": "購買 Immich", + "purchase_button_buy": "購置", + "purchase_button_buy_immich": "購置 Immich", "purchase_button_never_show_again": "不再顯示", - "purchase_button_reminder": "30天後提醒我", + "purchase_button_reminder": "過 30 天再提醒我", "purchase_button_remove_key": "移除金鑰", - "purchase_button_select": "選擇", + "purchase_button_select": "選這個", "purchase_failed_activation": "啟用失敗!請檢查您的電子郵件以取得正確的產品金鑰!", "purchase_individual_description_1": "針對個人", - "purchase_individual_description_2": "支持者狀態", + "purchase_individual_description_2": "擁護者狀態", "purchase_individual_title": "個人", "purchase_input_suggestion": "有產品金鑰嗎?請在下面輸入金鑰", - "purchase_license_subtitle": "購買 Immich 以支持軟件發展", - "purchase_lifetime_description": "終身購買", - "purchase_option_title": "購買選項", + "purchase_license_subtitle": "購置 Immich 來支援軟體開發", + "purchase_lifetime_description": "終身購置", + "purchase_option_title": "購置選項", "purchase_panel_info_1": "開發 Immich 可不是件容易的事,花了我們不少功夫。好在有一群全職工程師在背後默默努力,爲的就是把它做到最好。我們的目標很簡單:讓開源軟體和正當的商業模式能成爲開發者的長期飯碗,同時打造出重視隱私的生態系統,讓大家有個不被剝削的雲端服務新選擇。", - "purchase_panel_info_2": "由於我們於不設付費牆,這筆購買不會為你提供 Immich 任何額外功能。我們依賴像你這樣的用戶來支持 Immich 持續開發。", - "purchase_panel_title": "支持這個項目", - "purchase_per_server": "每台伺服器", + "purchase_panel_info_2": "我們承諾不設付費牆,所以購置 Immich 並不會讓您獲得額外的功能。我們是依賴使用者們的支援來開發 Immich 的。", + "purchase_panel_title": "支援這項專案", + "purchase_per_server": "每臺伺服器", "purchase_per_user": "每位使用者", - "purchase_remove_product_key": "移除產品密鑰", - "purchase_remove_product_key_prompt": "您確定要移除產品密鑰嗎?", - "purchase_remove_server_product_key": "移除伺服器產品密鑰", - "purchase_remove_server_product_key_prompt": "您確定要移除伺服器產品密鑰嗎?", - "purchase_server_description_1": "適用於整個伺服器", - "purchase_server_description_2": "支持者狀態", + "purchase_remove_product_key": "移除產品金鑰", + "purchase_remove_product_key_prompt": "確定要移除產品金鑰嗎?", + "purchase_remove_server_product_key": "移除伺服器產品金鑰", + "purchase_remove_server_product_key_prompt": "確定要移除伺服器產品金鑰嗎?", + "purchase_server_description_1": "給整臺伺服器", + "purchase_server_description_2": "擁護者狀態", "purchase_server_title": "伺服器", "purchase_settings_server_activated": "伺服器產品金鑰是由管理者管理的", "range": "", "rating": "評星", - "rating_description": "在資訊面板中顯示 Exif 評等", + "rating_clear": "清除評等", + "rating_count": "{count, plural, other {# 星}}", + "rating_description": "在資訊面板中顯示 EXIF 評等", "raw": "", "reaction_options": "反應選項", "read_changelog": "閱覽變更日誌", @@ -1023,7 +1050,8 @@ "removed_api_key": "已移除 API 金鑰:{name}", "removed_from_archive": "從封存中移除", "removed_from_favorites": "已從收藏中移除", - "removed_from_favorites_count": "已從收藏中移除 {count, plural, one {#} other {#}}", + "removed_from_favorites_count": "已移除收藏的 {count, plural, other {# 個項目}}", + "removed_tagged_assets": "已移除 {count, plural, other {# 個檔案}}的標記", "rename": "改名", "repair": "糾正", "repair_no_results_message": "未被追蹤及遺失的檔案會顯示在這裏", @@ -1033,22 +1061,22 @@ "require_user_to_change_password_on_first_login": "要求使用者在首次登入時更改密碼", "reset": "重設", "reset_password": "重設密碼", - "reset_people_visibility": "重置人物可見性", + "reset_people_visibility": "重設人物可見性", "reset_settings_to_default": "", - "reset_to_default": "設爲預設", + "reset_to_default": "重設回預設", "resolve_duplicates": "解決重複項", "resolved_all_duplicates": "已解決所有重複項目", - "restore": "恢复", - "restore_all": "恢復全部", - "restore_user": "恢復使用者", - "restored_asset": "已恢復檔案", + "restore": "還原", + "restore_all": "全部還原", + "restore_user": "還原使用者", + "restored_asset": "已還原檔案", "resume": "繼續", - "retry_upload": "重試上傳", + "retry_upload": "重新上傳", "review_duplicates": "查核重複項目", "role": "角色", "role_editor": "編輯者", "role_viewer": "檢視者", - "save": "保存", + "save": "儲存", "saved_api_key": "已儲存的 API 密鑰", "saved_profile": "已儲存個人資料", "saved_settings": "已儲存設定", @@ -1069,14 +1097,15 @@ "search_country": "搜尋國家…", "search_for_existing_person": "搜尋現有的人物", "search_no_people": "沒有人找到", - "search_no_people_named": "沒有名為 \"{name}\" 的人", + "search_no_people_named": "沒有名爲「{name}」的人物", "search_people": "搜尋人物", "search_places": "搜尋地點", "search_state": "搜尋地區…", - "search_timezone": "搜尋時區...", + "search_tags": "搜尋標記…", + "search_timezone": "搜尋時區…", "search_type": "搜尋類型", "search_your_photos": "搜尋照片", - "searching_locales": "搜尋地區...", + "searching_locales": "搜尋區域…", "second": "秒", "see_all_people": "查看所有人物", "select_album_cover": "選擇相簿封面", @@ -1089,7 +1118,7 @@ "select_keep_all": "全部保留", "select_library_owner": "選擇圖庫擁有者", "select_new_face": "選擇新臉孔", - "select_photos": "選相片", + "select_photos": "選照片", "select_trash_all": "全部刪除", "selected": "已選擇", "selected_count": "{count, plural, other {選了 # 項}}", @@ -1100,10 +1129,10 @@ "server_online": "伺服器在線", "server_stats": "伺服器統計", "server_version": "目前版本", - "set": "設置", + "set": "設定", "set_as_album_cover": "設爲相簿封面", "set_as_profile_picture": "設為個人資料圖片", - "set_date_of_birth": "設置出生日期", + "set_date_of_birth": "設定出生日期", "set_profile_picture": "設置個人資料圖片", "set_slideshow_to_fullscreen": "以全螢幕放映幻燈片", "settings": "設定", @@ -1114,6 +1143,7 @@ "shared_by_user": "由 {user} 分享", "shared_by_you": "由你分享", "shared_from_partner": "來自 {partner} 的照片", + "shared_link_options": "分享鏈結選項", "shared_links": "分享鏈結", "shared_photos_and_videos_count": "{assetCount, plural, other {已分享 # 張照片及影片。}}", "shared_with_partner": "與 {partner} 共享", @@ -1128,8 +1158,8 @@ "show_file_location": "顯示文件位置", "show_gallery": "顯示畫廊", "show_hidden_people": "顯示隱藏的人物", - "show_in_timeline": "在時間軸上顯示", - "show_in_timeline_setting_description": "在時間軸上顯示來自此用戶的照片和影片", + "show_in_timeline": "在時間軸中顯示", + "show_in_timeline_setting_description": "在您的時間軸中顯示這位使用者的照片和影片", "show_keyboard_shortcuts": "顯示鍵盤快捷鍵", "show_metadata": "顯示元資料", "show_or_hide_info": "顯示或隱藏資訊", @@ -1137,9 +1167,11 @@ "show_person_options": "顯示人物選項", "show_progress_bar": "顯示進度條", "show_search_options": "顯示搜尋選項", - "show_supporter_badge": "支持者徽章", - "show_supporter_badge_description": "顯示支持者徽章", + "show_supporter_badge": "擁護者徽章", + "show_supporter_badge_description": "顯示擁護者徽章", "shuffle": "隨機排序", + "sidebar": "側邊欄", + "sidebar_display_description": "在側邊欄中顯示鏈結", "sign_out": "登出", "sign_up": "註冊", "size": "用量", @@ -1157,29 +1189,36 @@ "stack": "堆叠", "stack_duplicates": "堆疊重複項目", "stack_select_one_photo": "爲堆疊選一張主要照片", - "stack_selected_photos": "堆疊選定的照片", + "stack_selected_photos": "堆疊所選的照片", "stacked_assets_count": "已堆疊 {count, plural, one {# 個檔案} other {# 個檔案}}", "stacktrace": "堆疊追蹤", "start": "開始", "start_date": "開始日期", "state": "地區", "status": "狀態", - "stop_motion_photo": "停格照片", + "stop_motion_photo": "停止動態照片", "stop_photo_sharing": "要停止分享您的照片嗎?", "stop_photo_sharing_description": "{partner} 將無法再訪問你的照片。", "stop_sharing_photos_with_user": "停止與此用戶共享你的照片", "storage": "儲存空間", - "storage_label": "儲存標記", + "storage_label": "儲存標籤", "storage_usage": "用了 {used} / 共 {available}", "submit": "提交", "suggestions": "建議", "sunrise_on_the_beach": "日出的海灘", "swap_merge_direction": "交換合併方向", "sync": "同步", + "tag": "標記", + "tag_assets": "標記檔案", + "tag_created": "已建立標記:{tag}", + "tag_feature_description": "以邏輯標記要旨分組瀏覽照片和影片", + "tag_updated": "已更新標記:{tag}", + "tagged_assets": "已標記 {count, plural, other {# 個檔案}}", + "tags": "標記", "template": "模板", "theme": "主題", "theme_selection": "主題選項", - "theme_selection_description": "根據你的瀏覽器系統偏好自動設置主題為淺色或深色", + "theme_selection_description": "依瀏覽器系統偏好自動設定深、淺色主題", "they_will_be_merged_together": "它們將會被合併在一起", "time_based_memories": "依時間回憶", "timezone": "時區", @@ -1187,15 +1226,16 @@ "to_change_password": "更改密碼", "to_favorite": "收藏", "to_login": "登入", + "to_root": "到根", "to_trash": "垃圾桶", "toggle_settings": "切換設定", - "toggle_theme": "切換主題", + "toggle_theme": "切換深色主題", "toggle_visibility": "", "total_usage": "總用量", "trash": "垃圾桶", - "trash_all": "全丟進垃圾桶", - "trash_count": "刪除 {count, number} 檔案", - "trash_delete_asset": "刪除檔案/放入垃圾桶", + "trash_all": "全部丟掉", + "trash_count": "丟掉 {count, number} 個檔案", + "trash_delete_asset": "將檔案丟進垃圾桶 / 刪除", "trash_no_results_message": "垃圾桶中的照片和影片將顯示在這裡。", "trashed_items_will_be_permanently_deleted_after": "垃圾桶中的項目會在 {days, plural, other {# 天}}後永久刪除。", "type": "類型", @@ -1211,12 +1251,13 @@ "unlink_oauth": "取消連接 OAuth", "unlinked_oauth_account": "已解除連接 OAuth 帳號", "unnamed_album": "未命名相簿", + "unnamed_album_delete_confirmation": "確定要刪除這本相簿嗎?", "unnamed_share": "未命名分享", - "unsaved_change": "未儲存的更改", + "unsaved_change": "更改未儲存", "unselect_all": "取消全選", - "unselect_all_duplicates": "取消選擇所有重複項", + "unselect_all_duplicates": "取消選取所有的重複項目", "unstack": "取消堆叠", - "unstacked_assets_count": "已取消堆疊 {count, plural, one {# 個檔案} other {# 個檔案}}", + "unstacked_assets_count": "已解除堆疊 {count, plural, other {# 個檔案}}", "untracked_files": "未被追蹤的檔案", "untracked_files_decription": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", "up_next": "下一個", @@ -1225,10 +1266,10 @@ "upload_concurrency": "上傳並行", "upload_errors": "上傳完成,但有 {count, plural, other {# 處出錯}},要查看新上傳的檔案請重新整理頁面。", "upload_progress": "剩餘 {remaining, number} - 已處理 {processed, number}/{total, number}", - "upload_skipped_duplicates": "跳過 {count, plural, one {# 個重複檔案} other {# 個重複檔案}}", + "upload_skipped_duplicates": "已略過 {count, plural, other {# 個重複的檔案}}", "upload_status_duplicates": "重複項目", "upload_status_errors": "錯誤", - "upload_status_uploaded": "己上載", + "upload_status_uploaded": "已上傳", "upload_success": "上傳成功,要查看新上傳的檔案請重新整理頁面。", "url": "網址", "usage": "用量", @@ -1236,7 +1277,7 @@ "user": "使用者", "user_id": "使用者 ID", "user_liked": "{user} 喜歡了 {type, select, photo {這張照片} video {這段影片} asset {這個檔案} other {它}}", - "user_purchase_settings": "購買", + "user_purchase_settings": "購置", "user_purchase_settings_description": "管理你的購買", "user_role_set": "設 {user} 爲{role}", "user_usage_detail": "使用者用量詳情", @@ -1249,7 +1290,7 @@ "version_announcement_closing": "敬祝順心,Alex", "version_announcement_message": "嗨~本應用程式可以更新了,爲防止配置出錯,請花點時間閱讀發行說明,並確保 docker-compose.yml.env 設置是最新的,特別是使用 WatchTower 等自動更新工具時。", "video": "影片", - "video_hover_setting": "在鼠標懸停時播放影片縮圖", + "video_hover_setting": "游標停留時播放影片縮圖", "video_hover_setting_description": "當滑鼠停在項目上時播放影片縮圖。即使停用,將滑鼠停在播放圖示上也可以播放。", "videos": "影片", "videos_count": "{count, plural, other {# 部影片}}", @@ -1257,12 +1298,13 @@ "view_album": "查看相簿", "view_all": "瀏覽全部", "view_all_users": "查看所有使用者", + "view_in_timeline": "在時間軸中查看", "view_links": "檢視鏈結", "view_next_asset": "查看下一項", "view_previous_asset": "查看上一項", "view_stack": "查看堆疊", "viewer": "", - "visibility_changed": "{count, plural, one {# 人} other {# 人}} 的可見性已更改", + "visibility_changed": "已更改 {count, plural, other {# 位人物}}的可見性", "waiting": "待處理", "warning": "警告", "week": "周", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index fd3fd5815cbbe..8e3bbe1a1b5bb 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -129,12 +129,13 @@ "map_enable_description": "启用地图功能", "map_gps_settings": "地图与GPS设置", "map_gps_settings_description": "管理地图与GPS(反向地理编码)设置", + "map_implications": "地图功能依赖于外部瓦片服务(tiles.immich.cloud)", "map_light_style": "浅色模式", "map_manage_reverse_geocoding_settings": "管理反向地理编码设置", "map_reverse_geocoding": "反向地理编码", "map_reverse_geocoding_enable_description": "启用反向地理编码", "map_reverse_geocoding_settings": "反向地理编码设置", - "map_settings": "地图设置", + "map_settings": "地图", "map_settings_description": "管理地图设置", "map_style_description": "地图主题 style.json 的 URL", "metadata_extraction_job": "提取元数据", @@ -167,13 +168,13 @@ "oauth_auto_register": "自动注册", "oauth_auto_register_description": "使用OAuth登录后自动注册新用户", "oauth_button_text": "按钮文本", - "oauth_client_id": "Client ID", + "oauth_client_id": "客户端ID", "oauth_client_secret": "客户端密匙", "oauth_enable_description": "使用OAuth登录", "oauth_issuer_url": "发行方的网址", "oauth_mobile_redirect_uri": "移动端重定向 URI", "oauth_mobile_redirect_uri_override": "移动端重定向 URI 覆盖", - "oauth_mobile_redirect_uri_override_description": "当 \"app.immich:/\"无效时,启用URL重定向。", + "oauth_mobile_redirect_uri_override_description": "当 OAuth 提供商不允许使用移动 URI 时启用,如“{callback}”", "oauth_profile_signing_algorithm": "配置文件签名算法", "oauth_profile_signing_algorithm_description": "用于签署用户配置文件的算法。", "oauth_scope": "范围", @@ -320,7 +321,8 @@ "user_settings": "用户设置", "user_settings_description": "管理用户设置", "user_successfully_removed": "用户{email}已被成功删除。", - "version_check_enabled_description": "启用对GitHub的定期请求以检查新版本", + "version_check_enabled_description": "启用版本检测", + "version_check_implications": "版本检查功能依赖于与 github.com 的定期通信", "version_check_settings": "版本检查", "version_check_settings_description": "启用或禁用新版本通知", "video_conversion_job": "视频转码", @@ -336,7 +338,8 @@ "album_added": "相册已添加", "album_added_notification_setting_description": "当您被添加到共享相册时,接收电子邮件通知", "album_cover_updated": "相册封面已更新", - "album_delete_confirmation": "是否确定要删除相册{album}?\n如果这是共享相册,其他用户将无法再访问它。", + "album_delete_confirmation": "是否确定要删除相册{album}?", + "album_delete_confirmation_description": "如果该相册是共享的,其他用户将无法再访问它。", "album_info_updated": "相册信息已更新", "album_leave": "退出相册?", "album_leave_confirmation": "确定要退出相册{album}?", @@ -358,8 +361,9 @@ "all_videos": "所有视频", "allow_dark_mode": "允许深色模式", "allow_edits": "允许编辑", - "allow_public_user_to_download": "开放下载给所有人", + "allow_public_user_to_download": "允许所有用户下载", "allow_public_user_to_upload": "允许所有用户上传", + "anti_clockwise": "逆时针", "api_key": "API Key", "api_key_description": "该应用密钥只会展示一次。请确保在关闭窗口前复制下来。", "api_key_empty": "API Key的名称不可以为空", @@ -371,7 +375,7 @@ "archive_size": "归档大小", "archive_size_description": "配置下载归档大小(以GiB为单位)", "archived": "已归档", - "archived_count": "{count, plural, other {已存档 # 项}}", + "archived_count": "{count, plural, other {已归档 # 项}}", "are_these_the_same_person": "是否是同一个人?", "are_you_sure_to_do_this": "确定要这样做吗?", "asset_added_to_album": "已添加至相册", @@ -393,7 +397,7 @@ "assets_moved_to_trash": "将{count, plural, one {# 个项目} other {# 个项目}}移动到回收站", "assets_moved_to_trash_count": "已移动{count, plural, one {#个项目} other {#个项目}}到回收站", "assets_permanently_deleted_count": "已永久删除{count, plural, one {#个项目} other {#个项目}}", - "assets_removed_count": "移除{count, plural, one {#个项目} other {#个项目}}", + "assets_removed_count": "已移除{count, plural, one {#个项目} other {#个项目}}", "assets_restore_confirmation": "确定要恢复回收站中的所有项目吗?该操作无法撤消!", "assets_restored_count": "已恢复{count, plural, one {#个项目} other {#个项目}}", "assets_trashed_count": "{count, plural, one {#个项目} other {#个项目}}已放入回收站", @@ -403,7 +407,7 @@ "back_close_deselect": "后退、关闭或反选", "backward": "后退", "birthdate_saved": "出生日期保存成功", - "birthdate_set_description": "出生日期用于计算照片中人物当时的年龄。", + "birthdate_set_description": "出生日期用于计算照片中人物在拍照时的年龄。", "blurred_background": "背景模糊", "build": "构建版本", "build_image": "镜像版本", @@ -441,9 +445,11 @@ "clear_all_recent_searches": "清除所有最近搜索", "clear_message": "清空消息", "clear_value": "清空值", + "clockwise": "顺时针", "close": "关闭", "collapse": "折叠", "collapse_all": "全部折叠", + "color": "颜色", "color_theme": "颜色主题", "comment_deleted": "评论已删除", "comment_options": "评论选项", @@ -477,6 +483,8 @@ "create_new_person": "创建新人物", "create_new_person_hint": "指派已选择项目到新的人物", "create_new_user": "创建新用户", + "create_tag": "新建标签", + "create_tag_description": "创建一个新标签。对于嵌套标签,请输入标签的完整路径,包括正斜杠(/)。", "create_user": "创建用户", "created": "已创建", "current_device": "当前设备", @@ -500,6 +508,8 @@ "delete_library": "删除图库", "delete_link": "删除链接", "delete_shared_link": "删除共享链接", + "delete_tag": "删除标签", + "delete_tag_confirmation_prompt": "您确定要删除{tagName}标签吗?", "delete_user": "删除用户", "deleted_shared_link": "共享链接已删除", "description": "描述", @@ -517,6 +527,8 @@ "do_not_show_again": "不再显示该信息", "done": "完成", "download": "下载", + "download_include_embedded_motion_videos": "内嵌视频", + "download_include_embedded_motion_videos_description": "将动态照片中的内嵌视频作为单独文件纳入", "download_settings": "下载", "download_settings_description": "管理项目下载相关设置", "downloading": "下载中", @@ -546,10 +558,15 @@ "edit_location": "编辑位置", "edit_name": "编辑名称", "edit_people": "编辑人物", + "edit_tag": "编辑标签", "edit_title": "编辑标题", "edit_user": "编辑用户", "edited": "已编辑", "editor": "编辑器", + "editor_close_without_save_prompt": "此更改不会被保存", + "editor_close_without_save_title": "关闭编辑器?", + "editor_crop_tool_h2_aspect_ratios": "长宽比", + "editor_crop_tool_h2_rotation": "旋转", "email": "邮箱", "empty": "空", "empty_album": "清空相册", @@ -684,7 +701,7 @@ "unable_to_update_library": "无法更新库", "unable_to_update_location": "无法更新位置", "unable_to_update_settings": "无法更新设置", - "unable_to_update_timeline_display_status": "无法更新时间线显示状态", + "unable_to_update_timeline_display_status": "无法更新时间轴显示状态", "unable_to_update_user": "无法更新用户", "unable_to_upload_file": "无法上传文件" }, @@ -694,11 +711,12 @@ "every_six_hours": "每6小时", "exif": "Exif", "exit_slideshow": "退出幻灯片放映", - "expand_all": "展开全部", + "expand_all": "全部展开", "expire_after": "有效期至", "expired": "已过期", "expires_date": "{date}过期", "explore": "探索", + "explorer": "浏览器", "export": "导出", "export_as_json": "导出为JSON", "extension": "扩展", @@ -712,6 +730,8 @@ "feature": "功能", "feature_photo_updated": "人物头像已更新", "featurecollection": "功能合集", + "features": "功能", + "features_setting_description": "管理App功能", "file_name": "文件名", "file_name_or_extension": "文件名或扩展名", "filename": "文件名", @@ -720,6 +740,8 @@ "filter_people": "过滤人物", "find_them_fast": "按名称快速搜索", "fix_incorrect_match": "修复不正确的匹配", + "folders": "文件夹", + "folders_feature_description": "在文件夹视图中浏览文件系统上的照片和视频", "force_re-scan_library_files": "强制重新扫描所有图库文件", "forward": "向前", "general": "通用", @@ -872,7 +894,7 @@ "my_albums": "我的相册", "name": "名称", "name_or_nickname": "名称或昵称", - "never": "从不", + "never": "永不过期", "new_album": "新相册", "new_api_key": "新API Key", "new_password": "新密码", @@ -912,6 +934,7 @@ "ok": "确定", "oldest_first": "最旧优先", "onboarding": "盛大开启", + "onboarding_privacy_description": "以下(可选)功能依赖外部服务,可随时在管理设置中禁用。", "onboarding_theme_description": "选择服务的颜色主题。稍后可以在设置中进行修改。", "onboarding_welcome_description": "我们在启用服务前先做一些通用设置。", "onboarding_welcome_user": "欢迎,{user}", @@ -953,6 +976,7 @@ "pending": "待处理", "people": "人物", "people_edits_count": "{count, plural, one {#个人物} other {#个人物}}已编辑", + "people_feature_description": "按人物分组进行浏览照片和视频", "people_sidebar_description": "在侧边栏中显示指向人物的链接", "perform_library_tasks": "", "permanent_deletion_warning": "永久删除警告", @@ -985,6 +1009,7 @@ "previous_memory": "上一个", "previous_or_next_photo": "上一张或下一张照片", "primary": "首要", + "privacy": "隐私", "profile_image_of_user": "{user}的个人资料图片", "profile_picture_set": "个人资料图片已设置。", "public_album": "公开相册", @@ -1023,6 +1048,8 @@ "purchase_settings_server_activated": "服务器产品密钥正在由管理员管理", "range": "范围", "rating": "星级", + "rating_clear": "删除星级", + "rating_count": "{count, plural, one {#星} other {#星}}", "rating_description": "在信息面板中展示EXIF星级", "raw": "Raw", "reaction_options": "反应选项", @@ -1056,6 +1083,7 @@ "removed_from_archive": "从归档中移除", "removed_from_favorites": "从收藏中移除", "removed_from_favorites_count": "从收藏中移除{count, plural, other {#项}}", + "removed_tagged_assets": "从 {count, plural, one {# 个项目} other {# 个项目}}中删除标签", "rename": "重命名", "repair": "修复", "repair_no_results_message": "未跟踪和缺失的文件将在此处显示", @@ -1105,6 +1133,7 @@ "search_people": "搜索人物", "search_places": "搜索地点", "search_state": "搜索省份...", + "search_tags": "搜索标签…", "search_timezone": "搜索时区...", "search_type": "搜索类型", "search_your_photos": "搜索你的照片", @@ -1146,6 +1175,7 @@ "shared_by_user": "由{user}共享", "shared_by_you": "你的共享", "shared_from_partner": "来自{partner}的照片", + "shared_link_options": "共享链接选项", "shared_links": "共享链接", "shared_photos_and_videos_count": "{assetCount, plural, other {#项已共享照片&视频。}}", "shared_with_partner": "与{partner}共享", @@ -1172,7 +1202,9 @@ "show_supporter_badge": "支持者徽章", "show_supporter_badge_description": "展示支持者徽章", "shuffle": "随机", - "sign_out": "登出", + "sidebar": "侧边栏", + "sidebar_display_description": "在侧边栏中显示视图链接", + "sign_out": "注销", "sign_up": "注册", "size": "大小", "skip_to_content": "跳到内容", @@ -1190,7 +1222,7 @@ "stack_duplicates": "堆叠重复项目", "stack_select_one_photo": "为堆叠选择一张展示图", "stack_selected_photos": "堆叠选定的照片", - "stacked_assets_count": "已归档{count, plural, one {#个项目} other {#个项目}}", + "stacked_assets_count": "已堆叠{count, plural, one {#个项目} other {#个项目}}", "stacktrace": "堆栈跟踪", "start": "开始", "start_date": "开始日期", @@ -1208,6 +1240,13 @@ "sunrise_on_the_beach": "海滩上的日出", "swap_merge_direction": "交换合并方向", "sync": "同步", + "tag": "标签", + "tag_assets": "标记项目", + "tag_created": "已创建标签:{tag}", + "tag_feature_description": "按逻辑标签主题分组进行浏览照片和视频", + "tag_updated": "已更新标签:{tag}", + "tagged_assets": "{count, plural, one {# 个项目} other {# 个项目}}被加上标签", + "tags": "标签", "template": "模版", "theme": "主题", "theme_selection": "主题选项", @@ -1219,9 +1258,10 @@ "to_change_password": "修改密码", "to_favorite": "收藏", "to_login": "登录", + "to_root": "到根目录", "to_trash": "放入回收站", "toggle_settings": "切换设置", - "toggle_theme": "切换主题", + "toggle_theme": "切换深色主题", "toggle_visibility": "切换可见性", "total_usage": "总用量", "trash": "回收站", @@ -1243,6 +1283,7 @@ "unlink_oauth": "解绑OAuth", "unlinked_oauth_account": "解绑OAuth账户", "unnamed_album": "未命名相册", + "unnamed_album_delete_confirmation": "您确定要删除该相册吗?", "unnamed_share": "未命名共享", "unsaved_change": "未保存的修改", "unselect_all": "取消全选", @@ -1291,6 +1332,7 @@ "view_album": "查看相册", "view_all": "查看全部", "view_all_users": "查看全部用户", + "view_in_timeline": "在时间轴中查看", "view_links": "查看链接", "view_next_asset": "查看下一项", "view_previous_asset": "查看上一项", diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index cabe2e85a1b6d..2e6e44511d36b 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -1,4 +1,5 @@ import { getKey } from '$lib/utils'; +import { type AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { readonly, writable } from 'svelte/store'; @@ -6,6 +7,7 @@ function createAssetViewingStore() { const viewingAssetStoreState = writable(); const preloadAssets = writable([]); const viewState = writable(false); + const gridScrollTarget = writable(); const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => { preloadAssets.set(assetsToPreload); @@ -26,6 +28,7 @@ function createAssetViewingStore() { asset: readonly(viewingAssetStoreState), preloadAssets: readonly(preloadAssets), isViewing: viewState, + gridScrollTarget, setAsset, setAssetId, showAssetViewer, diff --git a/web/src/lib/stores/asset.store.spec.ts b/web/src/lib/stores/asset.store.spec.ts index 3fd9e1e9819d6..7787bf794d090 100644 --- a/web/src/lib/stores/asset.store.spec.ts +++ b/web/src/lib/stores/asset.store.spec.ts @@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { AbortError } from '$lib/utils'; import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; import { assetFactory } from '@test-data/factories/asset-factory'; -import { AssetStore, BucketPosition } from './assets.store'; +import { AssetStore } from './assets.store'; describe('AssetStore', () => { beforeEach(() => { @@ -26,7 +26,8 @@ describe('AssetStore', () => { ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('should load buckets in viewport', () => { @@ -38,15 +39,15 @@ describe('AssetStore', () => { it('calculates bucket height', () => { expect(assetStore.buckets).toEqual( expect.arrayContaining([ - expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 235 }), - expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3760 }), - expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 235 }), + expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 286 }), + expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3811 }), + expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(assetStore.timelineHeight).toBe(4230); + expect(assetStore.timelineHeight).toBe(4383); }); }); @@ -72,35 +73,28 @@ describe('AssetStore', () => { return bucketAssets[timeBucket]; }); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('loads a bucket', async () => { expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0); - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3); }); it('ignores invalid buckets', async () => { - await assetStore.loadBucket('2023-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2023-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(0); }); - it('only updates the position of loaded buckets', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown); - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Unknown); - - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Visible); - }); - it('cancels bucket loading', async () => { const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); - const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + const loadPromise = assetStore.loadBucket(bucket!.bucketDate); const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort'); - assetStore.cancelBucket(bucket!); + bucket?.cancel(); expect(abortSpy).toBeCalledTimes(1); await loadPromise; @@ -109,24 +103,24 @@ describe('AssetStore', () => { it('prevents loading buckets multiple times', async () => { await Promise.all([ - assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown), - assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown), + assetStore.loadBucket('2024-01-01T00:00:00.000Z'), + assetStore.loadBucket('2024-01-01T00:00:00.000Z'), ]); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); }); it('allows loading a canceled bucket', async () => { const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); - const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + const loadPromise = assetStore.loadBucket(bucket!.bucketDate); - assetStore.cancelBucket(bucket!); + bucket?.cancel(); await loadPromise; expect(bucket?.assets.length).toEqual(0); - await assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + await assetStore.loadBucket(bucket!.bucketDate); expect(bucket!.assets.length).toEqual(3); }); }); @@ -137,7 +131,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('is empty initially', () => { @@ -219,7 +214,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('ignores non-existing assets', () => { @@ -263,7 +259,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('ignores invalid IDs', () => { @@ -312,7 +309,8 @@ describe('AssetStore', () => { ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('returns null for invalid assetId', async () => { @@ -321,15 +319,15 @@ describe('AssetStore', () => { }); it('returns previous assetId', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]); }); it('returns previous assetId spanning multiple buckets', async () => { - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z'); @@ -337,7 +335,7 @@ describe('AssetStore', () => { }); it('loads previous bucket', async () => { - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket'); const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); @@ -347,9 +345,9 @@ describe('AssetStore', () => { }); it('skips removed assets', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); const [assetOne, assetTwo, assetThree] = assetStore.assets; assetStore.removeAssets([assetTwo.id]); @@ -357,7 +355,7 @@ describe('AssetStore', () => { }); it('returns null when no more assets', async () => { - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull(); }); }); @@ -368,7 +366,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('returns null for invalid buckets', () => { diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 1022729e91574..763d5b1874c6e 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -1,6 +1,11 @@ +import { locale } from '$lib/stores/preferences.store'; import { getKey } from '$lib/utils'; -import { fromLocalDateTime } from '$lib/utils/timeline-util'; -import { TimeBucketSize, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; +import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager'; +import { getAssetRatio } from '$lib/utils/asset-utils'; +import type { AssetGridRouteSearchParams } from '$lib/utils/navigation'; +import { calculateWidth, fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util'; +import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; +import createJustifiedLayout from 'justified-layout'; import { throttle } from 'lodash-es'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; @@ -8,19 +13,24 @@ import { get, writable, type Unsubscriber } from 'svelte/store'; import { handleError } from '../utils/handle-error'; import { websocketEvents } from './websocket'; -export enum BucketPosition { - Above = 'above', - Below = 'below', - Visible = 'visible', - Unknown = 'unknown', -} type AssetApiGetTimeBucketsRequest = Parameters[0]; export type AssetStoreOptions = Omit; +const LAYOUT_OPTIONS = { + boxSpacing: 2, + containerPadding: 0, + targetRowHeightTolerance: 0.15, + targetRowHeight: 235, +}; + export interface Viewport { width: number; height: number; } +export type ViewportXY = Viewport & { + x: number; + y: number; +}; interface AssetLookup { bucket: AssetBucket; @@ -29,16 +39,89 @@ interface AssetLookup { } export class AssetBucket { + store!: AssetStore; + bucketDate!: string; /** * The DOM height of the bucket in pixel * This value is first estimated by the number of asset and later is corrected as the user scroll */ - bucketHeight!: number; - bucketDate!: string; - bucketCount!: number; - assets!: AssetResponseDto[]; - cancelToken!: AbortController | null; - position!: BucketPosition; + bucketHeight: number = 0; + isBucketHeightActual: boolean = false; + bucketDateFormattted!: string; + bucketCount: number = 0; + assets: AssetResponseDto[] = []; + dateGroups: DateGroup[] = []; + cancelToken: AbortController | undefined; + /** + * Prevent this asset's load from being canceled; i.e. to force load of offscreen asset. + */ + isPreventCancel: boolean = false; + /** + * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled. + */ + complete!: Promise; + loading: boolean = false; + isLoaded: boolean = false; + intersecting: boolean = false; + measured: boolean = false; + measuredPromise!: Promise; + + constructor(props: Partial & { store: AssetStore; bucketDate: string }) { + Object.assign(this, props); + this.init(); + } + + private init() { + // create a promise, and store its resolve/reject callbacks. The loadedSignal callback + // will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal + // callback will be called if the bucket is canceled before it was loaded, rejecting the + // promise. + this.complete = new Promise((resolve, reject) => { + this.loadedSignal = resolve; + this.canceledSignal = reject; + }); + // if no-one waits on complete, and its rejected a uncaught rejection message is logged. + // We this message with an empty reject handler, since waiting on a bucket is optional. + this.complete.catch(() => void 0); + this.measuredPromise = new Promise((resolve) => { + this.measuredSignal = resolve; + }); + + this.bucketDateFormattted = fromLocalDateTime(this.bucketDate) + .startOf('month') + .toJSDate() + .toLocaleString(get(locale), { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); + } + + private loadedSignal: (() => void) | undefined; + private canceledSignal: (() => void) | undefined; + measuredSignal: (() => void) | undefined; + + cancel() { + if (this.isLoaded) { + return; + } + if (this.isPreventCancel) { + return; + } + this.cancelToken?.abort(); + this.canceledSignal?.(); + this.init(); + } + + loaded() { + this.loadedSignal?.(); + this.isLoaded = true; + } + + errored() { + this.canceledSignal?.(); + this.init(); + } } const isMismatched = (option: boolean | undefined, value: boolean): boolean => @@ -65,34 +148,101 @@ interface TrashAssets { type: 'trash'; values: string[]; } +interface UpdateStackAssets { + type: 'update_stack_assets'; + values: string[]; +} export const photoViewer = writable(null); -type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets; +type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets; + +export type BucketListener = ( + event: + | ViewPortEvent + | BucketLoadEvent + | BucketLoadedEvent + | BucketCancelEvent + | BucketHeightEvent + | DateGroupIntersecting + | DateGroupHeightEvent, +) => void; + +type ViewPortEvent = { + type: 'viewport'; +}; +type BucketLoadEvent = { + type: 'load'; + bucket: AssetBucket; +}; +type BucketLoadedEvent = { + type: 'loaded'; + bucket: AssetBucket; +}; +type BucketCancelEvent = { + type: 'cancel'; + bucket: AssetBucket; +}; +type BucketHeightEvent = { + type: 'bucket-height'; + bucket: AssetBucket; + delta: number; +}; +type DateGroupIntersecting = { + type: 'intersecting'; + bucket: AssetBucket; + dateGroup: DateGroup; +}; +type DateGroupHeightEvent = { + type: 'height'; + bucket: AssetBucket; + dateGroup: DateGroup; + delta: number; + height: number; +}; export class AssetStore { - private store$ = writable(this); private assetToBucket: Record = {}; private pendingChanges: PendingChange[] = []; private unsubscribers: Unsubscriber[] = []; private options: AssetApiGetTimeBucketsRequest; + private viewport: Viewport = { + height: 0, + width: 0, + }; + private initializedSignal!: () => void; + private store$ = writable(this); + lastScrollTime: number = 0; + subscribe = this.store$.subscribe; + /** + * A promise that resolves once the store is initialized. + */ + taskManager = new AssetGridTaskManager(this); + complete!: Promise; initialized = false; timelineHeight = 0; buckets: AssetBucket[] = []; assets: AssetResponseDto[] = []; albumAssets: Set = new Set(); + pendingScrollBucket: AssetBucket | undefined; + pendingScrollAssetId: string | undefined; + + listeners: BucketListener[] = []; constructor( options: AssetStoreOptions, private albumId?: string, ) { this.options = { ...options, size: TimeBucketSize.Month }; + // create a promise, and store its resolve callbacks. The initializedSignal callback + // will be invoked when a the assetstore is initialized. + this.complete = new Promise((resolve) => { + this.initializedSignal = resolve; + }); this.store$.set(this); } - subscribe = this.store$.subscribe; - private addPendingChanges(...changes: PendingChange[]) { // prevent websocket events from happening before local client events setTimeout(() => { @@ -103,8 +253,9 @@ export class AssetStore { connect() { this.unsubscribers.push( - websocketEvents.on('on_upload_success', (asset) => { - this.addPendingChanges({ type: 'add', values: [asset] }); + websocketEvents.on('on_upload_success', (_) => { + // TODO!: Temporarily disable to avoid flashing effect of the timeline + // this.addPendingChanges({ type: 'add', values: [asset] }); }), websocketEvents.on('on_asset_trash', (ids) => { this.addPendingChanges({ type: 'trash', values: ids }); @@ -182,8 +333,35 @@ export class AssetStore { this.emit(true); }, 2500); - async init(viewport: Viewport) { - this.initialized = false; + addListener(bucketListener: BucketListener) { + this.listeners.push(bucketListener); + } + removeListener(bucketListener: BucketListener) { + this.listeners = this.listeners.filter((l) => l != bucketListener); + } + private notifyListeners( + event: + | ViewPortEvent + | BucketLoadEvent + | BucketLoadedEvent + | BucketCancelEvent + | BucketHeightEvent + | DateGroupIntersecting + | DateGroupHeightEvent, + ) { + for (const fn of this.listeners) { + fn(event); + } + } + async init({ bucketListener }: { bucketListener?: BucketListener } = {}) { + if (this.initialized) { + throw 'Can only init once'; + } + if (bucketListener) { + this.addListener(bucketListener); + } + // uncaught rejection go away + this.complete.catch(() => void 0); this.timelineHeight = 0; this.buckets = []; this.assets = []; @@ -194,65 +372,118 @@ export class AssetStore { ...this.options, key: getKey(), }); - + this.buckets = timebuckets.map( + (bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, bucketCount: bucket.count }), + ); + this.initializedSignal(); this.initialized = true; - - this.buckets = timebuckets.map((bucket) => ({ - bucketDate: bucket.timeBucket, - bucketHeight: 0, - bucketCount: bucket.count, - assets: [], - cancelToken: null, - position: BucketPosition.Unknown, - })); - - // if loading an asset, the grid-view may be hidden, which means - // it has 0 width and height. No need to update bucket or timeline - // heights in this case. Later, updateViewport will be called to - // update the heights. - if (viewport.height !== 0 && viewport.width !== 0) { - await this.updateViewport(viewport); - } } - async updateViewport(viewport: Viewport) { + public destroy() { + this.taskManager.destroy(); + this.listeners = []; + this.initialized = false; + } + + async updateViewport(viewport: Viewport, force?: boolean) { + if (!this.initialized) { + return; + } + if (viewport.height === 0 && viewport.width === 0) { + return; + } + + if (!force && this.viewport.height === viewport.height && this.viewport.width === viewport.width) { + return; + } + + // changing width invalidates the actual height, and needs to be remeasured, since width changes causes + // layout reflows. + const changedWidth = this.viewport.width != viewport.width; + this.viewport = { ...viewport }; + for (const bucket of this.buckets) { - const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); - const rows = Math.ceil(unwrappedWidth / viewport.width); - const height = rows * THUMBNAIL_HEIGHT; - bucket.bucketHeight = height; + this.updateGeometry(bucket, changedWidth); } this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); - let height = 0; const loaders = []; + let height = 0; for (const bucket of this.buckets) { - if (height < viewport.height) { - height += bucket.bucketHeight; - loaders.push(this.loadBucket(bucket.bucketDate, BucketPosition.Visible)); - continue; + if (height >= viewport.height) { + break; } - break; + height += bucket.bucketHeight; + loaders.push(this.loadBucket(bucket.bucketDate)); } await Promise.all(loaders); + this.notifyListeners({ type: 'viewport' }); this.emit(false); } - async loadBucket(bucketDate: string, position: BucketPosition): Promise { + private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { + if (invalidateHeight) { + bucket.isBucketHeightActual = false; + bucket.measured = false; + for (const assetGroup of bucket.dateGroups) { + assetGroup.heightActual = false; + } + } + if (!bucket.isBucketHeightActual) { + const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); + const rows = Math.ceil(unwrappedWidth / this.viewport.width); + const height = 51 + rows * THUMBNAIL_HEIGHT; + bucket.bucketHeight = height; + } + + for (const assetGroup of bucket.dateGroups) { + if (!assetGroup.heightActual) { + const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10); + const rows = Math.ceil(unwrappedWidth / this.viewport.width); + const height = rows * THUMBNAIL_HEIGHT; + assetGroup.height = height; + } + + const layoutResult = createJustifiedLayout( + assetGroup.assets.map((g) => getAssetRatio(g)), + { + ...LAYOUT_OPTIONS, + containerWidth: Math.floor(this.viewport.width), + }, + ); + assetGroup.geometry = { + ...layoutResult, + containerWidth: calculateWidth(layoutResult.boxes), + }; + } + } + + async loadBucket(bucketDate: string, options: { preventCancel?: boolean; pending?: boolean } = {}): Promise { const bucket = this.getBucketByDate(bucketDate); if (!bucket) { return; } - - bucket.position = position; - - if (bucket.cancelToken || bucket.assets.length > 0) { - this.emit(false); + if (bucket.bucketCount === bucket.assets.length) { + // already loaded return; } - bucket.cancelToken = new AbortController(); + if (bucket.cancelToken != null && bucket.bucketCount !== bucket.assets.length) { + // if promise is pending, and preventCancel is requested, then don't overwrite it + if (!bucket.isPreventCancel && options.preventCancel) { + bucket.isPreventCancel = options.preventCancel; + } + await bucket.complete; + return; + } + if (options.pending) { + this.pendingScrollBucket = bucket; + } + this.notifyListeners({ type: 'load', bucket }); + bucket.isPreventCancel = !!options.preventCancel; + + const cancelToken = (bucket.cancelToken = new AbortController()); try { const assets = await getTimeBucket( { @@ -260,9 +491,14 @@ export class AssetStore { timeBucket: bucketDate, key: getKey(), }, - { signal: bucket.cancelToken.signal }, + { signal: cancelToken.signal }, ); + if (cancelToken.signal.aborted) { + this.notifyListeners({ type: 'cancel', bucket }); + return; + } + if (this.albumId) { const albumAssets = await getTimeBucket( { @@ -271,50 +507,87 @@ export class AssetStore { size: this.options.size, key: getKey(), }, - { signal: bucket.cancelToken.signal }, + { signal: cancelToken.signal }, ); - + if (cancelToken.signal.aborted) { + this.notifyListeners({ type: 'cancel', bucket }); + return; + } for (const asset of albumAssets) { this.albumAssets.add(asset.id); } } - if (bucket.cancelToken.signal.aborted) { + bucket.assets = assets; + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); + this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); + bucket.loaded(); + this.notifyListeners({ type: 'loaded', bucket }); + } catch (error) { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + if ((error as any).name === 'AbortError') { return; } - - bucket.assets = assets; - - this.emit(true); - } catch (error) { const $t = get(t); handleError(error, $t('errors.failed_to_load_assets')); + bucket.errored(); } finally { - bucket.cancelToken = null; + bucket.cancelToken = undefined; + this.emit(true); } } - cancelBucket(bucket: AssetBucket) { - bucket.cancelToken?.abort(); - } - - updateBucket(bucketDate: string, height: number) { + updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) { const bucket = this.getBucketByDate(bucketDate); if (!bucket) { - return 0; + return {}; + } + let delta = 0; + if ('height' in properties) { + const height = properties.height!; + delta = height - bucket.bucketHeight; + bucket.isBucketHeightActual = true; + bucket.bucketHeight = height; + this.timelineHeight += delta; + this.notifyListeners({ type: 'bucket-height', bucket, delta }); + } + if ('intersecting' in properties) { + bucket.intersecting = properties.intersecting!; + } + if ('measured' in properties) { + if (properties.measured) { + bucket.measuredSignal?.(); + } + bucket.measured = properties.measured!; } - - const delta = height - bucket.bucketHeight; - const scrollTimeline = bucket.position == BucketPosition.Above; - - bucket.bucketHeight = height; - bucket.position = BucketPosition.Unknown; - - this.timelineHeight += delta; - this.emit(false); + return { delta }; + } - return scrollTimeline ? delta : 0; + updateBucketDateGroup( + bucket: AssetBucket, + dateGroup: DateGroup, + properties: { height?: number; intersecting?: boolean }, + ) { + let delta = 0; + if ('height' in properties) { + const height = properties.height!; + if (height > 0) { + delta = height - dateGroup.height; + dateGroup.heightActual = true; + dateGroup.height = height; + this.notifyListeners({ type: 'height', bucket, dateGroup, delta, height }); + } + } + if ('intersecting' in properties) { + dateGroup.intersecting = properties.intersecting!; + if (dateGroup.intersecting) { + this.notifyListeners({ type: 'intersecting', bucket, dateGroup }); + } + } + this.emit(false); + return { delta }; } addAssets(assets: AssetResponseDto[]) { @@ -354,15 +627,7 @@ export class AssetStore { let bucket = this.getBucketByDate(timeBucket); if (!bucket) { - bucket = { - bucketDate: timeBucket, - bucketHeight: THUMBNAIL_HEIGHT, - bucketCount: 0, - assets: [], - cancelToken: null, - position: BucketPosition.Unknown, - }; - + bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT }); this.buckets.push(bucket); } @@ -383,6 +648,8 @@ export class AssetStore { const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC(); return bDate.diff(aDate).milliseconds; }); + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); } this.emit(true); @@ -392,18 +659,73 @@ export class AssetStore { return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null; } - async getBucketInfoForAssetId({ id, localDateTime }: Pick) { + async findAndLoadBucketAsPending(id: string) { const bucketInfo = this.assetToBucket[id]; if (bucketInfo) { - return bucketInfo; + const bucket = bucketInfo.bucket; + this.pendingScrollBucket = bucket; + this.pendingScrollAssetId = id; + this.emit(false); + return bucket; } + const asset = await getAssetInfo({ id }); + if (asset) { + if (this.options.isArchived !== asset.isArchived) { + return; + } + const bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true }); + if (bucket) { + this.pendingScrollBucket = bucket; + this.pendingScrollAssetId = asset.id; + this.emit(false); + } + return bucket; + } + } + + /* Must be paired with matching clearPendingScroll() call */ + async scheduleScrollToAssetId(scrollTarget: AssetGridRouteSearchParams, onFailure: () => void) { + try { + const { at: assetId } = scrollTarget; + if (assetId) { + await this.complete; + const bucket = await this.findAndLoadBucketAsPending(assetId); + if (bucket) { + return; + } + } + } catch { + // failure + } + onFailure(); + } + + clearPendingScroll() { + this.pendingScrollBucket = undefined; + this.pendingScrollAssetId = undefined; + } + + private async loadBucketAtTime(localDateTime: string, options: { preventCancel?: boolean; pending?: boolean }) { let date = fromLocalDateTime(localDateTime); if (this.options.size == TimeBucketSize.Month) { date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); } else if (this.options.size == TimeBucketSize.Day) { date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); } - await this.loadBucket(date.toISO()!, BucketPosition.Unknown); + const iso = date.toISO()!; + await this.loadBucket(iso, options); + return this.getBucketByDate(iso); + } + + private async getBucketInfoForAsset( + { id, localDateTime }: Pick, + options: { preventCancel?: boolean; pending?: boolean } = {}, + ) { + const bucketInfo = this.assetToBucket[id]; + if (bucketInfo) { + return bucketInfo; + } + await this.loadBucketAtTime(localDateTime, options); return this.assetToBucket[id] || null; } @@ -417,7 +739,7 @@ export class AssetStore { ); for (const bucket of this.buckets) { if (index < bucket.bucketCount) { - await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(bucket.bucketDate); return bucket.assets[index] || null; } @@ -458,6 +780,7 @@ export class AssetStore { // Iterate in reverse to allow array splicing. for (let index = this.buckets.length - 1; index >= 0; index--) { const bucket = this.buckets[index]; + let changed = false; for (let index_ = bucket.assets.length - 1; index_ >= 0; index_--) { const asset = bucket.assets[index_]; if (!idSet.has(asset.id)) { @@ -465,17 +788,22 @@ export class AssetStore { } bucket.assets.splice(index_, 1); + changed = true; if (bucket.assets.length === 0) { this.buckets.splice(index, 1); } } + if (changed) { + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); + } } this.emit(true); } async getPreviousAsset(asset: AssetResponseDto): Promise { - const info = await this.getBucketInfoForAssetId(asset); + const info = await this.getBucketInfoForAsset(asset); if (!info) { return null; } @@ -491,12 +819,12 @@ export class AssetStore { } const previousBucket = this.buckets[bucketIndex - 1]; - await this.loadBucket(previousBucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(previousBucket.bucketDate); return previousBucket.assets.at(-1) || null; } async getNextAsset(asset: AssetResponseDto): Promise { - const info = await this.getBucketInfoForAssetId(asset); + const info = await this.getBucketInfoForAsset(asset); if (!info) { return null; } @@ -512,7 +840,7 @@ export class AssetStore { } const nextBucket = this.buckets[bucketIndex + 1]; - await this.loadBucket(nextBucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(nextBucket.bucketDate); return nextBucket.assets[0] || null; } @@ -537,8 +865,7 @@ export class AssetStore { } this.assetToBucket = assetToBucket; } - - this.store$.update(() => this); + this.store$.set(this); } } diff --git a/web/src/lib/stores/folders.store.ts b/web/src/lib/stores/folders.store.ts new file mode 100644 index 0000000000000..2e491374e2a15 --- /dev/null +++ b/web/src/lib/stores/folders.store.ts @@ -0,0 +1,69 @@ +import { + getAssetsByOriginalPath, + getUniqueOriginalPaths, + /** + * TODO: Incorrect type + */ + type AssetResponseDto, +} from '@immich/sdk'; +import { get, writable } from 'svelte/store'; + +type AssetCache = { + [path: string]: AssetResponseDto[]; +}; + +type FoldersStore = { + uniquePaths: string[] | null; + assets: AssetCache; +}; + +function createFoldersStore() { + const initialState: FoldersStore = { + uniquePaths: null, + assets: {}, + }; + + const { subscribe, set, update } = writable(initialState); + + async function fetchUniquePaths() { + const state = get(foldersStore); + + if (state.uniquePaths !== null) { + return; + } + + const uniquePaths = await getUniqueOriginalPaths(); + if (uniquePaths) { + update((state) => ({ ...state, uniquePaths })); + } + } + + async function fetchAssetsByPath(path: string) { + const state = get(foldersStore); + + if (state.assets[path]) { + return; + } + + const assets = await getAssetsByOriginalPath({ path }); + if (assets) { + update((state) => ({ + ...state, + assets: { ...state.assets, [path]: assets }, + })); + } + } + + function clearCache() { + set(initialState); + } + + return { + subscribe, + fetchUniquePaths, + fetchAssetsByPath, + clearCache, + }; +} + +export const foldersStore = createFoldersStore(); diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 11473f80612a6..de80702b95406 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -96,11 +96,6 @@ export interface SidebarSettings { sharing: boolean; } -export const sidebarSettings = persisted('sidebar-settings-1', { - people: false, - sharing: true, -}); - export enum SortOrder { Asc = 'asc', Desc = 'desc', diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 40670df25fb6f..1d3c4bc00eb26 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -33,7 +33,7 @@ export const serverConfig = writable({ externalDomain: '', }); -export const loadConfig = async () => { +export const retrieveServerConfig = async () => { const [flags, config] = await Promise.all([getServerFeatures(), getServerConfig()]); featureFlags.update(() => ({ ...flags, loaded: true })); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index b805cf8132a00..6c3add70ce562 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -33,7 +33,7 @@ interface DownloadRequestOptions { onDownloadProgress?: (event: ProgressEvent) => void; } -export const initApp = async () => { +export const initLanguage = async () => { const preferenceLang = get(lang); for (const { code, loader } of langs) { register(code, loader); diff --git a/web/src/lib/utils/album-utils.ts b/web/src/lib/utils/album-utils.ts index aff76ef88e2aa..028aa721c744c 100644 --- a/web/src/lib/utils/album-utils.ts +++ b/web/src/lib/utils/album-utils.ts @@ -1,4 +1,5 @@ import { goto } from '$app/navigation'; +import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { AppRoute } from '$lib/constants'; import { AlbumFilter, @@ -199,3 +200,16 @@ export const collapseAllAlbumGroups = (groupIds: string[]) => { export const expandAllAlbumGroups = () => { collapseAllAlbumGroups([]); }; + +export const confirmAlbumDelete = async (album: AlbumResponseDto) => { + const $t = get(t); + const confirmation = + album.albumName.length > 0 + ? $t('album_delete_confirmation', { values: { album: album.albumName } }) + : $t('unnamed_album_delete_confirmation'); + + const description = $t('album_delete_confirmation_description'); + const prompt = `${confirmation} ${description}`; + + return dialogController.show({ prompt }); +}; diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts new file mode 100644 index 0000000000000..e476738456d3b --- /dev/null +++ b/web/src/lib/utils/asset-store-task-manager.ts @@ -0,0 +1,465 @@ +import type { AssetBucket, AssetStore } from '$lib/stores/assets.store'; +import { generateId } from '$lib/utils/generate-id'; +import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support'; +import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue'; +import { type DateGroup } from '$lib/utils/timeline-util'; +import { TUNABLES } from '$lib/utils/tunables'; +import { type AssetResponseDto } from '@immich/sdk'; +import { clamp } from 'lodash-es'; + +type Task = () => void; + +class InternalTaskManager { + assetStore: AssetStore; + componentTasks = new Map>(); + priorityQueue = new KeyedPriorityQueue(); + idleQueue = new Map(); + taskCleaners = new Map(); + + queueTimer: ReturnType | undefined; + lastIdle: number | undefined; + + constructor(assetStore: AssetStore) { + this.assetStore = assetStore; + } + destroy() { + this.componentTasks.clear(); + this.priorityQueue.clear(); + this.idleQueue.clear(); + this.taskCleaners.clear(); + clearTimeout(this.queueTimer); + if (this.lastIdle) { + cancelIdleCB(this.lastIdle); + } + } + getOrCreateComponentTasks(componentId: string) { + let componentTaskSet = this.componentTasks.get(componentId); + if (!componentTaskSet) { + componentTaskSet = new Set(); + this.componentTasks.set(componentId, componentTaskSet); + } + + return componentTaskSet; + } + deleteFromComponentTasks(componentId: string, taskId: string) { + if (this.componentTasks.has(componentId)) { + const componentTaskSet = this.componentTasks.get(componentId); + componentTaskSet?.delete(taskId); + if (componentTaskSet?.size === 0) { + this.componentTasks.delete(componentId); + } + } + } + + drainIntersectedQueue() { + let count = 0; + for (let t = this.priorityQueue.shift(); t; t = this.priorityQueue.shift()) { + t.value(); + if (this.taskCleaners.has(t.key)) { + this.taskCleaners.get(t.key)!(); + this.taskCleaners.delete(t.key); + } + if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) { + this.scheduleDrainIntersectedQueue(TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS); + break; + } + } + } + + scheduleDrainIntersectedQueue(delay: number = TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS) { + clearTimeout(this.queueTimer); + this.queueTimer = setTimeout(() => { + const delta = Date.now() - this.assetStore.lastScrollTime; + if (delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) { + let amount = clamp( + 1 + Math.round(this.priorityQueue.length / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR), + 1, + TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS * 2, + ); + + const nextDelay = clamp( + amount > 1 + ? Math.round(delay / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR) + : TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS, + TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY, + TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY, + ); + + while (amount > 0) { + this.priorityQueue.shift()?.value(); + amount--; + } + if (this.priorityQueue.length > 0) { + this.scheduleDrainIntersectedQueue(nextDelay); + } + } else { + this.drainIntersectedQueue(); + } + }, delay); + } + + removeAllTasksForComponent(componentId: string) { + if (this.componentTasks.has(componentId)) { + const tasksIds = this.componentTasks.get(componentId) || []; + for (const taskId of tasksIds) { + this.priorityQueue.remove(taskId); + this.idleQueue.delete(taskId); + if (this.taskCleaners.has(taskId)) { + const cleanup = this.taskCleaners.get(taskId); + this.taskCleaners.delete(taskId); + cleanup!(); + } + } + } + this.componentTasks.delete(componentId); + } + + queueScrollSensitiveTask({ + task, + cleanup, + componentId, + priority = 10, + taskId = generateId(), + }: { + task: Task; + cleanup?: Task; + componentId: string; + priority?: number; + taskId?: string; + }) { + this.priorityQueue.push(taskId, task, priority); + if (cleanup) { + this.taskCleaners.set(taskId, cleanup); + } + this.getOrCreateComponentTasks(componentId).add(taskId); + const lastTime = this.assetStore.lastScrollTime; + const delta = Date.now() - lastTime; + if (lastTime != 0 && delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) { + this.scheduleDrainIntersectedQueue(); + } else { + // flush the queue early + clearTimeout(this.queueTimer); + this.drainIntersectedQueue(); + } + } + + scheduleDrainSeparatedQueue() { + if (this.lastIdle) { + cancelIdleCB(this.lastIdle); + } + this.lastIdle = idleCB( + () => { + let count = 0; + let entry = this.idleQueue.entries().next().value; + while (entry) { + const [taskId, task] = entry; + this.idleQueue.delete(taskId); + task(); + if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) { + break; + } + entry = this.idleQueue.entries().next().value; + } + if (this.idleQueue.size > 0) { + this.scheduleDrainSeparatedQueue(); + } + }, + { timeout: 1000 }, + ); + } + queueSeparateTask({ + task, + cleanup, + componentId, + taskId, + }: { + task: Task; + cleanup: Task; + componentId: string; + taskId: string; + }) { + this.idleQueue.set(taskId, task); + this.taskCleaners.set(taskId, cleanup); + this.getOrCreateComponentTasks(componentId).add(taskId); + this.scheduleDrainSeparatedQueue(); + } + + removeIntersectedTask(taskId: string) { + const removed = this.priorityQueue.remove(taskId); + if (this.taskCleaners.has(taskId)) { + const cleanup = this.taskCleaners.get(taskId); + this.taskCleaners.delete(taskId); + cleanup!(); + } + return removed; + } + + removeSeparateTask(taskId: string) { + const removed = this.idleQueue.delete(taskId); + if (this.taskCleaners.has(taskId)) { + const cleanup = this.taskCleaners.get(taskId); + this.taskCleaners.delete(taskId); + cleanup!(); + } + return removed; + } +} + +export class AssetGridTaskManager { + private internalManager: InternalTaskManager; + constructor(assetStore: AssetStore) { + this.internalManager = new InternalTaskManager(assetStore); + } + + tasks: Map = new Map(); + + queueScrollSensitiveTask({ + task, + cleanup, + componentId, + priority = 10, + taskId = generateId(), + }: { + task: Task; + cleanup?: Task; + componentId: string; + priority?: number; + taskId?: string; + }) { + return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId }); + } + + removeAllTasksForComponent(componentId: string) { + return this.internalManager.removeAllTasksForComponent(componentId); + } + + destroy() { + return this.internalManager.destroy(); + } + + private getOrCreateBucketTask(bucket: AssetBucket) { + let bucketTask = this.tasks.get(bucket); + if (!bucketTask) { + bucketTask = this.createBucketTask(bucket); + } + return bucketTask; + } + + private createBucketTask(bucket: AssetBucket) { + const bucketTask = new BucketTask(this.internalManager, this, bucket); + this.tasks.set(bucket, bucketTask); + return bucketTask; + } + + intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) { + const bucketTask = this.getOrCreateBucketTask(bucket); + bucketTask.scheduleIntersected(componentId, task); + } + + separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) { + const bucketTask = this.getOrCreateBucketTask(bucket); + bucketTask.scheduleSeparated(componentId, separated); + } + + intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + bucketTask.intersectedDateGroup(componentId, dateGroup, intersected); + } + + separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + bucketTask.separatedDateGroup(componentId, dateGroup, separated); + } + + intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.intersectedThumbnail(componentId, asset, intersected); + } + + separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.separatedThumbnail(componentId, asset, separated); + } +} + +class IntersectionTask { + internalTaskManager: InternalTaskManager; + separatedKey; + intersectedKey; + priority; + + intersected: Task | undefined; + separated: Task | undefined; + + constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) { + this.internalTaskManager = internalTaskManager; + this.separatedKey = keyPrefix + ':s:' + key; + this.intersectedKey = keyPrefix + ':i:' + key; + this.priority = priority; + } + + trackIntersectedTask(componentId: string, task: Task) { + const execTask = () => { + if (this.separated) { + return; + } + task?.(); + }; + this.intersected = execTask; + const cleanup = () => { + this.intersected = undefined; + this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey); + }; + return { task: execTask, cleanup }; + } + + trackSeperatedTask(componentId: string, task: Task) { + const execTask = () => { + if (this.intersected) { + return; + } + task?.(); + }; + this.separated = execTask; + const cleanup = () => { + this.separated = undefined; + this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey); + }; + return { task: execTask, cleanup }; + } + + removePendingSeparated() { + if (this.separated) { + this.internalTaskManager.removeSeparateTask(this.separatedKey); + } + } + removePendingIntersected() { + if (this.intersected) { + this.internalTaskManager.removeIntersectedTask(this.intersectedKey); + } + } + + scheduleIntersected(componentId: string, intersected: Task) { + this.removePendingSeparated(); + if (this.intersected) { + return; + } + const { task, cleanup } = this.trackIntersectedTask(componentId, intersected); + this.internalTaskManager.queueScrollSensitiveTask({ + task, + cleanup, + componentId, + priority: this.priority, + taskId: this.intersectedKey, + }); + } + + scheduleSeparated(componentId: string, separated: Task) { + this.removePendingIntersected(); + + if (this.separated) { + return; + } + + const { task, cleanup } = this.trackSeperatedTask(componentId, separated); + this.internalTaskManager.queueSeparateTask({ + task, + cleanup, + componentId, + taskId: this.separatedKey, + }); + } +} +class BucketTask extends IntersectionTask { + assetBucket: AssetBucket; + assetGridTaskManager: AssetGridTaskManager; + // indexed by dateGroup's date + dateTasks: Map = new Map(); + + constructor(internalTaskManager: InternalTaskManager, parent: AssetGridTaskManager, assetBucket: AssetBucket) { + super(internalTaskManager, 'b', assetBucket.bucketDate, TUNABLES.BUCKET.PRIORITY); + this.assetBucket = assetBucket; + this.assetGridTaskManager = parent; + } + + getOrCreateDateGroupTask(dateGroup: DateGroup) { + let dateGroupTask = this.dateTasks.get(dateGroup); + if (!dateGroupTask) { + dateGroupTask = this.createDateGroupTask(dateGroup); + } + return dateGroupTask; + } + + createDateGroupTask(dateGroup: DateGroup) { + const dateGroupTask = new DateGroupTask(this.internalTaskManager, this, dateGroup); + this.dateTasks.set(dateGroup, dateGroupTask); + return dateGroupTask; + } + + removePendingSeparated() { + super.removePendingSeparated(); + for (const dateGroupTask of this.dateTasks.values()) { + dateGroupTask.removePendingSeparated(); + } + } + + intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) { + const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.scheduleIntersected(componentId, intersected); + } + + separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) { + const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.scheduleSeparated(componentId, separated); + } +} +class DateGroupTask extends IntersectionTask { + dateGroup: DateGroup; + bucketTask: BucketTask; + // indexed by thumbnail's asset + thumbnailTasks: Map = new Map(); + + constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) { + super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY); + this.dateGroup = dateGroup; + this.bucketTask = parent; + } + + removePendingSeparated() { + super.removePendingSeparated(); + for (const thumbnailTask of this.thumbnailTasks.values()) { + thumbnailTask.removePendingSeparated(); + } + } + + getOrCreateThumbnailTask(asset: AssetResponseDto) { + let thumbnailTask = this.thumbnailTasks.get(asset); + if (!thumbnailTask) { + thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset); + this.thumbnailTasks.set(asset, thumbnailTask); + } + return thumbnailTask; + } + + intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) { + const thumbnailTask = this.getOrCreateThumbnailTask(asset); + thumbnailTask.scheduleIntersected(componentId, intersected); + } + + separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) { + const thumbnailTask = this.getOrCreateThumbnailTask(asset); + thumbnailTask.scheduleSeparated(componentId, separated); + } +} +class ThumbnailTask extends IntersectionTask { + asset: AssetResponseDto; + dateGroupTask: DateGroupTask; + + constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) { + super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY); + this.asset = asset; + this.dateGroupTask = parent; + } +} diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 74a695770e848..e309db5ff6a1e 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -4,17 +4,23 @@ import { NotificationType, notificationController } from '$lib/components/shared import { AppRoute } from '$lib/constants'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; -import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; +import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; import { preferences } from '$lib/stores/user.store'; import { downloadRequest, getKey, withError } from '$lib/utils'; import { createAlbum } from '$lib/utils/album-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; +import { getFormatter } from '$lib/utils/i18n'; import { addAssetsToAlbum as addAssets, + createStack, + deleteStacks, getAssetInfo, getBaseUrl, getDownloadInfo, + getStack, + tagAssets as tagAllAssets, + untagAssets, updateAsset, updateAssets, type AlbumResponseDto, @@ -46,7 +52,7 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show timeout: 5000, message: count > 0 - ? $t('assets_added_to_album_count', { values: { count: count } }) + ? $t('assets_added_to_album_count', { values: { count } }) : $t('assets_were_part_of_album_count', { values: { count: assetIds.length } }), button: { text: $t('view_album'), @@ -58,6 +64,54 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show } }; +export const tagAssets = async ({ + assetIds, + tagIds, + showNotification = true, +}: { + assetIds: string[]; + tagIds: string[]; + showNotification?: boolean; +}) => { + for (const tagId of tagIds) { + await tagAllAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }); + } + + if (showNotification) { + const $t = await getFormatter(); + notificationController.show({ + message: $t('tagged_assets', { values: { count: assetIds.length } }), + type: NotificationType.Info, + }); + } + + return assetIds; +}; + +export const removeTag = async ({ + assetIds, + tagIds, + showNotification = true, +}: { + assetIds: string[]; + tagIds: string[]; + showNotification?: boolean; +}) => { + for (const tagId of tagIds) { + await untagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }); + } + + if (showNotification) { + const $t = await getFormatter(); + notificationController.show({ + message: $t('removed_tagged_assets', { values: { count: assetIds.length } }), + type: NotificationType.Info, + }); + } + + return assetIds; +}; + export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => { const album = await createAlbum(albumName, assetIds); if (!album) { @@ -210,7 +264,7 @@ export const downloadFile = async (asset: AssetResponseDto) => { downloadBlob(data, filename); } catch (error) { - handleError(error, $t('errors.error_downloading', { values: { filename: filename } })); + handleError(error, $t('errors.error_downloading', { values: { filename } })); downloadManager.clear(downloadKey); } finally { setTimeout(() => downloadManager.clear(downloadKey), 5000); @@ -335,79 +389,60 @@ export const stackAssets = async (assets: AssetResponseDto[], showNotification = return false; } - const parent = assets[0]; - const children = assets.slice(1); - const ids = children.map(({ id }) => id); const $t = get(t); try { - await updateAssets({ - assetBulkUpdateDto: { - ids, - stackParentId: parent.id, - }, - }); + const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } }); + if (showNotification) { + notificationController.show({ + message: $t('stacked_assets_count', { values: { count: stack.assets.length } }), + type: NotificationType.Info, + button: { + text: $t('view_stack'), + onClick: () => assetViewingStore.setAssetId(stack.primaryAssetId), + }, + }); + } + + for (const [index, asset] of assets.entries()) { + asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null; + } + + return assets.slice(1).map((asset) => asset.id); } catch (error) { handleError(error, $t('errors.failed_to_stack_assets')); return false; } - - let grandChildren: AssetResponseDto[] = []; - for (const asset of children) { - asset.stackParentId = parent.id; - if (asset.stack) { - // Add grand-children to new parent - grandChildren = grandChildren.concat(asset.stack); - // Reset children stack info - asset.stackCount = null; - asset.stack = []; - } - } - - parent.stack ??= []; - parent.stack = parent.stack.concat(children, grandChildren); - parent.stackCount = parent.stack.length + 1; - - if (showNotification) { - notificationController.show({ - message: $t('stacked_assets_count', { values: { count: parent.stackCount } }), - type: NotificationType.Info, - button: { - text: $t('view_stack'), - onClick() { - return assetViewingStore.setAssetId(parent.id); - }, - }, - }); - } - - return ids; }; -export const unstackAssets = async (assets: AssetResponseDto[]) => { - const ids = assets.map(({ id }) => id); - const $t = get(t); - try { - await updateAssets({ - assetBulkUpdateDto: { - ids, - removeParent: true, - }, - }); - } catch (error) { - handleError(error, $t('errors.failed_to_unstack_assets')); +export const deleteStack = async (stackIds: string[]) => { + const ids = [...new Set(stackIds)]; + if (ids.length === 0) { return; } - for (const asset of assets) { - asset.stackParentId = null; - asset.stackCount = null; - asset.stack = []; + + const $t = get(t); + + try { + const stacks = await Promise.all(ids.map((id) => getStack({ id }))); + const count = stacks.reduce((sum, stack) => sum + stack.assets.length, 0); + + await deleteStacks({ bulkIdsDto: { ids: [...ids] } }); + + notificationController.show({ + type: NotificationType.Info, + message: $t('unstacked_assets_count', { values: { count } }), + }); + + const assets = stacks.flatMap((stack) => stack.assets); + for (const asset of assets) { + asset.stack = null; + } + + return assets; + } catch (error) { + handleError(error, $t('errors.failed_to_unstack_assets')); } - notificationController.show({ - type: NotificationType.Info, - message: $t('unstacked_assets_count', { values: { count: assets.length } }), - }); - return assets; }; export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => { @@ -419,7 +454,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt try { for (const bucket of assetStore.buckets) { - await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + await assetStore.loadBucket(bucket.bucketDate); if (!get(isSelectingAllAssets)) { break; // Cancelled diff --git a/web/src/lib/utils/idle-callback-support.ts b/web/src/lib/utils/idle-callback-support.ts new file mode 100644 index 0000000000000..0f7f0600849f8 --- /dev/null +++ b/web/src/lib/utils/idle-callback-support.ts @@ -0,0 +1,20 @@ +interface RequestIdleCallback { + didTimeout?: boolean; + timeRemaining?(): DOMHighResTimeStamp; +} +interface RequestIdleCallbackOptions { + timeout?: number; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function fake_requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, _?: RequestIdleCallbackOptions) { + const start = Date.now(); + return setTimeout(cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) }), 100); +} + +function fake_cancelIdleCallback(id: number) { + return clearTimeout(id); +} + +export const idleCB = window.requestIdleCallback || fake_requestIdleCallback; +export const cancelIdleCB = window.cancelIdleCallback || fake_cancelIdleCallback; diff --git a/web/src/lib/utils/keyed-priority-queue.ts b/web/src/lib/utils/keyed-priority-queue.ts new file mode 100644 index 0000000000000..2483b22c6d538 --- /dev/null +++ b/web/src/lib/utils/keyed-priority-queue.ts @@ -0,0 +1,50 @@ +export class KeyedPriorityQueue { + private items: { key: K; value: T; priority: number }[] = []; + private set: Set = new Set(); + + clear() { + this.items = []; + this.set.clear(); + } + + remove(key: K) { + const removed = this.set.delete(key); + if (removed) { + const idx = this.items.findIndex((i) => i.key === key); + if (idx >= 0) { + this.items.splice(idx, 1); + } + } + return removed; + } + + push(key: K, value: T, priority: number) { + if (this.set.has(key)) { + return this.length; + } + for (let i = 0; i < this.items.length; i++) { + if (this.items[i].priority > priority) { + this.set.add(key); + this.items.splice(i, 0, { key, value, priority }); + return this.length; + } + } + this.set.add(key); + return this.items.push({ key, value, priority }); + } + + shift() { + let item = this.items.shift(); + while (item) { + if (this.set.has(item.key)) { + this.set.delete(item.key); + return item; + } + item = this.items.shift(); + } + } + + get length() { + return this.set.size; + } +} diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index 4d5660f1737ff..304376b347e58 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -5,6 +5,9 @@ import { getAssetInfo } from '@immich/sdk'; import type { NavigationTarget } from '@sveltejs/kit'; import { get } from 'svelte/store'; +export type AssetGridRouteSearchParams = { + at: string | null | undefined; +}; export const isExternalUrl = (url: string): boolean => { return new URL(url, window.location.href).origin !== window.location.origin; }; @@ -33,17 +36,38 @@ function currentUrlWithoutAsset() { export function currentUrlReplaceAssetId(assetId: string) { const $page = get(page); + const params = new URLSearchParams($page.url.search); + // always remove the assetGridScrollTargetParams + params.delete('at'); + const searchparams = params.size > 0 ? '?' + params.toString() : ''; // this contains special casing for the /photos/:assetId photos route, which hangs directly // off / instead of a subpath, unlike every other asset-containing route. return isPhotosRoute($page.route.id) - ? `${AppRoute.PHOTOS}/${assetId}${$page.url.search}` - : `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${$page.url.search}`; + ? `${AppRoute.PHOTOS}/${assetId}${searchparams}` + : `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${searchparams}`; +} + +function replaceScrollTarget(url: string, searchParams?: AssetGridRouteSearchParams | null) { + const $page = get(page); + const parsed = new URL(url, $page.url); + + const { at: assetId } = searchParams || { at: null }; + + if (!assetId) { + return parsed.pathname; + } + + const params = new URLSearchParams($page.url.search); + if (assetId) { + params.set('at', assetId); + } + return parsed.pathname + '?' + params.toString(); } function currentUrl() { const $page = get(page); const current = $page.url; - return current.pathname + current.search; + return current.pathname + current.search + current.hash; } interface Route { @@ -55,24 +79,58 @@ interface Route { interface AssetRoute extends Route { targetRoute: 'current'; - assetId: string | null; + assetId: string | null | undefined; } +interface AssetGridRoute extends Route { + targetRoute: 'current'; + assetId: string | null | undefined; + assetGridRouteSearchParams: AssetGridRouteSearchParams | null | undefined; +} + +type ImmichRoute = AssetRoute | AssetGridRoute; + +type NavOptions = { + /* navigate even if url is the same */ + forceNavigate?: boolean | undefined; + replaceState?: boolean | undefined; + noScroll?: boolean | undefined; + keepFocus?: boolean | undefined; + invalidateAll?: boolean | undefined; + state?: App.PageState | undefined; +}; function isAssetRoute(route: Route): route is AssetRoute { return route.targetRoute === 'current' && 'assetId' in route; } -async function navigateAssetRoute(route: AssetRoute) { +function isAssetGridRoute(route: Route): route is AssetGridRoute { + return route.targetRoute === 'current' && 'assetId' in route && 'assetGridRouteSearchParams' in route; +} + +async function navigateAssetRoute(route: AssetRoute, options?: NavOptions) { const { assetId } = route; const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset(); - if (next !== currentUrl()) { - await goto(next, { replaceState: false }); + const current = currentUrl(); + if (next !== current || options?.forceNavigate) { + await goto(next, options); } } -export function navigate(change: T): Promise { - if (isAssetRoute(change)) { - return navigateAssetRoute(change); +async function navigateAssetGridRoute(route: AssetGridRoute, options?: NavOptions) { + const { assetId, assetGridRouteSearchParams: assetGridScrollTarget } = route; + const assetUrl = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset(); + const next = replaceScrollTarget(assetUrl, assetGridScrollTarget); + const current = currentUrl(); + if (next !== current || options?.forceNavigate) { + await goto(next, options); + } +} + +export function navigate(change: ImmichRoute, options?: NavOptions): Promise { + if (isAssetGridRoute(change)) { + return navigateAssetGridRoute(change, options); + } else if (isAssetRoute(change)) { + return navigateAssetRoute(change, options); } // future navigation requests here throw `Invalid navigation: ${JSON.stringify(change)}`; diff --git a/web/src/lib/utils/person.ts b/web/src/lib/utils/person.ts index 79f9284d8aa71..0b30556516cb2 100644 --- a/web/src/lib/utils/person.ts +++ b/web/src/lib/utils/person.ts @@ -28,5 +28,5 @@ export const searchNameLocal = ( }; export const getPersonNameWithHiddenValue = derived(t, ($t) => { - return (name: string, isHidden: boolean) => $t('person_hidden', { values: { name: name, hidden: isHidden } }); + return (name: string, isHidden: boolean) => $t('person_hidden', { values: { name, hidden: isHidden } }); }); diff --git a/web/src/lib/utils/priority-queue.ts b/web/src/lib/utils/priority-queue.ts new file mode 100644 index 0000000000000..6b08ffe7ad72d --- /dev/null +++ b/web/src/lib/utils/priority-queue.ts @@ -0,0 +1,21 @@ +export class PriorityQueue { + private items: { value: T; priority: number }[] = []; + + push(value: T, priority: number) { + for (let i = 0; i < this.items.length; i++) { + if (this.items[i].priority > priority) { + this.items.splice(i, 0, { value, priority }); + return this.length; + } + } + return this.items.push({ value, priority }); + } + + shift() { + return this.items.shift(); + } + + get length() { + return this.items.length; + } +} diff --git a/web/src/lib/utils/server.ts b/web/src/lib/utils/server.ts new file mode 100644 index 0000000000000..d2c5ab185119c --- /dev/null +++ b/web/src/lib/utils/server.ts @@ -0,0 +1,21 @@ +import { retrieveServerConfig } from '$lib/stores/server-config.store'; +import { initLanguage } from '$lib/utils'; +import { defaults } from '@immich/sdk'; +import { memoize } from 'lodash-es'; + +type fetchType = typeof fetch; + +export function initSDK(fetch: fetchType) { + // set event.fetch on the fetch-client used by @immich/sdk + // https://kit.svelte.dev/docs/load#making-fetch-requests + // https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options + defaults.fetch = fetch; +} + +async function _init(fetch: fetchType) { + initSDK(fetch); + await initLanguage(); + await retrieveServerConfig(); +} + +export const init = memoize(_init, () => 'singlevalue'); diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 76a0d1b5cb298..541ebea7f5444 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,9 +1,38 @@ +import type { AssetBucket } from '$lib/stores/assets.store'; import { locale } from '$lib/stores/preferences.store'; import type { AssetResponseDto } from '@immich/sdk'; -import { groupBy, sortBy } from 'lodash-es'; +import type createJustifiedLayout from 'justified-layout'; +import { groupBy, memoize, sortBy } from 'lodash-es'; import { DateTime } from 'luxon'; import { get } from 'svelte/store'; +export type DateGroup = { + date: DateTime; + groupTitle: string; + assets: AssetResponseDto[]; + height: number; + heightActual: boolean; + intersecting: boolean; + geometry: Geometry; + bucket: AssetBucket; +}; +export type ScrubberListener = ( + bucketDate: string | undefined, + overallScrollPercent: number, + bucketScrollPercent: number, +) => void | Promise; +export type ScrollTargetListener = ({ + bucket, + dateGroup, + asset, + offset, +}: { + bucket: AssetBucket; + dateGroup: DateGroup; + asset: AssetResponseDto; + offset: number; +}) => void; + export const fromLocalDateTime = (localDateTime: string) => DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) }); @@ -48,20 +77,48 @@ export function formatGroupTitle(_date: DateTime): string { return date.toLocaleString(groupDateFormat); } -export function splitBucketIntoDateGroups( - assets: AssetResponseDto[], - locale: string | undefined, -): AssetResponseDto[][] { - const grouped = groupBy(assets, (asset) => +type Geometry = ReturnType & { + containerWidth: number; +}; + +function emptyGeometry() { + return { + containerWidth: 0, + containerHeight: 0, + widowCount: 0, + boxes: [], + }; +} + +const formatDateGroupTitle = memoize(formatGroupTitle); + +export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] { + const grouped = groupBy(bucket.assets, (asset) => fromLocalDateTime(asset.localDateTime).toLocaleString(groupDateFormat, { locale }), ); - return sortBy(grouped, (group) => assets.indexOf(group[0])); + const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0])); + return sorted.map((group) => { + const date = fromLocalDateTime(group[0].localDateTime).startOf('day'); + return { + date, + groupTitle: formatDateGroupTitle(date), + assets: group, + height: 0, + heightActual: false, + intersecting: false, + geometry: emptyGeometry(), + bucket, + }; + }); } export type LayoutBox = { + aspectRatio: number; top: number; - left: number; width: number; + height: number; + left: number; + forcedAspectRatio?: boolean; }; export function calculateWidth(boxes: LayoutBox[]): number { @@ -71,6 +128,14 @@ export function calculateWidth(boxes: LayoutBox[]): number { width = box.left + box.width; } } - return width; } + +export function findTotalOffset(element: HTMLElement, stop: HTMLElement) { + let offset = 0; + while (element.offsetParent && element !== stop) { + offset += element.offsetTop; + element = element.offsetParent as HTMLElement; + } + return offset; +} diff --git a/web/src/lib/utils/tree-utils.ts b/web/src/lib/utils/tree-utils.ts new file mode 100644 index 0000000000000..13fb6c1605c5b --- /dev/null +++ b/web/src/lib/utils/tree-utils.ts @@ -0,0 +1,23 @@ +export interface RecursiveObject { + [key: string]: RecursiveObject; +} + +export const normalizeTreePath = (path: string) => path.replace(/^\//, '').replace(/\/$/, ''); + +export function buildTree(paths: string[]) { + const root: RecursiveObject = {}; + + paths.sort(); + + for (const path of paths) { + const parts = path.split('/'); + let current = root; + for (const part of parts) { + if (!current[part]) { + current[part] = {}; + } + current = current[part]; + } + } + return root; +} diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts new file mode 100644 index 0000000000000..e21c30de77b2a --- /dev/null +++ b/web/src/lib/utils/tunables.ts @@ -0,0 +1,63 @@ +function getBoolean(string: string | null, fallback: boolean) { + if (string === null) { + return fallback; + } + return 'true' === string; +} +function getNumber(string: string | null, fallback: number) { + if (string === null) { + return fallback; + } + return Number.parseInt(string); +} +function getFloat(string: string | null, fallback: number) { + if (string === null) { + return fallback; + } + return Number.parseFloat(string); +} +export const TUNABLES = { + SCROLL_TASK_QUEUE: { + TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25), + TRICKLE_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5), + TRICKLE_ACCELERATED_MIN_DELAY: getNumber( + localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY'), + 8, + ), + TRICKLE_ACCELERATED_MAX_DELAY: getNumber( + localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY'), + 2000, + ), + DRAIN_MAX_TASKS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS'), 15), + DRAIN_MAX_TASKS_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS'), 16), + MIN_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.MIN_DELAY_MS')!, 200), + CHECK_INTERVAL_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS'), 16), + }, + INTERSECTION_OBSERVER_QUEUE: { + DRAIN_MAX_TASKS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.DRAIN_MAX_TASKS'), 15), + THROTTLE_MS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE_MS'), 16), + THROTTLE: getBoolean(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE'), true), + }, + ASSET_GRID: { + NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false), + }, + BUCKET: { + PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2), + INTERSECTION_ROOT_TOP: localStorage.getItem('BUCKET.INTERSECTION_ROOT_TOP') || '300%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('BUCKET.INTERSECTION_ROOT_BOTTOM') || '300%', + }, + DATEGROUP: { + PRIORITY: getNumber(localStorage.getItem('DATEGROUP.PRIORITY'), 4), + INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false), + INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%', + }, + THUMBNAIL: { + PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8), + INTERSECTION_ROOT_TOP: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_TOP') || '250%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_BOTTOM') || '250%', + }, + IMAGE_THUMBNAIL: { + THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150), + }, +}; diff --git a/web/src/routes/(user)/+layout.svelte b/web/src/routes/(user)/+layout.svelte index 23f38b86f4ae2..bf24d0e7e48c9 100644 --- a/web/src/routes/(user)/+layout.svelte +++ b/web/src/routes/(user)/+layout.svelte @@ -1,10 +1,10 @@ diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9e670f714cae7..2c3f058e14daf 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -38,12 +38,18 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; - import { user } from '$lib/stores/user.store'; + import { preferences, user } from '$lib/stores/user.store'; import { handlePromiseError } from '$lib/utils'; import { downloadAlbum } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; - import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation'; + import { + isAlbumsRoute, + isPeopleRoute, + isSearchRoute, + navigate, + type AssetGridRouteSearchParams, + } from '$lib/utils/navigation'; import { AlbumUserRole, AssetOrder, @@ -76,14 +82,18 @@ } from '@mdi/js'; import { fly } from 'svelte/transition'; import type { PageData } from './$types'; - import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; + import { confirmAlbumDelete } from '$lib/utils/album-utils'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; export let data: PageData; - let { isViewing: showAssetViewer, setAsset } = assetViewingStore; + let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore; let { slideshowState, slideshowNavigation } = slideshowStore; + let oldAt: AssetGridRouteSearchParams | null | undefined; + $: album = data.album; $: albumId = album.id; $: albumKey = `${albumId}_${albumOrder}`; @@ -244,7 +254,7 @@ } if (viewMode === ViewMode.SELECT_ASSETS) { - handleCloseSelectAssets(); + await handleCloseSelectAssets(); return; } if (viewMode === ViewMode.LINK_SHARING) { @@ -282,27 +292,44 @@ const count = results.filter(({ success }) => success).length; notificationController.show({ type: NotificationType.Info, - message: $t('assets_added_count', { values: { count: count } }), + message: $t('assets_added_count', { values: { count } }), }); await refreshAlbum(); timelineInteractionStore.clearMultiselect(); viewMode = ViewMode.VIEW; + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { replaceState: true, forceNavigate: true }, + ); } catch (error) { handleError(error, $t('errors.error_adding_assets_to_album')); } }; - const handleCloseSelectAssets = () => { + const setModeToView = async () => { viewMode = ViewMode.VIEW; + assetStore.destroy(); + assetStore = new AssetStore({ albumId, order: albumOrder }); + timelineStore.destroy(); + timelineStore = new AssetStore({ isArchived: false }, albumId); + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } }, + { replaceState: true, forceNavigate: true }, + ); + oldAt = null; + }; + + const handleCloseSelectAssets = async () => { timelineInteractionStore.clearMultiselect(); + await setModeToView(); }; const handleSelectFromComputer = async () => { await openFileUploadDialog({ albumId: album.id }); timelineInteractionStore.clearMultiselect(); - viewMode = ViewMode.VIEW; + await setModeToView(); }; const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => { @@ -339,9 +366,7 @@ }; const handleRemoveAlbum = async () => { - const isConfirmed = await dialogController.show({ - prompt: $t('album_delete_confirmation', { values: { album: album.albumName } }), - }); + const isConfirmed = await confirmAlbumDelete(album); if (!isConfirmed) { viewMode = ViewMode.VIEW; @@ -395,11 +420,16 @@ } }; - onNavigate(async () => { - if (album.assetCount === 0 && !album.albumName) { + onNavigate(async ({ to }) => { + if (!isAlbumsRoute(to?.route.id) && album.assetCount === 0 && !album.albumName) { await deleteAlbum(album); } }); + + onDestroy(() => { + assetStore.destroy(); + timelineStore.destroy(); + });
@@ -429,6 +459,11 @@ {/if} assetStore.triggerUpdate()} /> {/if} + + {#if $preferences.tags.enabled && isAllUserOwned} + + {/if} + {#if isOwned || isAllUserOwned} {/if} @@ -444,7 +479,14 @@ {#if isEditor} (viewMode = ViewMode.SELECT_ASSETS)} + on:click={async () => { + viewMode = ViewMode.SELECT_ASSETS; + oldAt = { at: $gridScrollTarget?.at }; + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } }, + { replaceState: true }, + ); + }} icon={mdiImagePlusOutline} /> {/if} @@ -530,12 +572,14 @@ {#key albumKey} {#if viewMode === ViewMode.SELECT_ASSETS} {:else} (albumOrder = order)} + onChangeOrder={async (order) => { + albumOrder = order; + await setModeToView(); + }} on:close={() => (viewMode = ViewMode.VIEW)} on:toggleEnableActivity={handleToggleEnableActivity} on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6e3fb4cb28018..2ce13093513cc 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -17,6 +17,7 @@ import type { PageData } from './$types'; import { mdiPlus, mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -25,6 +26,10 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite); + + onDestroy(() => { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -45,7 +50,7 @@ {/if} - + diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 49af165ac99c7..13e70c9161540 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -19,6 +19,7 @@ import type { PageData } from './$types'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -27,6 +28,10 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived); + + onDestroy(() => { + assetStore.destroy(); + }); @@ -50,7 +55,7 @@ {/if} - + diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000000..1ffa64d3a3213 --- /dev/null +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,81 @@ + + + + +
+
{$t('explorer').toUpperCase()}
+
+ +
+
+
+ + + +
+ + + + {#if data.pathAssets && data.pathAssets.length > 0} +
+ +
+ {/if} +
+
diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000000..41800c1a7df89 --- /dev/null +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,42 @@ +import { QueryParameter } from '$lib/constants'; +import { foldersStore } from '$lib/stores/folders.store'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + await foldersStore.fetchUniquePaths(); + const { uniquePaths } = get(foldersStore); + + let pathAssets = null; + + const path = url.searchParams.get(QueryParameter.PATH); + if (path) { + await foldersStore.fetchAssetsByPath(path); + const { assets } = get(foldersStore); + pathAssets = assets[path] || null; + } + + let tree = buildTree(uniquePaths || []); + const parts = normalizeTreePath(path || '').split('/'); + for (const part of parts) { + tree = tree?.[part]; + } + + return { + asset, + path, + currentFolders: Object.keys(tree || {}), + pathAssets, + meta: { + title: $t('folders'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3eb65ca1bdb64..0ea0ed18bb733 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -124,7 +124,10 @@ showNavigation={viewingAssets.length > 1} on:next={navigateNext} on:previous={navigatePrevious} - on:close={() => assetViewingStore.showAssetViewer(false)} + on:close={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} isShared={false} /> {/await} diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 83e2ba3c1f24a..b580c4faa5454 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -23,6 +23,7 @@ onDestroy(() => { assetInteractionStore.clearMultiselect(); + assetStore.destroy(); }); @@ -45,5 +46,5 @@ {/if} - +

diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 02afe7f6106d1..26e803deb6fd4 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -52,7 +52,7 @@ mdiEyeOutline, mdiPlus, } from '@mdi/js'; - import { onMount } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import type { PageData } from './$types'; import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; @@ -155,6 +155,7 @@ } if (previousPersonId !== data.person.id) { handlePromiseError(updateAssetCount()); + assetStore.destroy(); assetStore = new AssetStore({ isArchived: false, personId: data.person.id, @@ -344,6 +345,10 @@ await goto($page.url); } }; + + onDestroy(() => { + assetStore.destroy(); + }); {#if viewMode === ViewMode.UNASSIGN_ASSETS} @@ -442,6 +447,7 @@
{#key refreshAssetGrid} { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -75,6 +81,9 @@ assetStore.removeAssets(assetIds)} /> + {#if $preferences.tags.enabled} + + {/if} assetStore.removeAssets(assetIds)} />
@@ -84,6 +93,7 @@ { if ($showAssetViewer) { @@ -70,6 +73,13 @@ $preventRaceConditionSearchBar = false; }; + // save and restore scroll position + afterUpdate(() => { + if (scrollY) { + scrollYHistory = scrollY; + } + }); + afterNavigate(({ from }) => { // Prevent setting previousRoute to the current page. if (from?.url && from.route.id !== $page.route.id) { @@ -84,6 +94,14 @@ if (isAlbumsRoute(route)) { previousRoute = AppRoute.EXPLORE; } + + tick() + .then(() => { + window.scrollTo(0, scrollYHistory); + }) + .catch(() => { + // do nothing + }); }); let selectedAssets: Set = new Set(); @@ -203,7 +221,7 @@ } - +
{#if isMultiSelectionMode} @@ -291,7 +309,7 @@ diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5ebb0e294cbc7..f4fac282bae76 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,8 +11,13 @@ import type { PageData } from './$types'; import { setSharedLink } from '$lib/utils'; import { t } from 'svelte-i18n'; + import { navigate } from '$lib/utils/navigation'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { tick } from 'svelte'; export let data: PageData; + + let { gridScrollTarget } = assetViewingStore; let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data; let { title, description } = meta; let isOwned = $user ? $user.id === sharedLink?.userId : false; @@ -29,6 +34,11 @@ description = sharedLink.description || $t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } }); + await tick(); + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { forceNavigate: true, replaceState: true }, + ); } catch (error) { handleError(error, $t('errors.unable_to_get_shared_link')); } diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000000..30f17da15d06e --- /dev/null +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,229 @@ + + + + +
+
{$t('explorer').toUpperCase()}
+
+ +
+
+
+ +
+ +
+ + +
+
+ + {#if pathSegments.length > 0 && tag} + +
+ + +
+
+ +
+ + +
+
+ {/if} +
+ + + +
+ {#key $page.url.href} + {#if tag} + + + + {:else} + + {/if} + {/key} +
+
+ +{#if isNewOpen} + +
+

+ {$t('create_tag_description')} +

+
+ +
+
+ +
+
+ + + + +
+{/if} + +{#if isEditOpen} + +
+
+ +
+
+ + + + +
+{/if} diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000000..23846e57c43d3 --- /dev/null +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,32 @@ +import { QueryParameter } from '$lib/constants'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; +import { getAllTags } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + const path = url.searchParams.get(QueryParameter.PATH); + const tags = await getAllTags(); + const tree = buildTree(tags.map((tag) => tag.value)); + let currentTree = tree; + const parts = normalizeTreePath(path || '').split('/'); + for (const part of parts) { + currentTree = currentTree?.[part]; + } + + return { + tags, + asset, + path, + children: Object.keys(currentTree || {}), + meta: { + title: $t('tags'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2907a542b30f7..27ad5bb3f072a 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -25,6 +25,7 @@ import { handlePromiseError } from '$lib/utils'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -84,6 +85,10 @@ handleError(error, $t('errors.unable_to_restore_trash')); } }; + + onDestroy(() => { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -111,7 +116,7 @@ - +

{$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}

diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index e82605d83ef9a..23e8fd3ff191e 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -1,106 +1,6 @@ -
-
-
- - - -
-
- -
-
-
-
-
-

- 🚨 {$t('error_title')} -

-
- handleCopy()} - /> -
-
- -
- -
-
-

{$page.error?.message} ({$page.error?.code})

- {#if $page.error?.stack} - -
{$page.error?.stack || 'No stack'}
- {/if} -
-
- -
- - -
-
-
-
-
+ diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index d086129d7fde3..b7335dea595d8 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -10,16 +10,19 @@ import VersionAnnouncementBox from '$lib/components/shared-components/version-announcement-box.svelte'; import { Theme } from '$lib/constants'; import { colorTheme, handleToggleTheme, type ThemeSetting } from '$lib/stores/preferences.store'; - import { loadConfig, serverConfig } from '$lib/stores/server-config.store'; + + import { serverConfig } from '$lib/stores/server-config.store'; + import { user } from '$lib/stores/user.store'; import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket'; - import { setKey } from '$lib/utils'; - import { handleError } from '$lib/utils/handle-error'; + import { copyToClipboard, setKey } from '$lib/utils'; import { onDestroy, onMount } from 'svelte'; import '../app.css'; import { isAssetViewerRoute, isSharedLinkRoute } from '$lib/utils/navigation'; import DialogWrapper from '$lib/components/shared-components/dialog/dialog-wrapper.svelte'; import { t } from 'svelte-i18n'; + import Error from '$lib/components/error.svelte'; + import { shortcut } from '$lib/actions/shortcut'; let showNavigationLoadingBar = false; $: changeTheme($colorTheme); @@ -32,8 +35,7 @@ const changeTheme = (theme: ThemeSetting) => { if (theme.system) { - theme.value = - window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT; + theme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT; } if (theme.value === Theme.LIGHT) { @@ -49,7 +51,13 @@ } }; + const getMyImmichLink = () => { + return new URL($page.url.pathname + $page.url.search, 'https://my.immich.app'); + }; + onMount(() => { + const element = document.querySelector('#stencil'); + element?.remove(); // if the browser theme changes, changes the Immich theme too window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleChangeTheme); }); @@ -72,14 +80,6 @@ afterNavigate(() => { showNavigationLoadingBar = false; }); - - onMount(async () => { - try { - await loadConfig(); - } catch (error) { - handleError(error, $t('errors.unable_to_connect_to_server')); - } - }); @@ -123,7 +123,18 @@ - + copyToClipboard(getMyImmichLink().toString()), + }} +/> + +{#if $page.data.error} + +{:else} + +{/if} {#if showNavigationLoadingBar} diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts index e8f665e0e44af..b5edece09e58a 100644 --- a/web/src/routes/+layout.ts +++ b/web/src/routes/+layout.ts @@ -1,19 +1,19 @@ -import { initApp } from '$lib/utils'; -import { defaults } from '@immich/sdk'; +import { init } from '$lib/utils/server'; import type { LayoutLoad } from './$types'; export const ssr = false; export const csr = true; export const load = (async ({ fetch }) => { - // set event.fetch on the fetch-client used by @immich/sdk - // https://kit.svelte.dev/docs/load#making-fetch-requests - // https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options - defaults.fetch = fetch; - - await initApp(); + let error; + try { + await init(fetch); + } catch (initError) { + error = initError; + } return { + error, meta: { title: 'Immich', }, diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts index f9897336af75c..0f3a7377d2644 100644 --- a/web/src/routes/+page.ts +++ b/web/src/routes/+page.ts @@ -1,23 +1,35 @@ import { AppRoute } from '$lib/constants'; +import { serverConfig } from '$lib/stores/server-config.store'; import { getFormatter } from '$lib/utils/i18n'; -import { getServerConfig } from '@immich/sdk'; +import { init } from '$lib/utils/server'; + import { redirect } from '@sveltejs/kit'; +import { get } from 'svelte/store'; import { loadUser } from '../lib/utils/auth'; import type { PageLoad } from './$types'; export const ssr = false; export const csr = true; -export const load = (async () => { - const authenticated = await loadUser(); - if (authenticated) { - redirect(302, AppRoute.PHOTOS); - } +export const load = (async ({ fetch }) => { + try { + await init(fetch); + const authenticated = await loadUser(); + if (authenticated) { + redirect(302, AppRoute.PHOTOS); + } - const { isInitialized } = await getServerConfig(); - if (isInitialized) { - // Redirect to login page if there exists an admin account (i.e. server is initialized) - redirect(302, AppRoute.AUTH_LOGIN); + const { isInitialized } = get(serverConfig); + if (isInitialized) { + // Redirect to login page if there exists an admin account (i.e. server is initialized) + redirect(302, AppRoute.AUTH_LOGIN); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (redirectError: any) { + if (redirectError?.status === 302) { + throw redirectError; + } } const $t = await getFormatter(); diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 64b104624b6a9..74db5628ba3a6 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -329,17 +329,21 @@ {:else}{owner[index].name}{/if} - - {#if totalCount[index] == undefined} - + + {#if totalCount[index] == undefined} - - {:else} - + {:else} {totalCount[index].toLocaleString($locale)} - - {diskUsage[index]} {diskUsageUnit[index]} - {/if} + {/if} + + + {#if diskUsage[index] == undefined} + + {:else} + {diskUsage[index]} + {diskUsageUnit[index]} + {/if} + goto(AppRoute.PHOTOS, { invalidateAll: true })} - onFirstLogin={() => goto(AppRoute.AUTH_CHANGE_PASSWORD)} - onOnboarding={() => goto(AppRoute.AUTH_ONBOARDING)} + onSuccess={async () => await goto(AppRoute.PHOTOS, { invalidateAll: true })} + onFirstLogin={async () => await goto(AppRoute.AUTH_CHANGE_PASSWORD)} + onOnboarding={async () => await goto(AppRoute.AUTH_ONBOARDING)} /> {/if} diff --git a/web/src/routes/auth/login/+page.ts b/web/src/routes/auth/login/+page.ts index 427287c8eafb9..847992ab20098 100644 --- a/web/src/routes/auth/login/+page.ts +++ b/web/src/routes/auth/login/+page.ts @@ -1,12 +1,15 @@ import { AppRoute } from '$lib/constants'; +import { serverConfig } from '$lib/stores/server-config.store'; import { getFormatter } from '$lib/utils/i18n'; -import { defaults, getServerConfig } from '@immich/sdk'; + import { redirect } from '@sveltejs/kit'; +import { get } from 'svelte/store'; import type { PageLoad } from './$types'; -export const load = (async ({ fetch }) => { - defaults.fetch = fetch; - const { isInitialized } = await getServerConfig(); +export const load = (async ({ parent }) => { + await parent(); + const { isInitialized } = get(serverConfig); + if (!isInitialized) { // Admin not registered redirect(302, AppRoute.AUTH_REGISTER); diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index 0fe2c68c84c0a..ddb30d1b45eff 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -6,6 +6,7 @@ import OnboadingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte'; import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; + import { retrieveServerConfig } from '$lib/stores/server-config.store'; import { updateAdminOnboarding } from '@immich/sdk'; let index = 0; @@ -35,6 +36,7 @@ const handleDoneClicked = async () => { if (index >= onboardingSteps.length - 1) { await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } }); + await retrieveServerConfig(); await goto(AppRoute.PHOTOS); } else { index++; diff --git a/web/src/routes/auth/onboarding/+page.ts b/web/src/routes/auth/onboarding/+page.ts index 7bd307a3ee9ef..db16c8e51419e 100644 --- a/web/src/routes/auth/onboarding/+page.ts +++ b/web/src/routes/auth/onboarding/+page.ts @@ -1,11 +1,9 @@ -import { loadConfig } from '$lib/stores/server-config.store'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; export const load = (async () => { await authenticate({ admin: true }); - await loadConfig(); const $t = await getFormatter(); diff --git a/web/src/routes/auth/register/+page.ts b/web/src/routes/auth/register/+page.ts index 00574043c1381..88b56caa47b6a 100644 --- a/web/src/routes/auth/register/+page.ts +++ b/web/src/routes/auth/register/+page.ts @@ -1,11 +1,13 @@ import { AppRoute } from '$lib/constants'; +import { serverConfig } from '$lib/stores/server-config.store'; import { getFormatter } from '$lib/utils/i18n'; -import { getServerConfig } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; +import { get } from 'svelte/store'; import type { PageLoad } from './$types'; -export const load = (async () => { - const { isInitialized } = await getServerConfig(); +export const load = (async ({ parent }) => { + await parent(); + const { isInitialized } = get(serverConfig); if (isInitialized) { // Admin has been registered, redirect to login redirect(302, AppRoute.AUTH_LOGIN); diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index e76138fe59488..700b98c180e46 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -12,7 +12,6 @@ export const assetFactory = Sync.makeFactory({ originalPath: Sync.each(() => faker.system.filePath()), originalFileName: Sync.each(() => faker.system.fileName()), originalMimeType: Sync.each(() => faker.system.mimeType()), - resized: true, thumbhash: Sync.each(() => faker.string.alphanumeric(28)), fileCreatedAt: Sync.each(() => faker.date.past().toISOString()), fileModifiedAt: Sync.each(() => faker.date.past().toISOString()), @@ -25,5 +24,4 @@ export const assetFactory = Sync.makeFactory({ checksum: Sync.each(() => faker.string.alphanumeric(28)), isOffline: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()), - stackCount: null, }); diff --git a/web/static/dark_skeleton.png b/web/static/dark_skeleton.png new file mode 100644 index 0000000000000..2a115a849680b Binary files /dev/null and b/web/static/dark_skeleton.png differ diff --git a/web/static/light_skeleton.png b/web/static/light_skeleton.png new file mode 100644 index 0000000000000..22c7eae75473c Binary files /dev/null and b/web/static/light_skeleton.png differ