diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 231ad141e4..c4c522fe3f 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -96,7 +96,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7d07785de7..69ef63efe9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -50,7 +50,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3 + uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -63,7 +63,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3 + uses: github/codeql-action/autobuild@28deaeda66b76a05916b6923827895f2b14ab387 # v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -76,6 +76,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3 + uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9a65b05ace..1746c93bc7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -205,7 +205,7 @@ jobs: - name: Build and push image id: build - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 with: context: ${{ env.context }} file: ${{ env.file }} @@ -266,7 +266,7 @@ jobs: - build_and_push_ml steps: - name: Download digests - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: ${{ runner.temp }}/digests pattern: ml-digests-${{ matrix.device }}-* @@ -407,7 +407,7 @@ jobs: - name: Build and push image id: build - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 with: context: ${{ env.context }} file: ${{ env.file }} @@ -454,7 +454,7 @@ jobs: - build_and_push_server steps: - name: Download digests - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: ${{ runner.temp }}/digests pattern: server-digests-* diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index ece3bbd248..aaa1780657 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -72,4 +72,5 @@ jobs: with: name: docs-build-output path: docs/build/ + include-hidden-files: true retention-days: 1 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 2704246399..27957ca4a3 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -95,7 +95,7 @@ jobs: persist-credentials: false - name: Download APK - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: release-apk-signed diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 3efbc25de3..3a0b702210 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -105,12 +105,12 @@ jobs: actions: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: persist-credentials: false - name: Install the latest version of uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - name: Run zizmor 🌈 run: uvx zizmor --format=sarif . > results.sarif @@ -118,7 +118,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3 with: sarif_file: results.sarif category: zizmor diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2840a631eb..eb48c50fa9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -461,7 +461,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) # with: # python-version: 3.11 diff --git a/.vscode/settings.json b/.vscode/settings.json index 49692809bc..396755a634 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,45 +1,63 @@ { - "editor.formatOnSave": true, - "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.tabSize": 2, - "editor.formatOnSave": true - }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.tabSize": 2, - "editor.formatOnSave": true - }, "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.tabSize": 2, - "editor.formatOnSave": true - }, - "[svelte]": { - "editor.defaultFormatter": "svelte.svelte-vscode", + "editor.formatOnSave": true, "editor.tabSize": 2 }, - "svelte.enable-ts-plugin": true, - "eslint.validate": [ - "javascript", - "svelte" - ], - "typescript.preferences.importModuleSpecifier": "non-relative", "[dart]": { + "editor.defaultFormatter": "Dart-Code.dart-code", "editor.formatOnSave": true, "editor.selectionHighlight": false, "editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggestSelection": "first", "editor.tabCompletion": "onlySnippets", - "editor.wordBasedSuggestions": "off", - "editor.defaultFormatter": "Dart-Code.dart-code" + "editor.wordBasedSuggestions": "off" }, - "cSpell.words": [ - "immich" - ], + "[javascript]": { + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.removeUnusedImports": "explicit" + }, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, + "[svelte]": { + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.removeUnusedImports": "explicit" + }, + "editor.defaultFormatter": "svelte.svelte-vscode", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, + "[typescript]": { + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.removeUnusedImports": "explicit" + }, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, + "cSpell.words": ["immich"], + "editor.formatOnSave": true, + "eslint.validate": ["javascript", "svelte"], "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { - "*.ts": "${capture}.spec.ts,${capture}.mock.ts", - "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart" - } -} \ No newline at end of file + "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart", + "*.ts": "${capture}.spec.ts,${capture}.mock.ts" + }, + "svelte.enable-ts-plugin": true, + "typescript.preferences.importModuleSpecifier": "non-relative" +} diff --git a/Makefile b/Makefile index e15faa8051..1e7760ae68 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ e2e: prod: docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans +prod-down: + docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans + prod-scale: docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans diff --git a/cli/Dockerfile b/cli/Dockerfile index 356537213b..ce345c29a0 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.14.0-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS core +FROM node:22.15.0-alpine3.20@sha256:686b8892b69879ef5bfd6047589666933508f9a5451c67320df3070ba0e9807b 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/package-lock.json b/cli/package-lock.json index fe428b2714..cc8e88012f 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -647,9 +647,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -697,9 +697,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", - "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", + "version": "9.25.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz", + "integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==", "dev": true, "license": "MIT", "engines": { @@ -730,19 +730,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1380,17 +1367,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.30.1.tgz", - "integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz", + "integrity": "sha512-evaQJZ/J/S4wisevDvC1KFZkPzRetH8kYZbkgcTRyql3mcKsf+ZFDV1BVWUGTCAW5pQHoqn5gK5b8kn7ou9aFQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/type-utils": "8.30.1", - "@typescript-eslint/utils": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/type-utils": "8.31.0", + "@typescript-eslint/utils": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1410,16 +1397,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.30.1.tgz", - "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.0.tgz", + "integrity": "sha512-67kYYShjBR0jNI5vsf/c3WG4u+zDnCTHTPqVMQguffaWWFs7artgwKmfwdifl+r6XyM5LYLas/dInj2T0SgJyw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/typescript-estree": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/typescript-estree": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "debug": "^4.3.4" }, "engines": { @@ -1435,14 +1422,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.30.1.tgz", - "integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.0.tgz", + "integrity": "sha512-knO8UyF78Nt8O/B64i7TlGXod69ko7z6vJD9uhSlm0qkAbGeRUSudcm0+K/4CrRjrpiHfBCjMWlc08Vav1xwcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1" + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1453,14 +1440,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.30.1.tgz", - "integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.0.tgz", + "integrity": "sha512-DJ1N1GdjI7IS7uRlzJuEDCgDQix3ZVYVtgeWEyhyn4iaoitpMBX6Ndd488mXSx0xah/cONAkEaYyylDyAeHMHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.30.1", - "@typescript-eslint/utils": "8.30.1", + "@typescript-eslint/typescript-estree": "8.31.0", + "@typescript-eslint/utils": "8.31.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -1477,9 +1464,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.30.1.tgz", - "integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.0.tgz", + "integrity": "sha512-Ch8oSjVyYyJxPQk8pMiP2FFGYatqXQfQIaMp+TpuuLlDachRWpUAeEu1u9B/v/8LToehUIWyiKcA/w5hUFRKuQ==", "dev": true, "license": "MIT", "engines": { @@ -1491,14 +1478,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.30.1.tgz", - "integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.0.tgz", + "integrity": "sha512-xLmgn4Yl46xi6aDSZ9KkyfhhtnYI15/CvHbpOy/eR5NWhK/BK8wc709KKwhAR0m4ZKRP7h07bm4BWUYOCuRpQQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1544,16 +1531,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.30.1.tgz", - "integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.0.tgz", + "integrity": "sha512-qi6uPLt9cjTFxAb1zGNgTob4x9ur7xC6mHQJ8GwEzGMGE9tYniublmJaowOJ9V2jUzxrltTPfdG2nKlWsq0+Ww==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/typescript-estree": "8.30.1" + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/typescript-estree": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1568,13 +1555,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.30.1.tgz", - "integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.0.tgz", + "integrity": "sha512-QcGHmlRHWOl93o64ZUMNewCdwKGU6WItOU52H0djgNmn1EOrhVudrDzXz4OycCRSCPwFCDrE2iIt5vmuUdHxuQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", + "@typescript-eslint/types": "8.31.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -1586,9 +1573,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.1.tgz", - "integrity": "sha512-MgV6D2dhpD6Hp/uroUoAIvFqA8AuvXEFBC2eepG3WFc1pxTfdk1LEqqkWoWhjz+rytoqrnUUCdf6Lzco3iHkLQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.2.tgz", + "integrity": "sha512-XDdaDOeaTMAMYW7N63AqoK32sYUWbXnTkC6tEbVcu3RlU1bB9of32T+PGf8KZvxqLNqeXhafDFqCkwpf2+dyaQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1601,7 +1588,7 @@ "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", - "std-env": "^3.8.1", + "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, @@ -1609,8 +1596,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.1.1", - "vitest": "3.1.1" + "@vitest/browser": "3.1.2", + "vitest": "3.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1619,14 +1606,14 @@ } }, "node_modules/@vitest/expect": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", - "integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.2.tgz", + "integrity": "sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@vitest/spy": "3.1.2", + "@vitest/utils": "3.1.2", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -1635,13 +1622,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", - "integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.2.tgz", + "integrity": "sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", + "@vitest/spy": "3.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -1662,9 +1649,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz", + "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==", "dev": true, "license": "MIT", "dependencies": { @@ -1675,13 +1662,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", - "integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.2.tgz", + "integrity": "sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.1", + "@vitest/utils": "3.1.2", "pathe": "^2.0.3" }, "funding": { @@ -1689,13 +1676,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", - "integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.2.tgz", + "integrity": "sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.2", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -1704,9 +1691,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", - "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.2.tgz", + "integrity": "sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==", "dev": true, "license": "MIT", "dependencies": { @@ -1717,13 +1704,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz", + "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.2", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -2183,9 +2170,9 @@ "license": "MIT" }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -2254,20 +2241,20 @@ } }, "node_modules/eslint": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", - "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "version": "9.25.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.1.tgz", + "integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.13.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.24.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.25.1", + "@eslint/plugin-kit": "^0.2.8", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4197,15 +4184,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.30.1.tgz", - "integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.31.0.tgz", + "integrity": "sha512-u+93F0sB0An8WEAPtwxVhFby573E8ckdjwUUQUj9QA4v8JAvgtoDdIyYR3XFwFHq2W1KJ1AurwJCO+w+Y1ixyQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.30.1", - "@typescript-eslint/parser": "8.30.1", - "@typescript-eslint/utils": "8.30.1" + "@typescript-eslint/eslint-plugin": "8.31.0", + "@typescript-eslint/parser": "8.31.0", + "@typescript-eslint/utils": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4292,18 +4279,18 @@ } }, "node_modules/vite": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", - "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==", + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz", + "integrity": "sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.3", + "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", - "tinyglobby": "^0.2.12" + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -4367,9 +4354,9 @@ } }, "node_modules/vite-node": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", - "integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.2.tgz", + "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", "dev": true, "license": "MIT", "dependencies": { @@ -4438,31 +4425,32 @@ } }, "node_modules/vitest": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", - "integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.2.tgz", + "integrity": "sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.1.1", - "@vitest/mocker": "3.1.1", - "@vitest/pretty-format": "^3.1.1", - "@vitest/runner": "3.1.1", - "@vitest/snapshot": "3.1.1", - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@vitest/expect": "3.1.2", + "@vitest/mocker": "3.1.2", + "@vitest/pretty-format": "^3.1.2", + "@vitest/runner": "3.1.2", + "@vitest/snapshot": "3.1.2", + "@vitest/spy": "3.1.2", + "@vitest/utils": "3.1.2", "chai": "^5.2.0", "debug": "^4.4.0", - "expect-type": "^1.2.0", + "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", - "std-env": "^3.8.1", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.1", + "vite-node": "3.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4478,8 +4466,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.1", - "@vitest/ui": "3.1.1", + "@vitest/browser": "3.1.2", + "@vitest/ui": "3.1.2", "happy-dom": "*", "jsdom": "*" }, diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index af7e2c52a9..01be1ef247 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -116,7 +116,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1 + image: docker.io/valkey/valkey:8-bookworm@sha256:c855f98e09d558a0d7cc1a4e56473231206a4c54c0114ada9c485b47aeb92ec8 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 04ddbccbd8..c4fb086a09 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1 + image: docker.io/valkey/valkey:8-bookworm@sha256:c855f98e09d558a0d7cc1a4e56473231206a4c54c0114ada9c485b47aeb92ec8 healthcheck: test: redis-cli ping || exit 1 restart: always @@ -102,7 +102,7 @@ services: command: [ './run.sh', '-disable-reporting' ] ports: - 3000:3000 - image: grafana/grafana:11.6.0-ubuntu@sha256:fd8fa48213c624e1a95122f1d93abbf1cf1cbe85fc73212c1e599dbd76c63ff8 + image: grafana/grafana:11.6.1-ubuntu@sha256:6fc273288470ef499dd3c6b36aeade093170d4f608f864c5dd3a7fabeae77b50 volumes: - grafana-data:/var/lib/grafana diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 499673a383..b9fa6f8b02 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1 + image: docker.io/valkey/valkey:8-bookworm@sha256:c855f98e09d558a0d7cc1a4e56473231206a4c54c0114ada9c485b47aeb92ec8 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docs/static/.well-known/security.txt b/docs/static/.well-known/security.txt new file mode 100644 index 0000000000..5a8414c3e2 --- /dev/null +++ b/docs/static/.well-known/security.txt @@ -0,0 +1,5 @@ +Policy: https://github.com/immich-app/immich/blob/main/SECURITY.md +Contact: mailto:security@immich.app +Preferred-Languages: en +Expires: 2026-05-01T23:59:00.000Z +Canonical: https://immich.app/.well-known/security.txt diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index e05d7734ed..48c17c828b 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -34,7 +34,7 @@ services: - 2285:2285 redis: - image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 + image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 diff --git a/e2e/package-lock.json b/e2e/package-lock.json index af8117da2c..a61324beff 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -194,9 +194,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", + "integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==", "cpu": [ "ppc64" ], @@ -211,9 +211,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", - "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", + "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", "cpu": [ "arm" ], @@ -228,9 +228,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", - "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", + "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", "cpu": [ "arm64" ], @@ -245,9 +245,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", - "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz", + "integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==", "cpu": [ "x64" ], @@ -262,9 +262,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", - "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", + "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", "cpu": [ "arm64" ], @@ -279,9 +279,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", - "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", + "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", "cpu": [ "x64" ], @@ -296,9 +296,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", - "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz", + "integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==", "cpu": [ "arm64" ], @@ -313,9 +313,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", - "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", + "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", "cpu": [ "x64" ], @@ -330,9 +330,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", - "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", + "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", "cpu": [ "arm" ], @@ -347,9 +347,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", - "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", + "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", "cpu": [ "arm64" ], @@ -364,9 +364,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", - "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", + "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", "cpu": [ "ia32" ], @@ -381,9 +381,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", - "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz", + "integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==", "cpu": [ "loong64" ], @@ -398,9 +398,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", - "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", + "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", "cpu": [ "mips64el" ], @@ -415,9 +415,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", - "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", + "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", "cpu": [ "ppc64" ], @@ -432,9 +432,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", - "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz", + "integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==", "cpu": [ "riscv64" ], @@ -449,9 +449,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", - "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz", + "integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==", "cpu": [ "s390x" ], @@ -466,9 +466,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", - "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", + "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", "cpu": [ "x64" ], @@ -483,9 +483,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", - "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", + "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", "cpu": [ "arm64" ], @@ -500,9 +500,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", - "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz", + "integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==", "cpu": [ "x64" ], @@ -517,9 +517,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", - "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz", + "integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==", "cpu": [ "arm64" ], @@ -534,9 +534,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", - "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", + "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", "cpu": [ "x64" ], @@ -551,9 +551,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", + "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", "cpu": [ "x64" ], @@ -568,9 +568,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", - "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", + "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", "cpu": [ "arm64" ], @@ -585,9 +585,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", - "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", + "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", "cpu": [ "ia32" ], @@ -602,9 +602,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", - "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz", + "integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==", "cpu": [ "x64" ], @@ -686,9 +686,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -736,9 +736,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", - "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", + "version": "9.25.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz", + "integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==", "dev": true, "license": "MIT", "engines": { @@ -769,19 +769,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1098,9 +1085,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz", - "integrity": "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", + "integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==", "cpu": [ "arm" ], @@ -1112,9 +1099,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz", - "integrity": "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz", + "integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==", "cpu": [ "arm64" ], @@ -1126,9 +1113,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz", - "integrity": "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz", + "integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==", "cpu": [ "arm64" ], @@ -1140,9 +1127,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz", - "integrity": "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz", + "integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==", "cpu": [ "x64" ], @@ -1154,9 +1141,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz", - "integrity": "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz", + "integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==", "cpu": [ "arm64" ], @@ -1168,9 +1155,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz", - "integrity": "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz", + "integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==", "cpu": [ "x64" ], @@ -1182,9 +1169,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz", - "integrity": "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz", + "integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==", "cpu": [ "arm" ], @@ -1196,9 +1183,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz", - "integrity": "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz", + "integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==", "cpu": [ "arm" ], @@ -1210,9 +1197,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz", - "integrity": "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz", + "integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==", "cpu": [ "arm64" ], @@ -1224,9 +1211,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz", - "integrity": "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz", + "integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==", "cpu": [ "arm64" ], @@ -1238,9 +1225,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz", - "integrity": "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz", + "integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==", "cpu": [ "loong64" ], @@ -1252,9 +1239,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz", - "integrity": "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz", + "integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==", "cpu": [ "ppc64" ], @@ -1266,9 +1253,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz", - "integrity": "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz", + "integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==", "cpu": [ "riscv64" ], @@ -1280,9 +1267,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz", - "integrity": "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz", + "integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==", "cpu": [ "riscv64" ], @@ -1294,9 +1281,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz", - "integrity": "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz", + "integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==", "cpu": [ "s390x" ], @@ -1308,9 +1295,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz", - "integrity": "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz", + "integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==", "cpu": [ "x64" ], @@ -1322,9 +1309,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz", - "integrity": "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz", + "integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==", "cpu": [ "x64" ], @@ -1336,9 +1323,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz", - "integrity": "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz", + "integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==", "cpu": [ "arm64" ], @@ -1350,9 +1337,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz", - "integrity": "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz", + "integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==", "cpu": [ "ia32" ], @@ -1364,9 +1351,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz", - "integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz", + "integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==", "cpu": [ "x64" ], @@ -1613,9 +1600,9 @@ } }, "node_modules/@types/pg": { - "version": "8.11.13", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.13.tgz", - "integrity": "sha512-6kXByGkvRvwXLuyaWzsebs2du6+XuAB2CuMsuzP7uaihQahshVgSmB22Pmh0vQMkQ1h5+PZU0d+Di1o+WpVWJg==", + "version": "8.11.14", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.14.tgz", + "integrity": "sha512-qyD11E5R3u0eJmd1lB0WnWKXJGA7s015nyARWljfz5DcX83TKAIlY+QrmvzQTsbIe+hkiFtkyL2gHC6qwF6Fbg==", "dev": true, "license": "MIT", "dependencies": { @@ -1696,17 +1683,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.30.1.tgz", - "integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz", + "integrity": "sha512-evaQJZ/J/S4wisevDvC1KFZkPzRetH8kYZbkgcTRyql3mcKsf+ZFDV1BVWUGTCAW5pQHoqn5gK5b8kn7ou9aFQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/type-utils": "8.30.1", - "@typescript-eslint/utils": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/type-utils": "8.31.0", + "@typescript-eslint/utils": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1726,16 +1713,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.30.1.tgz", - "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.0.tgz", + "integrity": "sha512-67kYYShjBR0jNI5vsf/c3WG4u+zDnCTHTPqVMQguffaWWFs7artgwKmfwdifl+r6XyM5LYLas/dInj2T0SgJyw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/typescript-estree": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/typescript-estree": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "debug": "^4.3.4" }, "engines": { @@ -1751,14 +1738,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.30.1.tgz", - "integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.0.tgz", + "integrity": "sha512-knO8UyF78Nt8O/B64i7TlGXod69ko7z6vJD9uhSlm0qkAbGeRUSudcm0+K/4CrRjrpiHfBCjMWlc08Vav1xwcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1" + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1769,14 +1756,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.30.1.tgz", - "integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.0.tgz", + "integrity": "sha512-DJ1N1GdjI7IS7uRlzJuEDCgDQix3ZVYVtgeWEyhyn4iaoitpMBX6Ndd488mXSx0xah/cONAkEaYyylDyAeHMHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.30.1", - "@typescript-eslint/utils": "8.30.1", + "@typescript-eslint/typescript-estree": "8.31.0", + "@typescript-eslint/utils": "8.31.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -1793,9 +1780,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.30.1.tgz", - "integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.0.tgz", + "integrity": "sha512-Ch8oSjVyYyJxPQk8pMiP2FFGYatqXQfQIaMp+TpuuLlDachRWpUAeEu1u9B/v/8LToehUIWyiKcA/w5hUFRKuQ==", "dev": true, "license": "MIT", "engines": { @@ -1807,14 +1794,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.30.1.tgz", - "integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.0.tgz", + "integrity": "sha512-xLmgn4Yl46xi6aDSZ9KkyfhhtnYI15/CvHbpOy/eR5NWhK/BK8wc709KKwhAR0m4ZKRP7h07bm4BWUYOCuRpQQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1860,16 +1847,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.30.1.tgz", - "integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.0.tgz", + "integrity": "sha512-qi6uPLt9cjTFxAb1zGNgTob4x9ur7xC6mHQJ8GwEzGMGE9tYniublmJaowOJ9V2jUzxrltTPfdG2nKlWsq0+Ww==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/typescript-estree": "8.30.1" + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/typescript-estree": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1884,13 +1871,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.30.1.tgz", - "integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.0.tgz", + "integrity": "sha512-QcGHmlRHWOl93o64ZUMNewCdwKGU6WItOU52H0djgNmn1EOrhVudrDzXz4OycCRSCPwFCDrE2iIt5vmuUdHxuQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", + "@typescript-eslint/types": "8.31.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -1902,9 +1889,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.1.tgz", - "integrity": "sha512-MgV6D2dhpD6Hp/uroUoAIvFqA8AuvXEFBC2eepG3WFc1pxTfdk1LEqqkWoWhjz+rytoqrnUUCdf6Lzco3iHkLQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.2.tgz", + "integrity": "sha512-XDdaDOeaTMAMYW7N63AqoK32sYUWbXnTkC6tEbVcu3RlU1bB9of32T+PGf8KZvxqLNqeXhafDFqCkwpf2+dyaQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1917,7 +1904,7 @@ "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", - "std-env": "^3.8.1", + "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, @@ -1925,8 +1912,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.1.1", - "vitest": "3.1.1" + "@vitest/browser": "3.1.2", + "vitest": "3.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1935,14 +1922,14 @@ } }, "node_modules/@vitest/expect": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", - "integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.2.tgz", + "integrity": "sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@vitest/spy": "3.1.2", + "@vitest/utils": "3.1.2", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -1951,13 +1938,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", - "integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.2.tgz", + "integrity": "sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", + "@vitest/spy": "3.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -1978,9 +1965,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz", + "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==", "dev": true, "license": "MIT", "dependencies": { @@ -1991,13 +1978,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", - "integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.2.tgz", + "integrity": "sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.1", + "@vitest/utils": "3.1.2", "pathe": "^2.0.3" }, "funding": { @@ -2005,13 +1992,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", - "integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.2.tgz", + "integrity": "sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.2", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -2020,9 +2007,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", - "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.2.tgz", + "integrity": "sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==", "dev": true, "license": "MIT", "dependencies": { @@ -2033,13 +2020,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz", + "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.2", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -2915,9 +2902,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -2951,9 +2938,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", - "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", + "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2964,31 +2951,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.2", - "@esbuild/android-arm": "0.25.2", - "@esbuild/android-arm64": "0.25.2", - "@esbuild/android-x64": "0.25.2", - "@esbuild/darwin-arm64": "0.25.2", - "@esbuild/darwin-x64": "0.25.2", - "@esbuild/freebsd-arm64": "0.25.2", - "@esbuild/freebsd-x64": "0.25.2", - "@esbuild/linux-arm": "0.25.2", - "@esbuild/linux-arm64": "0.25.2", - "@esbuild/linux-ia32": "0.25.2", - "@esbuild/linux-loong64": "0.25.2", - "@esbuild/linux-mips64el": "0.25.2", - "@esbuild/linux-ppc64": "0.25.2", - "@esbuild/linux-riscv64": "0.25.2", - "@esbuild/linux-s390x": "0.25.2", - "@esbuild/linux-x64": "0.25.2", - "@esbuild/netbsd-arm64": "0.25.2", - "@esbuild/netbsd-x64": "0.25.2", - "@esbuild/openbsd-arm64": "0.25.2", - "@esbuild/openbsd-x64": "0.25.2", - "@esbuild/sunos-x64": "0.25.2", - "@esbuild/win32-arm64": "0.25.2", - "@esbuild/win32-ia32": "0.25.2", - "@esbuild/win32-x64": "0.25.2" + "@esbuild/aix-ppc64": "0.25.3", + "@esbuild/android-arm": "0.25.3", + "@esbuild/android-arm64": "0.25.3", + "@esbuild/android-x64": "0.25.3", + "@esbuild/darwin-arm64": "0.25.3", + "@esbuild/darwin-x64": "0.25.3", + "@esbuild/freebsd-arm64": "0.25.3", + "@esbuild/freebsd-x64": "0.25.3", + "@esbuild/linux-arm": "0.25.3", + "@esbuild/linux-arm64": "0.25.3", + "@esbuild/linux-ia32": "0.25.3", + "@esbuild/linux-loong64": "0.25.3", + "@esbuild/linux-mips64el": "0.25.3", + "@esbuild/linux-ppc64": "0.25.3", + "@esbuild/linux-riscv64": "0.25.3", + "@esbuild/linux-s390x": "0.25.3", + "@esbuild/linux-x64": "0.25.3", + "@esbuild/netbsd-arm64": "0.25.3", + "@esbuild/netbsd-x64": "0.25.3", + "@esbuild/openbsd-arm64": "0.25.3", + "@esbuild/openbsd-x64": "0.25.3", + "@esbuild/sunos-x64": "0.25.3", + "@esbuild/win32-arm64": "0.25.3", + "@esbuild/win32-ia32": "0.25.3", + "@esbuild/win32-x64": "0.25.3" } }, "node_modules/escalade": { @@ -3022,20 +3009,20 @@ } }, "node_modules/eslint": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", - "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "version": "9.25.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.1.tgz", + "integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.13.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.24.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.25.1", + "@eslint/plugin-kit": "^0.2.8", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -5235,15 +5222,15 @@ } }, "node_modules/pg": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", - "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", + "version": "8.15.5", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.15.5.tgz", + "integrity": "sha512-EpAhHFQc+aH9VfeffWIVC+XXk6lmAhS9W1FxtxcPXs94yxhrI1I6w/zkWfIOII/OkBv3Be04X3xMOj0kQ78l6w==", "dev": true, "license": "MIT", "dependencies": { - "pg-connection-string": "^2.7.0", - "pg-pool": "^3.8.0", - "pg-protocol": "^1.8.0", + "pg-connection-string": "^2.8.5", + "pg-pool": "^3.9.5", + "pg-protocol": "^1.9.5", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -5251,7 +5238,7 @@ "node": ">= 8.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.1.1" + "pg-cloudflare": "^1.2.5" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -5263,17 +5250,17 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", - "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz", + "integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==", "dev": true, "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", - "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.8.5.tgz", + "integrity": "sha512-Ni8FuZ8yAF+sWZzojvtLE2b03cqjO5jNULcHFfM9ZZ0/JXrgom5pBREbtnAw7oxsxJqHw9Nz/XWORUEL3/IFow==", "dev": true, "license": "MIT" }, @@ -5298,9 +5285,9 @@ } }, "node_modules/pg-pool": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", - "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.9.6.tgz", + "integrity": "sha512-rFen0G7adh1YmgvrmE5IPIqbb+IgEzENUm+tzm6MLLDSlPRoZVhzU1WdML9PV2W5GOdRA9qBKURlbt1OsXOsPw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5308,9 +5295,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", - "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.9.5.tgz", + "integrity": "sha512-DYTWtWpfd5FOro3UnAfwvhD8jh59r2ig8bPtc9H8Ds7MscE/9NYruUQWFAOuraRl29jwcT2kyMFQ3MxeaVjUhg==", "dev": true, "license": "MIT" }, @@ -5878,9 +5865,9 @@ } }, "node_modules/rollup": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz", - "integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz", + "integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==", "dev": true, "license": "MIT", "dependencies": { @@ -5894,26 +5881,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.40.0", - "@rollup/rollup-android-arm64": "4.40.0", - "@rollup/rollup-darwin-arm64": "4.40.0", - "@rollup/rollup-darwin-x64": "4.40.0", - "@rollup/rollup-freebsd-arm64": "4.40.0", - "@rollup/rollup-freebsd-x64": "4.40.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", - "@rollup/rollup-linux-arm-musleabihf": "4.40.0", - "@rollup/rollup-linux-arm64-gnu": "4.40.0", - "@rollup/rollup-linux-arm64-musl": "4.40.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", - "@rollup/rollup-linux-riscv64-gnu": "4.40.0", - "@rollup/rollup-linux-riscv64-musl": "4.40.0", - "@rollup/rollup-linux-s390x-gnu": "4.40.0", - "@rollup/rollup-linux-x64-gnu": "4.40.0", - "@rollup/rollup-linux-x64-musl": "4.40.0", - "@rollup/rollup-win32-arm64-msvc": "4.40.0", - "@rollup/rollup-win32-ia32-msvc": "4.40.0", - "@rollup/rollup-win32-x64-msvc": "4.40.0", + "@rollup/rollup-android-arm-eabi": "4.40.1", + "@rollup/rollup-android-arm64": "4.40.1", + "@rollup/rollup-darwin-arm64": "4.40.1", + "@rollup/rollup-darwin-x64": "4.40.1", + "@rollup/rollup-freebsd-arm64": "4.40.1", + "@rollup/rollup-freebsd-x64": "4.40.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.1", + "@rollup/rollup-linux-arm-musleabihf": "4.40.1", + "@rollup/rollup-linux-arm64-gnu": "4.40.1", + "@rollup/rollup-linux-arm64-musl": "4.40.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.1", + "@rollup/rollup-linux-riscv64-gnu": "4.40.1", + "@rollup/rollup-linux-riscv64-musl": "4.40.1", + "@rollup/rollup-linux-s390x-gnu": "4.40.1", + "@rollup/rollup-linux-x64-gnu": "4.40.1", + "@rollup/rollup-linux-x64-musl": "4.40.1", + "@rollup/rollup-win32-arm64-msvc": "4.40.1", + "@rollup/rollup-win32-ia32-msvc": "4.40.1", + "@rollup/rollup-win32-x64-msvc": "4.40.1", "fsevents": "~2.3.2" } }, @@ -6570,6 +6557,51 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", @@ -6715,15 +6747,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.30.1.tgz", - "integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.31.0.tgz", + "integrity": "sha512-u+93F0sB0An8WEAPtwxVhFby573E8ckdjwUUQUj9QA4v8JAvgtoDdIyYR3XFwFHq2W1KJ1AurwJCO+w+Y1ixyQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.30.1", - "@typescript-eslint/parser": "8.30.1", - "@typescript-eslint/utils": "8.30.1" + "@typescript-eslint/eslint-plugin": "8.31.0", + "@typescript-eslint/parser": "8.31.0", + "@typescript-eslint/utils": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6852,15 +6884,18 @@ } }, "node_modules/vite": { - "version": "6.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", - "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==", + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz", + "integrity": "sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -6924,9 +6959,9 @@ } }, "node_modules/vite-node": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", - "integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.2.tgz", + "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", "dev": true, "license": "MIT", "dependencies": { @@ -6946,6 +6981,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6961,32 +7011,46 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", - "integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.2.tgz", + "integrity": "sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.1.1", - "@vitest/mocker": "3.1.1", - "@vitest/pretty-format": "^3.1.1", - "@vitest/runner": "3.1.1", - "@vitest/snapshot": "3.1.1", - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@vitest/expect": "3.1.2", + "@vitest/mocker": "3.1.2", + "@vitest/pretty-format": "^3.1.2", + "@vitest/runner": "3.1.2", + "@vitest/snapshot": "3.1.2", + "@vitest/spy": "3.1.2", + "@vitest/utils": "3.1.2", "chai": "^5.2.0", "debug": "^4.4.0", - "expect-type": "^1.2.0", + "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", - "std-env": "^3.8.1", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.1", + "vite-node": "3.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -7002,8 +7066,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.1", - "@vitest/ui": "3.1.1", + "@vitest/browser": "3.1.2", + "@vitest/ui": "3.1.2", "happy-dom": "*", "jsdom": "*" }, diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index 9299e62b79..1fbee84c3f 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -215,6 +215,19 @@ describe('/admin/users', () => { const user = await getMyUser({ headers: asBearerAuth(token.accessToken) }); expect(user).toMatchObject({ email: nonAdmin.userEmail }); }); + + it('should update the avatar color', async () => { + const { status, body } = await request(app) + .put(`/admin/users/${admin.userId}`) + .send({ avatarColor: 'orange' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ avatarColor: 'orange' }); + + const after = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ avatarColor: 'orange' }); + }); }); describe('PUT /admin/users/:id/preferences', () => { @@ -240,19 +253,6 @@ describe('/admin/users', () => { expect(after).toMatchObject({ memories: { enabled: false } }); }); - it('should update the avatar color', async () => { - const { status, body } = await request(app) - .put(`/admin/users/${admin.userId}/preferences`) - .send({ avatar: { color: 'orange' } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ avatar: { color: 'orange' } }); - - const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ avatar: { color: 'orange' } }); - }); - it('should update download archive size', async () => { const { status, body } = await request(app) .put(`/admin/users/${admin.userId}/preferences`) diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 54d11e5049..b9eb140c56 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -139,6 +139,19 @@ describe('/users', () => { profileChangedAt: expect.anything(), }); }); + + it('should update avatar color', async () => { + const { status, body } = await request(app) + .put(`/users/me`) + .send({ avatarColor: 'blue' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ avatarColor: 'blue' }); + + const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ avatarColor: 'blue' }); + }); }); describe('PUT /users/me/preferences', () => { @@ -158,19 +171,6 @@ describe('/users', () => { expect(after).toMatchObject({ memories: { enabled: false } }); }); - it('should update avatar color', async () => { - const { status, body } = await request(app) - .put(`/users/me/preferences`) - .send({ avatar: { color: 'blue' } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ avatar: { color: 'blue' } }); - - const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ avatar: { color: 'blue' } }); - }); - it('should require an integer for download archive size', async () => { const { status, body } = await request(app) .put(`/users/me/preferences`) diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index 4871e7522c..c8a9b42b2a 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -21,23 +21,9 @@ test.describe('Photo Viewer', () => { test.beforeEach(async ({ context, page }) => { // before each test, login as user await utils.setAuthCookies(context, admin.accessToken); - await page.goto('/photos'); await page.waitForLoadState('networkidle'); }); - test('initially shows a loading spinner', async ({ page }) => { - await page.route(`/api/assets/${asset.id}/thumbnail**`, async (route) => { - // slow down the request for thumbnail, so spinner has chance to show up - await new Promise((f) => setTimeout(f, 2000)); - await route.continue(); - }); - await page.goto(`/photos/${asset.id}`); - await page.waitForLoadState('load'); - // this is the spinner - await page.waitForSelector('svg[role=status]'); - await expect(page.getByTestId('loading-spinner')).toBeVisible(); - }); - test('loads original photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); diff --git a/i18n/en.json b/i18n/en.json index eafb3415d5..6d7b8610a1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -192,26 +192,22 @@ "oauth_auto_register": "Auto register", "oauth_auto_register_description": "Automatically register new users after signing in with OAuth", "oauth_button_text": "Button text", - "oauth_client_id": "Client ID", - "oauth_client_secret": "Client Secret", + "oauth_client_secret_description": "Required if PKCE (Proof Key for Code Exchange) is not supported by the OAuth provider", "oauth_enable_description": "Login with OAuth", - "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 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", "oauth_settings": "OAuth", "oauth_settings_description": "Manage OAuth login settings", "oauth_settings_more_details": "For more details about this feature, refer to the docs.", - "oauth_signing_algorithm": "Signing algorithm", "oauth_storage_label_claim": "Storage label claim", "oauth_storage_label_claim_description": "Automatically set the user's storage label to the value of this claim.", "oauth_storage_quota_claim": "Storage quota claim", "oauth_storage_quota_claim_description": "Automatically set the user's storage quota to the value of this claim.", "oauth_storage_quota_default": "Default storage quota (GiB)", "oauth_storage_quota_default_description": "Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota).", + "oauth_timeout": "Request Timeout", + "oauth_timeout_description": "Timeout for requests in milliseconds", "offline_paths": "Offline Paths", "offline_paths_description": "These results may be due to manual deletion of files that are not part of an external library.", "password_enable_description": "Login with email and password", @@ -853,10 +849,12 @@ "failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets", "failed_to_load_asset": "Failed to load asset", "failed_to_load_assets": "Failed to load assets", + "failed_to_load_notifications": "Failed to load notifications", "failed_to_load_people": "Failed to load people", "failed_to_remove_product_key": "Failed to remove product key", "failed_to_stack_assets": "Failed to stack assets", "failed_to_unstack_assets": "Failed to un-stack assets", + "failed_to_update_notification_status": "Failed to update notification status", "import_path_already_exists": "This import path already exists.", "incorrect_email_or_password": "Incorrect email or password", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", @@ -1199,6 +1197,9 @@ "map_settings_only_show_favorites": "Show Favorite Only", "map_settings_theme_settings": "Map Theme", "map_zoom_to_see_photos": "Zoom out to see photos", + "mark_as_read": "Mark as read", + "mark_all_as_read": "Mark all as read", + "marked_all_as_read": "Marked all as read", "matches": "Matches", "media_type": "Media type", "memories": "Memories", @@ -1225,6 +1226,8 @@ "month": "Month", "monthly_title_text_date_format": "MMMM y", "more": "More", + "moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive", + "moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library", "moved_to_trash": "Moved to trash", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", @@ -1260,6 +1263,7 @@ "no_places": "No places", "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", + "no_notifications": "No notifications", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", "not_in_any_album": "Not in any album", "not_selected": "Not selected", diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 6e31cfe60e..270e743291 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:a3e280261e448b95d49423532ccd6e5329c39d171c10df1457891ff7c5e2301b AS builder-cpu +FROM python:3.11-bookworm@sha256:ab60e444e04215a62671149f24c59cc2893b49cb5dad26f9d139077a86be760e AS builder-cpu FROM builder-cpu AS builder-openvino @@ -54,7 +54,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ RUN apt-get update && apt-get install -y --no-install-recommends g++ -COPY --from=ghcr.io/astral-sh/uv:latest@sha256:db305ce8edc1c2df4988b9d23471465d90d599cc55571e6501421c173a33bb0b /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:latest@sha256:4a6c9444b126bd325fba904bff796bf91fb777bf6148d60109c4cb1de2ffc497 /uv /uvx /bin/ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ @@ -63,11 +63,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \ uv pip install /opt/onnxruntime_rocm-*.whl; \ fi -FROM python:3.11-slim-bookworm@sha256:82c07f2f6e35255b92eb16f38dbd22679d5e8fb523064138d7c6468e7bf0c15b AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:97ef3198ec8c78690587167bb6a4905d00ffe053900687bdae93ad667e507cbb AS prod-cpu ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 -FROM python:3.11-slim-bookworm@sha256:82c07f2f6e35255b92eb16f38dbd22679d5e8fb523064138d7c6468e7bf0c15b AS prod-openvino +FROM python:3.11-slim-bookworm@sha256:97ef3198ec8c78690587167bb6a4905d00ffe053900687bdae93ad667e507cbb AS prod-openvino RUN apt-get update && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index eeb48ea666..1930e80661 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -69,6 +69,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/cd/d6d9bb1dadf73e7af02d18225cbd2c93f8552e13130484f1c8dcfece292b/anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee", size = 85481, upload_time = "2023-12-16T17:06:55.989Z" }, ] +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload_time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload_time = "2024-02-18T19:09:04.156Z" }, +] + [[package]] name = "black" version = "25.1.0" @@ -1200,15 +1209,16 @@ wheels = [ [[package]] name = "locust" -version = "2.35.0" +version = "2.36.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "configargparse" }, { name = "flask" }, { name = "flask-cors" }, { name = "flask-login" }, - { name = "gevent", marker = "python_full_version != '3.13.*'" }, + { name = "gevent" }, { name = "geventhttpclient" }, + { name = "locust-cloud" }, { name = "msgpack" }, { name = "psutil" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -1219,9 +1229,25 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/21/d5aeeee74173d73d7d8d392e307ec24d8281fca69a2bf1f19199bd84c498/locust-2.35.0.tar.gz", hash = "sha256:97f83e591646ca3227644cfb6d4fa590e9a3e3d791ab18b216ca98be235b9b24", size = 2240690, upload_time = "2025-04-16T12:10:25.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/90/55d4fbc8911e5e6ec4072caaca9e8b7b2b11279435c0d1330c9966b0c898/locust-2.36.2.tar.gz", hash = "sha256:604aff7535f5a83b7f666d32373b2dc74ad260c7c3d1dc274f4c82844be72eb6", size = 2251110, upload_time = "2025-04-25T14:03:35.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/15/9e92757c08af3f0c0168ab6480315ed6374396d635f70055df5c42cfa672/locust-2.35.0-py3-none-any.whl", hash = "sha256:fb9e0ec25c5db3ed6a3c6d48e7236d7c2c370b0ddae102e9badcb2d3d101abde", size = 2258054, upload_time = "2025-04-16T12:10:22.608Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f5/99dab104be69122eee3513dcdc6e0b32d59ca1f4cfd8715470c5f3aa7643/locust-2.36.2-py3-none-any.whl", hash = "sha256:74239f493f44035b25a87a0665deadf41d213b3dcd45774398e511dec15e26eb", size = 2267937, upload_time = "2025-04-25T14:03:33.671Z" }, +] + +[[package]] +name = "locust-cloud" +version = "1.20.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configargparse" }, + { name = "gevent" }, + { name = "platformdirs" }, + { name = "python-socketio", extra = ["client"] }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/47/1ec2478f3d4e526fb8d667b01a75b22093b2e66aea665b5369dd656ceec9/locust_cloud-1.20.7.tar.gz", hash = "sha256:24c16b767adffab51b97f489bcf142e16e2439354fb4296ecbb3e87ad20e220a", size = 448622, upload_time = "2025-04-28T11:01:49.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/07/62b5b174c77d4281235405f1ffd439f12a877e434e007a24a5299c461e39/locust_cloud-1.20.7-py3-none-any.whl", hash = "sha256:f38214e77993d0ee87114dafa857e1689789ed4bfe4ae57c2b9dc754674f08bc", size = 406619, upload_time = "2025-04-28T11:01:43.135Z" }, ] [[package]] @@ -1722,11 +1748,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.1.0" +version = "4.3.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/d1/7feaaacb1a3faeba96c06e6c5091f90695cc0f94b7e8e1a3a3fe2b33ff9a/platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420", size = 19760, upload_time = "2023-12-04T15:32:15.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291, upload_time = "2025-03-19T20:36:10.989Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/53/42fe5eab4a09d251a76d0043e018172db324a23fcdac70f77a551c11f618/platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380", size = 17420, upload_time = "2023-12-04T15:32:13.795Z" }, + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499, upload_time = "2025-03-19T20:36:09.038Z" }, ] [[package]] @@ -2005,6 +2031,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482, upload_time = "2023-02-24T06:46:36.009Z" }, ] +[[package]] +name = "python-engineio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/e1/eee1129544b7f78fa2afa9fa0fce153cdcb21015b9b331d1b8adf90f45cb/python_engineio-4.12.0.tar.gz", hash = "sha256:f42a36a868d7063aa10ddccf6bd6117a169b6bd00d7ca53999772093b62014f9", size = 91503, upload_time = "2025-04-12T15:30:23.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/f7/0aeea75424c47633c1d98557a2323be23bed31fa950f00161b34a5150d06/python_engineio-4.12.0-py3-none-any.whl", hash = "sha256:a0c47c129c39777e8ebc6d18011efd50db2144e4e8f08983acae8a3614626535", size = 59319, upload_time = "2025-04-12T15:30:22.325Z" }, +] + [[package]] name = "python-multipart" version = "0.0.20" @@ -2014,6 +2052,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload_time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "python-socketio" +version = "5.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/1a/396d50ccf06ee539fa758ce5623b59a9cb27637fc4b2dc07ed08bf495e77/python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029", size = 121125, upload_time = "2025-04-12T15:46:59.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800, upload_time = "2025-04-12T15:46:58.412Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "requests" }, + { name = "websocket-client" }, +] + [[package]] name = "pywin32" version = "306" @@ -2226,27 +2283,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.11.6" +version = "0.11.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/11/bcef6784c7e5d200b8a1f5c2ddf53e5da0efec37e6e5a44d163fb97e04ba/ruff-0.11.6.tar.gz", hash = "sha256:bec8bcc3ac228a45ccc811e45f7eb61b950dbf4cf31a67fa89352574b01c7d79", size = 4010053, upload_time = "2025-04-17T13:35:53.905Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/89/6f9c9674818ac2e9cc2f2b35b704b7768656e6b7c139064fc7ba8fbc99f1/ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4", size = 4054861, upload_time = "2025-04-24T18:49:37.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/1f/8848b625100ebcc8740c8bac5b5dd8ba97dd4ee210970e98832092c1635b/ruff-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:d84dcbe74cf9356d1bdb4a78cf74fd47c740bf7bdeb7529068f69b08272239a1", size = 10248105, upload_time = "2025-04-17T13:35:14.758Z" }, - { url = "https://files.pythonhosted.org/packages/e0/47/c44036e70c6cc11e6ee24399c2a1e1f1e99be5152bd7dff0190e4b325b76/ruff-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9bc583628e1096148011a5d51ff3c836f51899e61112e03e5f2b1573a9b726de", size = 11001494, upload_time = "2025-04-17T13:35:18.444Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5b/170444061650202d84d316e8f112de02d092bff71fafe060d3542f5bc5df/ruff-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2959049faeb5ba5e3b378709e9d1bf0cab06528b306b9dd6ebd2a312127964a", size = 10352151, upload_time = "2025-04-17T13:35:20.563Z" }, - { url = "https://files.pythonhosted.org/packages/ff/91/f02839fb3787c678e112c8865f2c3e87cfe1744dcc96ff9fc56cfb97dda2/ruff-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63c5d4e30d9d0de7fedbfb3e9e20d134b73a30c1e74b596f40f0629d5c28a193", size = 10541951, upload_time = "2025-04-17T13:35:22.522Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f3/c09933306096ff7a08abede3cc2534d6fcf5529ccd26504c16bf363989b5/ruff-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4b9a4e1439f7d0a091c6763a100cef8fbdc10d68593df6f3cfa5abdd9246e", size = 10079195, upload_time = "2025-04-17T13:35:24.485Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/a87f8933fccbc0d8c653cfbf44bedda69c9582ba09210a309c066794e2ee/ruff-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5edf270223dd622218256569636dc3e708c2cb989242262fe378609eccf1308", size = 11698918, upload_time = "2025-04-17T13:35:26.504Z" }, - { url = "https://files.pythonhosted.org/packages/52/7d/8eac0bd083ea8a0b55b7e4628428203441ca68cd55e0b67c135a4bc6e309/ruff-0.11.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f55844e818206a9dd31ff27f91385afb538067e2dc0beb05f82c293ab84f7d55", size = 12319426, upload_time = "2025-04-17T13:35:28.452Z" }, - { url = "https://files.pythonhosted.org/packages/c2/dc/d0c17d875662d0c86fadcf4ca014ab2001f867621b793d5d7eef01b9dcce/ruff-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d8f782286c5ff562e4e00344f954b9320026d8e3fae2ba9e6948443fafd9ffc", size = 11791012, upload_time = "2025-04-17T13:35:30.455Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f3/81a1aea17f1065449a72509fc7ccc3659cf93148b136ff2a8291c4bc3ef1/ruff-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01c63ba219514271cee955cd0adc26a4083df1956d57847978383b0e50ffd7d2", size = 13949947, upload_time = "2025-04-17T13:35:33.133Z" }, - { url = "https://files.pythonhosted.org/packages/61/9f/a3e34de425a668284e7024ee6fd41f452f6fa9d817f1f3495b46e5e3a407/ruff-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15adac20ef2ca296dd3d8e2bedc6202ea6de81c091a74661c3666e5c4c223ff6", size = 11471753, upload_time = "2025-04-17T13:35:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/4a57a86d12542c0f6e2744f262257b2aa5a3783098ec14e40f3e4b3a354a/ruff-0.11.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4dd6b09e98144ad7aec026f5588e493c65057d1b387dd937d7787baa531d9bc2", size = 10417121, upload_time = "2025-04-17T13:35:38.224Z" }, - { url = "https://files.pythonhosted.org/packages/58/3f/a3b4346dff07ef5b862e2ba06d98fcbf71f66f04cf01d375e871382b5e4b/ruff-0.11.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:45b2e1d6c0eed89c248d024ea95074d0e09988d8e7b1dad8d3ab9a67017a5b03", size = 10073829, upload_time = "2025-04-17T13:35:40.255Z" }, - { url = "https://files.pythonhosted.org/packages/93/cc/7ed02e0b86a649216b845b3ac66ed55d8aa86f5898c5f1691797f408fcb9/ruff-0.11.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd40de4115b2ec4850302f1a1d8067f42e70b4990b68838ccb9ccd9f110c5e8b", size = 11076108, upload_time = "2025-04-17T13:35:42.559Z" }, - { url = "https://files.pythonhosted.org/packages/39/5e/5b09840fef0eff1a6fa1dea6296c07d09c17cb6fb94ed5593aa591b50460/ruff-0.11.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:77cda2dfbac1ab73aef5e514c4cbfc4ec1fbef4b84a44c736cc26f61b3814cd9", size = 11512366, upload_time = "2025-04-17T13:35:45.702Z" }, - { url = "https://files.pythonhosted.org/packages/6f/4c/1cd5a84a412d3626335ae69f5f9de2bb554eea0faf46deb1f0cb48534042/ruff-0.11.6-py3-none-win32.whl", hash = "sha256:5151a871554be3036cd6e51d0ec6eef56334d74dfe1702de717a995ee3d5b287", size = 10485900, upload_time = "2025-04-17T13:35:47.695Z" }, - { url = "https://files.pythonhosted.org/packages/42/46/8997872bc44d43df986491c18d4418f1caff03bc47b7f381261d62c23442/ruff-0.11.6-py3-none-win_amd64.whl", hash = "sha256:cce85721d09c51f3b782c331b0abd07e9d7d5f775840379c640606d3159cae0e", size = 11558592, upload_time = "2025-04-17T13:35:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6a/65fecd51a9ca19e1477c3879a7fda24f8904174d1275b419422ac00f6eee/ruff-0.11.6-py3-none-win_arm64.whl", hash = "sha256:3567ba0d07fb170b1b48d944715e3294b77f5b7679e8ba258199a250383ccb79", size = 10682766, upload_time = "2025-04-17T13:35:52.014Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ec/21927cb906c5614b786d1621dba405e3d44f6e473872e6df5d1a6bca0455/ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c", size = 10245403, upload_time = "2025-04-24T18:48:40.459Z" }, + { url = "https://files.pythonhosted.org/packages/e2/af/fec85b6c2c725bcb062a354dd7cbc1eed53c33ff3aa665165871c9c16ddf/ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee", size = 11007166, upload_time = "2025-04-24T18:48:44.742Z" }, + { url = "https://files.pythonhosted.org/packages/31/9a/2d0d260a58e81f388800343a45898fd8df73c608b8261c370058b675319a/ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada", size = 10378076, upload_time = "2025-04-24T18:48:47.918Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/9b09b45051404d2e7dd6d9dbcbabaa5ab0093f9febcae664876a77b9ad53/ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64", size = 10557138, upload_time = "2025-04-24T18:48:51.707Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5e/f62a1b6669870a591ed7db771c332fabb30f83c967f376b05e7c91bccd14/ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201", size = 10095726, upload_time = "2025-04-24T18:48:54.243Z" }, + { url = "https://files.pythonhosted.org/packages/45/59/a7aa8e716f4cbe07c3500a391e58c52caf665bb242bf8be42c62adef649c/ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6", size = 11672265, upload_time = "2025-04-24T18:48:57.639Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e3/101a8b707481f37aca5f0fcc3e42932fa38b51add87bfbd8e41ab14adb24/ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4", size = 12331418, upload_time = "2025-04-24T18:49:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/71/037f76cbe712f5cbc7b852e4916cd3cf32301a30351818d32ab71580d1c0/ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e", size = 11794506, upload_time = "2025-04-24T18:49:03.545Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/e450b6bab1fc60ef263ef8fcda077fb4977601184877dce1c59109356084/ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63", size = 13939084, upload_time = "2025-04-24T18:49:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2c/1e364cc92970075d7d04c69c928430b23e43a433f044474f57e425cbed37/ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502", size = 11450441, upload_time = "2025-04-24T18:49:11.41Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/1b048eb460517ff9accd78bca0fa6ae61df2b276010538e586f834f5e402/ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92", size = 10441060, upload_time = "2025-04-24T18:49:14.184Z" }, + { url = "https://files.pythonhosted.org/packages/3a/57/8dc6ccfd8380e5ca3d13ff7591e8ba46a3b330323515a4996b991b10bd5d/ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94", size = 10058689, upload_time = "2025-04-24T18:49:17.559Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/20487561ed72654147817885559ba2aa705272d8b5dee7654d3ef2dbf912/ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6", size = 11073703, upload_time = "2025-04-24T18:49:20.247Z" }, + { url = "https://files.pythonhosted.org/packages/9d/27/04f2db95f4ef73dccedd0c21daf9991cc3b7f29901a4362057b132075aa4/ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6", size = 11532822, upload_time = "2025-04-24T18:49:23.765Z" }, + { url = "https://files.pythonhosted.org/packages/e1/72/43b123e4db52144c8add336581de52185097545981ff6e9e58a21861c250/ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26", size = 10362436, upload_time = "2025-04-24T18:49:27.377Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a0/3e58cd76fdee53d5c8ce7a56d84540833f924ccdf2c7d657cb009e604d82/ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a", size = 11566676, upload_time = "2025-04-24T18:49:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/68/ca/69d7c7752bce162d1516e5592b1cc6b6668e9328c0d270609ddbeeadd7cf/ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177", size = 10677936, upload_time = "2025-04-24T18:49:34.392Z" }, ] [[package]] @@ -2349,6 +2406,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/15/88e46eb9387e905704b69849618e699dc2f54407d8953cc4ec4b8b46528d/setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc", size = 931070, upload_time = "2024-07-09T16:07:58.829Z" }, ] +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload_time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload_time = "2024-10-10T22:39:29.645Z" }, +] + [[package]] name = "six" version = "1.16.0" @@ -2652,6 +2721,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload_time = "2024-01-06T02:10:55.763Z" }, ] +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648, upload_time = "2024-04-23T22:16:16.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload_time = "2024-04-23T22:16:14.422Z" }, +] + [[package]] name = "websockets" version = "12.0" @@ -2711,6 +2789,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/6e/e792999e816d19d7fcbfa94c730936750036d65656a76a5a688b57a656c4/werkzeug-3.0.3-py3-none-any.whl", hash = "sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8", size = 227274, upload_time = "2024-05-05T23:10:29.567Z" }, ] +[[package]] +name = "wsproto" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload_time = "2022-08-23T19:58:21.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226, upload_time = "2022-08-23T19:58:19.96Z" }, +] + [[package]] name = "zope-event" version = "5.0" diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index 4a9fcc8c99..c63d819153 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/share.service.dart'; +import 'package:immich_mobile/utils/translation.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; @@ -57,12 +58,13 @@ Future handleArchiveAssets( .read(assetProvider.notifier) .toggleArchive(selection, shouldArchive); - final assetOrAssets = selection.length > 1 ? 'assets' : 'asset'; - final archiveOrLibrary = shouldArchive ? 'archive' : 'library'; + final message = shouldArchive + ? t('moved_to_archive', {'count': selection.length}) + : t('moved_to_library', {'count': selection.length}); if (context.mounted) { ImmichToast.show( context: context, - msg: 'Moved ${selection.length} $assetOrAssets to $archiveOrLibrary', + msg: message, gravity: toastGravity, ); } diff --git a/mobile/lib/utils/translation.dart b/mobile/lib/utils/translation.dart new file mode 100644 index 0000000000..461e88ead7 --- /dev/null +++ b/mobile/lib/utils/translation.dart @@ -0,0 +1,14 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:intl/message_format.dart'; + +String t(String key, [Map? args]) { + try { + String message = key.tr(); + if (args != null) { + return MessageFormat(message).format(args); + } + return message; + } catch (e) { + return key; + } +} diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart index 2ec01e871f..da4c47e466 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart @@ -97,6 +97,7 @@ class ImmichAssetGrid extends HookConsumerWidget { ); if (7 - scaleFactor.value.toInt() != perRow.value) { perRow.value = 7 - scaleFactor.value.toInt(); + settings.setSetting(AppSettingsEnum.tilesPerRow, perRow.value); } }; }), diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index a7141c33b2..060898e270 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -755,7 +755,7 @@ class _MonthTitle extends StatelessWidget { key: Key("month-$title"), padding: const EdgeInsets.only(left: 12.0, top: 24.0), child: Text( - title, + toBeginningOfSentenceCase(title, context.locale.languageCode), style: const TextStyle( fontSize: 26, fontWeight: FontWeight.w500, @@ -786,7 +786,7 @@ class _Title extends StatelessWidget { @override Widget build(BuildContext context) { return GroupDividerTitle( - text: title, + text: toBeginningOfSentenceCase(title, context.locale.languageCode), multiselectEnabled: selectionActive, onSelect: () => selectAssets(assets), onDeselect: () => deselectAssets(assets), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4f9b062ba6..d46945f640 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -145,8 +145,15 @@ Class | Method | HTTP request | Description *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | -*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /notifications/admin/templates/{name} | -*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /notifications/admin/test-email | +*NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} | +*NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications | +*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} | +*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications | +*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} | +*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications | +*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications | +*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} | +*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email | *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | *OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | @@ -300,7 +307,6 @@ Class | Method | HTTP request | Description - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) - [AudioCodec](doc//AudioCodec.md) - - [AvatarResponse](doc//AvatarResponse.md) - [AvatarUpdate](doc//AvatarUpdate.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md) - [BulkIdsDto](doc//BulkIdsDto.md) @@ -361,9 +367,17 @@ Class | Method | HTTP request | Description - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MergePersonDto](doc//MergePersonDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md) + - [NotificationCreateDto](doc//NotificationCreateDto.md) + - [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md) + - [NotificationDto](doc//NotificationDto.md) + - [NotificationLevel](doc//NotificationLevel.md) + - [NotificationType](doc//NotificationType.md) + - [NotificationUpdateAllDto](doc//NotificationUpdateAllDto.md) + - [NotificationUpdateDto](doc//NotificationUpdateDto.md) - [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md) - [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md) + - [OAuthTokenEndpointAuthMethod](doc//OAuthTokenEndpointAuthMethod.md) - [OnThisDayDto](doc//OnThisDayDto.md) - [PartnerDirection](doc//PartnerDirection.md) - [PartnerResponseDto](doc//PartnerResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ff5a95bbbc..ba64363c97 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -44,6 +44,7 @@ part 'api/jobs_api.dart'; part 'api/libraries_api.dart'; part 'api/map_api.dart'; part 'api/memories_api.dart'; +part 'api/notifications_api.dart'; part 'api/notifications_admin_api.dart'; part 'api/o_auth_api.dart'; part 'api/partners_api.dart'; @@ -107,7 +108,6 @@ 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'; -part 'model/avatar_response.dart'; part 'model/avatar_update.dart'; part 'model/bulk_id_response_dto.dart'; part 'model/bulk_ids_dto.dart'; @@ -168,9 +168,17 @@ part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; part 'model/merge_person_dto.dart'; part 'model/metadata_search_dto.dart'; +part 'model/notification_create_dto.dart'; +part 'model/notification_delete_all_dto.dart'; +part 'model/notification_dto.dart'; +part 'model/notification_level.dart'; +part 'model/notification_type.dart'; +part 'model/notification_update_all_dto.dart'; +part 'model/notification_update_dto.dart'; part 'model/o_auth_authorize_response_dto.dart'; part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_config_dto.dart'; +part 'model/o_auth_token_endpoint_auth_method.dart'; part 'model/on_this_day_dto.dart'; part 'model/partner_direction.dart'; part 'model/partner_response_dto.dart'; diff --git a/mobile/openapi/lib/api/notifications_admin_api.dart b/mobile/openapi/lib/api/notifications_admin_api.dart index c58bf8978d..409683a950 100644 --- a/mobile/openapi/lib/api/notifications_admin_api.dart +++ b/mobile/openapi/lib/api/notifications_admin_api.dart @@ -16,7 +16,54 @@ class NotificationsAdminApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /notifications/admin/templates/{name}' operation and returns the [Response]. + /// Performs an HTTP 'POST /admin/notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [NotificationCreateDto] notificationCreateDto (required): + Future createNotificationWithHttpInfo(NotificationCreateDto notificationCreateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/notifications'; + + // ignore: prefer_final_locals + Object? postBody = notificationCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [NotificationCreateDto] notificationCreateDto (required): + Future createNotification(NotificationCreateDto notificationCreateDto,) async { + final response = await createNotificationWithHttpInfo(notificationCreateDto,); + 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), 'NotificationDto',) as NotificationDto; + + } + return null; + } + + /// Performs an HTTP 'POST /admin/notifications/templates/{name}' operation and returns the [Response]. /// Parameters: /// /// * [String] name (required): @@ -24,7 +71,7 @@ class NotificationsAdminApi { /// * [TemplateDto] templateDto (required): Future getNotificationTemplateAdminWithHttpInfo(String name, TemplateDto templateDto,) async { // ignore: prefer_const_declarations - final apiPath = r'/notifications/admin/templates/{name}' + final apiPath = r'/admin/notifications/templates/{name}' .replaceAll('{name}', name); // ignore: prefer_final_locals @@ -68,13 +115,13 @@ class NotificationsAdminApi { return null; } - /// Performs an HTTP 'POST /notifications/admin/test-email' operation and returns the [Response]. + /// Performs an HTTP 'POST /admin/notifications/test-email' operation and returns the [Response]. /// Parameters: /// /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): Future sendTestEmailAdminWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async { // ignore: prefer_const_declarations - final apiPath = r'/notifications/admin/test-email'; + final apiPath = r'/admin/notifications/test-email'; // ignore: prefer_final_locals Object? postBody = systemConfigSmtpDto; diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart new file mode 100644 index 0000000000..501cc70a29 --- /dev/null +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -0,0 +1,311 @@ +// +// 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 NotificationsApi { + NotificationsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'DELETE /notifications/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future deleteNotificationWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future deleteNotification(String id,) async { + final response = await deleteNotificationWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'DELETE /notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [NotificationDeleteAllDto] notificationDeleteAllDto (required): + Future deleteNotificationsWithHttpInfo(NotificationDeleteAllDto notificationDeleteAllDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications'; + + // ignore: prefer_final_locals + Object? postBody = notificationDeleteAllDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [NotificationDeleteAllDto] notificationDeleteAllDto (required): + Future deleteNotifications(NotificationDeleteAllDto notificationDeleteAllDto,) async { + final response = await deleteNotificationsWithHttpInfo(notificationDeleteAllDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'GET /notifications/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getNotificationWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getNotification(String id,) async { + final response = await getNotificationWithHttpInfo(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), 'NotificationDto',) as NotificationDto; + + } + return null; + } + + /// Performs an HTTP 'GET /notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id: + /// + /// * [NotificationLevel] level: + /// + /// * [NotificationType] type: + /// + /// * [bool] unread: + Future getNotificationsWithHttpInfo({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } + if (level != null) { + queryParams.addAll(_queryParams('', 'level', level)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + if (unread != null) { + queryParams.addAll(_queryParams('', 'unread', unread)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id: + /// + /// * [NotificationLevel] level: + /// + /// * [NotificationType] type: + /// + /// * [bool] unread: + Future?> getNotifications({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async { + final response = await getNotificationsWithHttpInfo( id: id, level: level, type: type, unread: unread, ); + 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 /notifications/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [NotificationUpdateDto] notificationUpdateDto (required): + Future updateNotificationWithHttpInfo(String id, NotificationUpdateDto notificationUpdateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = notificationUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [NotificationUpdateDto] notificationUpdateDto (required): + Future updateNotification(String id, NotificationUpdateDto notificationUpdateDto,) async { + final response = await updateNotificationWithHttpInfo(id, notificationUpdateDto,); + 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), 'NotificationDto',) as NotificationDto; + + } + return null; + } + + /// Performs an HTTP 'PUT /notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [NotificationUpdateAllDto] notificationUpdateAllDto (required): + Future updateNotificationsWithHttpInfo(NotificationUpdateAllDto notificationUpdateAllDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications'; + + // ignore: prefer_final_locals + Object? postBody = notificationUpdateAllDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [NotificationUpdateAllDto] notificationUpdateAllDto (required): + Future updateNotifications(NotificationUpdateAllDto notificationUpdateAllDto,) async { + final response = await updateNotificationsWithHttpInfo(notificationUpdateAllDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5759217f41..6abe576aca 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -270,8 +270,6 @@ class ApiClient { return AssetTypeEnumTypeTransformer().decode(value); case 'AudioCodec': return AudioCodecTypeTransformer().decode(value); - case 'AvatarResponse': - return AvatarResponse.fromJson(value); case 'AvatarUpdate': return AvatarUpdate.fromJson(value); case 'BulkIdResponseDto': @@ -392,12 +390,28 @@ class ApiClient { return MergePersonDto.fromJson(value); case 'MetadataSearchDto': return MetadataSearchDto.fromJson(value); + case 'NotificationCreateDto': + return NotificationCreateDto.fromJson(value); + case 'NotificationDeleteAllDto': + return NotificationDeleteAllDto.fromJson(value); + case 'NotificationDto': + return NotificationDto.fromJson(value); + case 'NotificationLevel': + return NotificationLevelTypeTransformer().decode(value); + case 'NotificationType': + return NotificationTypeTypeTransformer().decode(value); + case 'NotificationUpdateAllDto': + return NotificationUpdateAllDto.fromJson(value); + case 'NotificationUpdateDto': + return NotificationUpdateDto.fromJson(value); case 'OAuthAuthorizeResponseDto': return OAuthAuthorizeResponseDto.fromJson(value); case 'OAuthCallbackDto': return OAuthCallbackDto.fromJson(value); case 'OAuthConfigDto': return OAuthConfigDto.fromJson(value); + case 'OAuthTokenEndpointAuthMethod': + return OAuthTokenEndpointAuthMethodTypeTransformer().decode(value); case 'OnThisDayDto': return OnThisDayDto.fromJson(value); case 'PartnerDirection': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 1ebf8314ad..5f9d15c089 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -100,6 +100,15 @@ String parameterToString(dynamic value) { if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); } + if (value is NotificationLevel) { + return NotificationLevelTypeTransformer().encode(value).toString(); + } + if (value is NotificationType) { + return NotificationTypeTypeTransformer().encode(value).toString(); + } + if (value is OAuthTokenEndpointAuthMethod) { + return OAuthTokenEndpointAuthMethodTypeTransformer().encode(value).toString(); + } if (value is PartnerDirection) { return PartnerDirectionTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/notification_create_dto.dart b/mobile/openapi/lib/model/notification_create_dto.dart new file mode 100644 index 0000000000..07985353b2 --- /dev/null +++ b/mobile/openapi/lib/model/notification_create_dto.dart @@ -0,0 +1,180 @@ +// +// 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 NotificationCreateDto { + /// Returns a new [NotificationCreateDto] instance. + NotificationCreateDto({ + this.data, + this.description, + this.level, + this.readAt, + required this.title, + this.type, + required this.userId, + }); + + /// + /// 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. + /// + Object? data; + + String? description; + + /// + /// 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. + /// + NotificationLevel? level; + + DateTime? readAt; + + String title; + + /// + /// 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. + /// + NotificationType? type; + + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationCreateDto && + other.data == data && + other.description == description && + other.level == level && + other.readAt == readAt && + other.title == title && + other.type == type && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (data == null ? 0 : data!.hashCode) + + (description == null ? 0 : description!.hashCode) + + (level == null ? 0 : level!.hashCode) + + (readAt == null ? 0 : readAt!.hashCode) + + (title.hashCode) + + (type == null ? 0 : type!.hashCode) + + (userId.hashCode); + + @override + String toString() => 'NotificationCreateDto[data=$data, description=$description, level=$level, readAt=$readAt, title=$title, type=$type, userId=$userId]'; + + Map toJson() { + final json = {}; + if (this.data != null) { + json[r'data'] = this.data; + } else { + // json[r'data'] = null; + } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.level != null) { + json[r'level'] = this.level; + } else { + // json[r'level'] = null; + } + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + json[r'title'] = this.title; + if (this.type != null) { + json[r'type'] = this.type; + } else { + // json[r'type'] = null; + } + json[r'userId'] = this.userId; + return json; + } + + /// Returns a new [NotificationCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationCreateDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationCreateDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationCreateDto( + data: mapValueOfType(json, r'data'), + description: mapValueOfType(json, r'description'), + level: NotificationLevel.fromJson(json[r'level']), + readAt: mapDateTime(json, r'readAt', r''), + title: mapValueOfType(json, r'title')!, + type: NotificationType.fromJson(json[r'type']), + userId: mapValueOfType(json, r'userId')!, + ); + } + 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 = NotificationCreateDto.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 = NotificationCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationCreateDto-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] = NotificationCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'title', + 'userId', + }; +} + diff --git a/mobile/openapi/lib/model/notification_delete_all_dto.dart b/mobile/openapi/lib/model/notification_delete_all_dto.dart new file mode 100644 index 0000000000..4be1b89e92 --- /dev/null +++ b/mobile/openapi/lib/model/notification_delete_all_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 NotificationDeleteAllDto { + /// Returns a new [NotificationDeleteAllDto] instance. + NotificationDeleteAllDto({ + this.ids = const [], + }); + + List ids; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationDeleteAllDto && + _deepEquality.equals(other.ids, ids); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (ids.hashCode); + + @override + String toString() => 'NotificationDeleteAllDto[ids=$ids]'; + + Map toJson() { + final json = {}; + json[r'ids'] = this.ids; + return json; + } + + /// Returns a new [NotificationDeleteAllDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationDeleteAllDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationDeleteAllDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationDeleteAllDto( + ids: json[r'ids'] is Iterable + ? (json[r'ids'] 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 = NotificationDeleteAllDto.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 = NotificationDeleteAllDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationDeleteAllDto-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] = NotificationDeleteAllDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'ids', + }; +} + diff --git a/mobile/openapi/lib/model/notification_dto.dart b/mobile/openapi/lib/model/notification_dto.dart new file mode 100644 index 0000000000..4f730b4e50 --- /dev/null +++ b/mobile/openapi/lib/model/notification_dto.dart @@ -0,0 +1,182 @@ +// +// 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 NotificationDto { + /// Returns a new [NotificationDto] instance. + NotificationDto({ + required this.createdAt, + this.data, + this.description, + required this.id, + required this.level, + this.readAt, + required this.title, + required this.type, + }); + + DateTime createdAt; + + /// + /// 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. + /// + Object? data; + + /// + /// 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? description; + + String id; + + NotificationLevel level; + + /// + /// 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. + /// + DateTime? readAt; + + String title; + + NotificationType type; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationDto && + other.createdAt == createdAt && + other.data == data && + other.description == description && + other.id == id && + other.level == level && + other.readAt == readAt && + other.title == title && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (createdAt.hashCode) + + (data == null ? 0 : data!.hashCode) + + (description == null ? 0 : description!.hashCode) + + (id.hashCode) + + (level.hashCode) + + (readAt == null ? 0 : readAt!.hashCode) + + (title.hashCode) + + (type.hashCode); + + @override + String toString() => 'NotificationDto[createdAt=$createdAt, data=$data, description=$description, id=$id, level=$level, readAt=$readAt, title=$title, type=$type]'; + + Map toJson() { + final json = {}; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + if (this.data != null) { + json[r'data'] = this.data; + } else { + // json[r'data'] = null; + } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + json[r'id'] = this.id; + json[r'level'] = this.level; + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + json[r'title'] = this.title; + json[r'type'] = this.type; + return json; + } + + /// Returns a new [NotificationDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationDto( + createdAt: mapDateTime(json, r'createdAt', r'')!, + data: mapValueOfType(json, r'data'), + description: mapValueOfType(json, r'description'), + id: mapValueOfType(json, r'id')!, + level: NotificationLevel.fromJson(json[r'level'])!, + readAt: mapDateTime(json, r'readAt', r''), + title: mapValueOfType(json, r'title')!, + type: NotificationType.fromJson(json[r'type'])!, + ); + } + 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 = NotificationDto.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 = NotificationDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationDto-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] = NotificationDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'createdAt', + 'id', + 'level', + 'title', + 'type', + }; +} + diff --git a/mobile/openapi/lib/model/notification_level.dart b/mobile/openapi/lib/model/notification_level.dart new file mode 100644 index 0000000000..554863ae4f --- /dev/null +++ b/mobile/openapi/lib/model/notification_level.dart @@ -0,0 +1,91 @@ +// +// 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 NotificationLevel { + /// Instantiate a new enum with the provided [value]. + const NotificationLevel._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const success = NotificationLevel._(r'success'); + static const error = NotificationLevel._(r'error'); + static const warning = NotificationLevel._(r'warning'); + static const info = NotificationLevel._(r'info'); + + /// List of all possible values in this [enum][NotificationLevel]. + static const values = [ + success, + error, + warning, + info, + ]; + + static NotificationLevel? fromJson(dynamic value) => NotificationLevelTypeTransformer().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 = NotificationLevel.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [NotificationLevel] to String, +/// and [decode] dynamic data back to [NotificationLevel]. +class NotificationLevelTypeTransformer { + factory NotificationLevelTypeTransformer() => _instance ??= const NotificationLevelTypeTransformer._(); + + const NotificationLevelTypeTransformer._(); + + String encode(NotificationLevel data) => data.value; + + /// Decodes a [dynamic value][data] to a NotificationLevel. + /// + /// 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. + NotificationLevel? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'success': return NotificationLevel.success; + case r'error': return NotificationLevel.error; + case r'warning': return NotificationLevel.warning; + case r'info': return NotificationLevel.info; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [NotificationLevelTypeTransformer] instance. + static NotificationLevelTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/notification_type.dart b/mobile/openapi/lib/model/notification_type.dart new file mode 100644 index 0000000000..436d2d190f --- /dev/null +++ b/mobile/openapi/lib/model/notification_type.dart @@ -0,0 +1,91 @@ +// +// 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 NotificationType { + /// Instantiate a new enum with the provided [value]. + const NotificationType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const jobFailed = NotificationType._(r'JobFailed'); + static const backupFailed = NotificationType._(r'BackupFailed'); + static const systemMessage = NotificationType._(r'SystemMessage'); + static const custom = NotificationType._(r'Custom'); + + /// List of all possible values in this [enum][NotificationType]. + static const values = [ + jobFailed, + backupFailed, + systemMessage, + custom, + ]; + + static NotificationType? fromJson(dynamic value) => NotificationTypeTypeTransformer().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 = NotificationType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [NotificationType] to String, +/// and [decode] dynamic data back to [NotificationType]. +class NotificationTypeTypeTransformer { + factory NotificationTypeTypeTransformer() => _instance ??= const NotificationTypeTypeTransformer._(); + + const NotificationTypeTypeTransformer._(); + + String encode(NotificationType data) => data.value; + + /// Decodes a [dynamic value][data] to a NotificationType. + /// + /// 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. + NotificationType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'JobFailed': return NotificationType.jobFailed; + case r'BackupFailed': return NotificationType.backupFailed; + case r'SystemMessage': return NotificationType.systemMessage; + case r'Custom': return NotificationType.custom; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [NotificationTypeTypeTransformer] instance. + static NotificationTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/notification_update_all_dto.dart b/mobile/openapi/lib/model/notification_update_all_dto.dart new file mode 100644 index 0000000000..a6393b275a --- /dev/null +++ b/mobile/openapi/lib/model/notification_update_all_dto.dart @@ -0,0 +1,112 @@ +// +// 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 NotificationUpdateAllDto { + /// Returns a new [NotificationUpdateAllDto] instance. + NotificationUpdateAllDto({ + this.ids = const [], + this.readAt, + }); + + List ids; + + DateTime? readAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateAllDto && + _deepEquality.equals(other.ids, ids) && + other.readAt == readAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (ids.hashCode) + + (readAt == null ? 0 : readAt!.hashCode); + + @override + String toString() => 'NotificationUpdateAllDto[ids=$ids, readAt=$readAt]'; + + Map toJson() { + final json = {}; + json[r'ids'] = this.ids; + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + return json; + } + + /// Returns a new [NotificationUpdateAllDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationUpdateAllDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationUpdateAllDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationUpdateAllDto( + ids: json[r'ids'] is Iterable + ? (json[r'ids'] as Iterable).cast().toList(growable: false) + : const [], + readAt: mapDateTime(json, r'readAt', r''), + ); + } + 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 = NotificationUpdateAllDto.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 = NotificationUpdateAllDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationUpdateAllDto-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] = NotificationUpdateAllDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'ids', + }; +} + diff --git a/mobile/openapi/lib/model/avatar_response.dart b/mobile/openapi/lib/model/notification_update_dto.dart similarity index 51% rename from mobile/openapi/lib/model/avatar_response.dart rename to mobile/openapi/lib/model/notification_update_dto.dart index 8ce0287565..e76496eb97 100644 --- a/mobile/openapi/lib/model/avatar_response.dart +++ b/mobile/openapi/lib/model/notification_update_dto.dart @@ -10,52 +10,56 @@ part of openapi.api; -class AvatarResponse { - /// Returns a new [AvatarResponse] instance. - AvatarResponse({ - required this.color, +class NotificationUpdateDto { + /// Returns a new [NotificationUpdateDto] instance. + NotificationUpdateDto({ + this.readAt, }); - UserAvatarColor color; + DateTime? readAt; @override - bool operator ==(Object other) => identical(this, other) || other is AvatarResponse && - other.color == color; + bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateDto && + other.readAt == readAt; @override int get hashCode => // ignore: unnecessary_parenthesis - (color.hashCode); + (readAt == null ? 0 : readAt!.hashCode); @override - String toString() => 'AvatarResponse[color=$color]'; + String toString() => 'NotificationUpdateDto[readAt=$readAt]'; Map toJson() { final json = {}; - json[r'color'] = this.color; + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } return json; } - /// Returns a new [AvatarResponse] instance and imports its values from + /// Returns a new [NotificationUpdateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AvatarResponse? fromJson(dynamic value) { - upgradeDto(value, "AvatarResponse"); + static NotificationUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationUpdateDto"); if (value is Map) { final json = value.cast(); - return AvatarResponse( - color: UserAvatarColor.fromJson(json[r'color'])!, + return NotificationUpdateDto( + readAt: mapDateTime(json, r'readAt', r''), ); } 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 = AvatarResponse.fromJson(row); + final value = NotificationUpdateDto.fromJson(row); if (value != null) { result.add(value); } @@ -64,12 +68,12 @@ class AvatarResponse { 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 = AvatarResponse.fromJson(entry.value); + final value = NotificationUpdateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -78,14 +82,14 @@ class AvatarResponse { return map; } - // maps a json object with a list of AvatarResponse-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 NotificationUpdateDto-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] = AvatarResponse.listFromJson(entry.value, growable: growable,); + map[entry.key] = NotificationUpdateDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -93,7 +97,6 @@ class AvatarResponse { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'color', }; } diff --git a/mobile/openapi/lib/model/o_auth_token_endpoint_auth_method.dart b/mobile/openapi/lib/model/o_auth_token_endpoint_auth_method.dart new file mode 100644 index 0000000000..fc528888b3 --- /dev/null +++ b/mobile/openapi/lib/model/o_auth_token_endpoint_auth_method.dart @@ -0,0 +1,85 @@ +// +// 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 OAuthTokenEndpointAuthMethod { + /// Instantiate a new enum with the provided [value]. + const OAuthTokenEndpointAuthMethod._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const post = OAuthTokenEndpointAuthMethod._(r'client_secret_post'); + static const basic = OAuthTokenEndpointAuthMethod._(r'client_secret_basic'); + + /// List of all possible values in this [enum][OAuthTokenEndpointAuthMethod]. + static const values = [ + post, + basic, + ]; + + static OAuthTokenEndpointAuthMethod? fromJson(dynamic value) => OAuthTokenEndpointAuthMethodTypeTransformer().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 = OAuthTokenEndpointAuthMethod.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [OAuthTokenEndpointAuthMethod] to String, +/// and [decode] dynamic data back to [OAuthTokenEndpointAuthMethod]. +class OAuthTokenEndpointAuthMethodTypeTransformer { + factory OAuthTokenEndpointAuthMethodTypeTransformer() => _instance ??= const OAuthTokenEndpointAuthMethodTypeTransformer._(); + + const OAuthTokenEndpointAuthMethodTypeTransformer._(); + + String encode(OAuthTokenEndpointAuthMethod data) => data.value; + + /// Decodes a [dynamic value][data] to a OAuthTokenEndpointAuthMethod. + /// + /// 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. + OAuthTokenEndpointAuthMethod? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'client_secret_post': return OAuthTokenEndpointAuthMethod.post; + case r'client_secret_basic': return OAuthTokenEndpointAuthMethod.basic; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [OAuthTokenEndpointAuthMethodTypeTransformer] instance. + static OAuthTokenEndpointAuthMethodTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 1244a434b6..1735bc2eb5 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -66,6 +66,10 @@ class Permission { static const memoryPeriodRead = Permission._(r'memory.read'); static const memoryPeriodUpdate = Permission._(r'memory.update'); static const memoryPeriodDelete = Permission._(r'memory.delete'); + static const notificationPeriodCreate = Permission._(r'notification.create'); + static const notificationPeriodRead = Permission._(r'notification.read'); + static const notificationPeriodUpdate = Permission._(r'notification.update'); + static const notificationPeriodDelete = Permission._(r'notification.delete'); static const partnerPeriodCreate = Permission._(r'partner.create'); static const partnerPeriodRead = Permission._(r'partner.read'); static const partnerPeriodUpdate = Permission._(r'partner.update'); @@ -147,6 +151,10 @@ class Permission { memoryPeriodRead, memoryPeriodUpdate, memoryPeriodDelete, + notificationPeriodCreate, + notificationPeriodRead, + notificationPeriodUpdate, + notificationPeriodDelete, partnerPeriodCreate, partnerPeriodRead, partnerPeriodUpdate, @@ -263,6 +271,10 @@ class PermissionTypeTransformer { case r'memory.read': return Permission.memoryPeriodRead; case r'memory.update': return Permission.memoryPeriodUpdate; case r'memory.delete': return Permission.memoryPeriodDelete; + case r'notification.create': return Permission.notificationPeriodCreate; + case r'notification.read': return Permission.notificationPeriodRead; + case r'notification.update': return Permission.notificationPeriodUpdate; + case r'notification.delete': return Permission.notificationPeriodDelete; case r'partner.create': return Permission.partnerPeriodCreate; case r'partner.read': return Permission.partnerPeriodRead; case r'partner.update': return Permission.partnerPeriodUpdate; diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart index 9125bb7bba..24384a47b1 100644 --- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart +++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart @@ -28,6 +28,8 @@ class SystemConfigOAuthDto { required this.signingAlgorithm, required this.storageLabelClaim, required this.storageQuotaClaim, + required this.timeout, + required this.tokenEndpointAuthMethod, }); bool autoLaunch; @@ -61,6 +63,11 @@ class SystemConfigOAuthDto { String storageQuotaClaim; + /// Minimum value: 1 + int timeout; + + OAuthTokenEndpointAuthMethod tokenEndpointAuthMethod; + @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigOAuthDto && other.autoLaunch == autoLaunch && @@ -77,7 +84,9 @@ class SystemConfigOAuthDto { other.scope == scope && other.signingAlgorithm == signingAlgorithm && other.storageLabelClaim == storageLabelClaim && - other.storageQuotaClaim == storageQuotaClaim; + other.storageQuotaClaim == storageQuotaClaim && + other.timeout == timeout && + other.tokenEndpointAuthMethod == tokenEndpointAuthMethod; @override int get hashCode => @@ -96,10 +105,12 @@ class SystemConfigOAuthDto { (scope.hashCode) + (signingAlgorithm.hashCode) + (storageLabelClaim.hashCode) + - (storageQuotaClaim.hashCode); + (storageQuotaClaim.hashCode) + + (timeout.hashCode) + + (tokenEndpointAuthMethod.hashCode); @override - String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, profileSigningAlgorithm=$profileSigningAlgorithm, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim]'; + String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, profileSigningAlgorithm=$profileSigningAlgorithm, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim, timeout=$timeout, tokenEndpointAuthMethod=$tokenEndpointAuthMethod]'; Map toJson() { final json = {}; @@ -118,6 +129,8 @@ class SystemConfigOAuthDto { json[r'signingAlgorithm'] = this.signingAlgorithm; json[r'storageLabelClaim'] = this.storageLabelClaim; json[r'storageQuotaClaim'] = this.storageQuotaClaim; + json[r'timeout'] = this.timeout; + json[r'tokenEndpointAuthMethod'] = this.tokenEndpointAuthMethod; return json; } @@ -145,6 +158,8 @@ class SystemConfigOAuthDto { signingAlgorithm: mapValueOfType(json, r'signingAlgorithm')!, storageLabelClaim: mapValueOfType(json, r'storageLabelClaim')!, storageQuotaClaim: mapValueOfType(json, r'storageQuotaClaim')!, + timeout: mapValueOfType(json, r'timeout')!, + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.fromJson(json[r'tokenEndpointAuthMethod'])!, ); } return null; @@ -207,6 +222,8 @@ class SystemConfigOAuthDto { 'signingAlgorithm', 'storageLabelClaim', 'storageQuotaClaim', + 'timeout', + 'tokenEndpointAuthMethod', }; } diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index 4bd1266426..1477c82ca1 100644 --- a/mobile/openapi/lib/model/user_admin_create_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class UserAdminCreateDto { /// Returns a new [UserAdminCreateDto] instance. UserAdminCreateDto({ + this.avatarColor, required this.email, required this.name, this.notify, @@ -22,6 +23,8 @@ class UserAdminCreateDto { this.storageLabel, }); + UserAvatarColor? avatarColor; + String email; String name; @@ -51,6 +54,7 @@ class UserAdminCreateDto { @override bool operator ==(Object other) => identical(this, other) || other is UserAdminCreateDto && + other.avatarColor == avatarColor && other.email == email && other.name == name && other.notify == notify && @@ -62,6 +66,7 @@ class UserAdminCreateDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (avatarColor == null ? 0 : avatarColor!.hashCode) + (email.hashCode) + (name.hashCode) + (notify == null ? 0 : notify!.hashCode) + @@ -71,10 +76,15 @@ class UserAdminCreateDto { (storageLabel == null ? 0 : storageLabel!.hashCode); @override - String toString() => 'UserAdminCreateDto[email=$email, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; + String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; Map toJson() { final json = {}; + if (this.avatarColor != null) { + json[r'avatarColor'] = this.avatarColor; + } else { + // json[r'avatarColor'] = null; + } json[r'email'] = this.email; json[r'name'] = this.name; if (this.notify != null) { @@ -110,6 +120,7 @@ class UserAdminCreateDto { final json = value.cast(); return UserAdminCreateDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), email: mapValueOfType(json, r'email')!, name: mapValueOfType(json, r'name')!, notify: mapValueOfType(json, r'notify'), diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index f0478c9b4c..951ee8ce84 100644 --- a/mobile/openapi/lib/model/user_admin_update_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class UserAdminUpdateDto { /// Returns a new [UserAdminUpdateDto] instance. UserAdminUpdateDto({ + this.avatarColor, this.email, this.name, this.password, @@ -21,6 +22,8 @@ class UserAdminUpdateDto { this.storageLabel, }); + UserAvatarColor? avatarColor; + /// /// 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 @@ -60,6 +63,7 @@ class UserAdminUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is UserAdminUpdateDto && + other.avatarColor == avatarColor && other.email == email && other.name == name && other.password == password && @@ -70,6 +74,7 @@ class UserAdminUpdateDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (avatarColor == null ? 0 : avatarColor!.hashCode) + (email == null ? 0 : email!.hashCode) + (name == null ? 0 : name!.hashCode) + (password == null ? 0 : password!.hashCode) + @@ -78,10 +83,15 @@ class UserAdminUpdateDto { (storageLabel == null ? 0 : storageLabel!.hashCode); @override - String toString() => 'UserAdminUpdateDto[email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; + String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; Map toJson() { final json = {}; + if (this.avatarColor != null) { + json[r'avatarColor'] = this.avatarColor; + } else { + // json[r'avatarColor'] = null; + } if (this.email != null) { json[r'email'] = this.email; } else { @@ -124,6 +134,7 @@ class UserAdminUpdateDto { final json = value.cast(); return UserAdminUpdateDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), email: mapValueOfType(json, r'email'), name: mapValueOfType(json, r'name'), password: mapValueOfType(json, r'password'), diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index b244284eb0..215e691cb1 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -13,7 +13,6 @@ part of openapi.api; class UserPreferencesResponseDto { /// Returns a new [UserPreferencesResponseDto] instance. UserPreferencesResponseDto({ - required this.avatar, required this.download, required this.emailNotifications, required this.folders, @@ -25,8 +24,6 @@ class UserPreferencesResponseDto { required this.tags, }); - AvatarResponse avatar; - DownloadResponse download; EmailNotificationsResponse emailNotifications; @@ -47,7 +44,6 @@ class UserPreferencesResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && - other.avatar == avatar && other.download == download && other.emailNotifications == emailNotifications && other.folders == folders && @@ -61,7 +57,6 @@ class UserPreferencesResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis - (avatar.hashCode) + (download.hashCode) + (emailNotifications.hashCode) + (folders.hashCode) + @@ -73,11 +68,10 @@ class UserPreferencesResponseDto { (tags.hashCode); @override - String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; + String toString() => 'UserPreferencesResponseDto[download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, 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; @@ -99,7 +93,6 @@ class UserPreferencesResponseDto { final json = value.cast(); return UserPreferencesResponseDto( - avatar: AvatarResponse.fromJson(json[r'avatar'])!, download: DownloadResponse.fromJson(json[r'download'])!, emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, folders: FoldersResponse.fromJson(json[r'folders'])!, @@ -156,7 +149,6 @@ class UserPreferencesResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'avatar', 'download', 'emailNotifications', 'folders', diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart index 8f3f4df37a..779e07ffa6 100644 --- a/mobile/openapi/lib/model/user_update_me_dto.dart +++ b/mobile/openapi/lib/model/user_update_me_dto.dart @@ -13,11 +13,14 @@ part of openapi.api; class UserUpdateMeDto { /// Returns a new [UserUpdateMeDto] instance. UserUpdateMeDto({ + this.avatarColor, this.email, this.name, this.password, }); + UserAvatarColor? avatarColor; + /// /// 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 @@ -44,6 +47,7 @@ class UserUpdateMeDto { @override bool operator ==(Object other) => identical(this, other) || other is UserUpdateMeDto && + other.avatarColor == avatarColor && other.email == email && other.name == name && other.password == password; @@ -51,15 +55,21 @@ class UserUpdateMeDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (avatarColor == null ? 0 : avatarColor!.hashCode) + (email == null ? 0 : email!.hashCode) + (name == null ? 0 : name!.hashCode) + (password == null ? 0 : password!.hashCode); @override - String toString() => 'UserUpdateMeDto[email=$email, name=$name, password=$password]'; + String toString() => 'UserUpdateMeDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password]'; Map toJson() { final json = {}; + if (this.avatarColor != null) { + json[r'avatarColor'] = this.avatarColor; + } else { + // json[r'avatarColor'] = null; + } if (this.email != null) { json[r'email'] = this.email; } else { @@ -87,6 +97,7 @@ class UserUpdateMeDto { final json = value.cast(); return UserUpdateMeDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), email: mapValueOfType(json, r'email'), name: mapValueOfType(json, r'name'), password: mapValueOfType(json, r'password'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f2851d7cf1..826af5a2ec 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -206,6 +206,141 @@ ] } }, + "/admin/notifications": { + "post": { + "operationId": "createNotification", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, + "/admin/notifications/templates/{name}": { + "post": { + "operationId": "getNotificationTemplateAdmin", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, + "/admin/notifications/test-email": { + "post": { + "operationId": "sendTestEmailAdmin", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemConfigSmtpDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestEmailResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, "/admin/users": { "get": { "operationId": "searchUsersAdmin", @@ -3485,15 +3620,224 @@ ] } }, - "/notifications/admin/templates/{name}": { - "post": { - "operationId": "getNotificationTemplateAdmin", + "/notifications": { + "delete": { + "operationId": "deleteNotifications", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDeleteAllDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "get": { + "operationId": "getNotifications", "parameters": [ { - "name": "name", + "name": "id", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "level", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/NotificationLevel" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/NotificationType" + } + }, + { + "name": "unread", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/NotificationDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "put": { + "operationId": "updateNotifications", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationUpdateAllDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + } + }, + "/notifications/{id}": { + "delete": { + "operationId": "deleteNotification", + "parameters": [ + { + "name": "id", "required": true, "in": "path", "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "get": { + "operationId": "getNotification", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "put": { + "operationId": "updateNotification", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", "type": "string" } } @@ -3502,7 +3846,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateDto" + "$ref": "#/components/schemas/NotificationUpdateDto" } } }, @@ -3513,7 +3857,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateResponseDto" + "$ref": "#/components/schemas/NotificationDto" } } }, @@ -3532,49 +3876,7 @@ } ], "tags": [ - "Notifications (Admin)" - ] - } - }, - "/notifications/admin/test-email": { - "post": { - "operationId": "sendTestEmailAdmin", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SystemConfigSmtpDto" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TestEmailResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Notifications (Admin)" + "Notifications" ] } }, @@ -8884,21 +9186,6 @@ ], "type": "string" }, - "AvatarResponse": { - "properties": { - "color": { - "allOf": [ - { - "$ref": "#/components/schemas/UserAvatarColor" - } - ] - } - }, - "required": [ - "color" - ], - "type": "object" - }, "AvatarUpdate": { "properties": { "color": { @@ -10341,6 +10628,157 @@ }, "type": "object" }, + "NotificationCreateDto": { + "properties": { + "data": { + "type": "object" + }, + "description": { + "nullable": true, + "type": "string" + }, + "level": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationLevel" + } + ] + }, + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationType" + } + ] + }, + "userId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "title", + "userId" + ], + "type": "object" + }, + "NotificationDeleteAllDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, + "NotificationDto": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "data": { + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "level": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationLevel" + } + ] + }, + "readAt": { + "format": "date-time", + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationType" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "level", + "title", + "type" + ], + "type": "object" + }, + "NotificationLevel": { + "enum": [ + "success", + "error", + "warning", + "info" + ], + "type": "string" + }, + "NotificationType": { + "enum": [ + "JobFailed", + "BackupFailed", + "SystemMessage", + "Custom" + ], + "type": "string" + }, + "NotificationUpdateAllDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, + "NotificationUpdateDto": { + "properties": { + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, "OAuthAuthorizeResponseDto": { "properties": { "url": { @@ -10386,6 +10824,13 @@ ], "type": "object" }, + "OAuthTokenEndpointAuthMethod": { + "enum": [ + "client_secret_post", + "client_secret_basic" + ], + "type": "string" + }, "OnThisDayDto": { "properties": { "year": { @@ -10615,6 +11060,10 @@ "memory.read", "memory.update", "memory.delete", + "notification.create", + "notification.read", + "notification.update", + "notification.delete", "partner.create", "partner.read", "partner.update", @@ -12962,6 +13411,17 @@ }, "storageQuotaClaim": { "type": "string" + }, + "timeout": { + "minimum": 1, + "type": "integer" + }, + "tokenEndpointAuthMethod": { + "allOf": [ + { + "$ref": "#/components/schemas/OAuthTokenEndpointAuthMethod" + } + ] } }, "required": [ @@ -12979,7 +13439,9 @@ "scope", "signingAlgorithm", "storageLabelClaim", - "storageQuotaClaim" + "storageQuotaClaim", + "timeout", + "tokenEndpointAuthMethod" ], "type": "object" }, @@ -13621,6 +14083,14 @@ }, "UserAdminCreateDto": { "properties": { + "avatarColor": { + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ], + "nullable": true + }, "email": { "format": "email", "type": "string" @@ -13763,6 +14233,14 @@ }, "UserAdminUpdateDto": { "properties": { + "avatarColor": { + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ], + "nullable": true + }, "email": { "format": "email", "type": "string" @@ -13826,9 +14304,6 @@ }, "UserPreferencesResponseDto": { "properties": { - "avatar": { - "$ref": "#/components/schemas/AvatarResponse" - }, "download": { "$ref": "#/components/schemas/DownloadResponse" }, @@ -13858,7 +14333,6 @@ } }, "required": [ - "avatar", "download", "emailNotifications", "folders", @@ -13952,6 +14426,14 @@ }, "UserUpdateMeDto": { "properties": { + "avatarColor": { + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ], + "nullable": true + }, "email": { "format": "email", "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 51e17c08ac..743eeadf03 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -39,6 +39,48 @@ export type ActivityCreateDto = { export type ActivityStatisticsResponseDto = { comments: number; }; +export type NotificationCreateDto = { + data?: object; + description?: string | null; + level?: NotificationLevel; + readAt?: string | null; + title: string; + "type"?: NotificationType; + userId: string; +}; +export type NotificationDto = { + createdAt: string; + data?: object; + description?: string; + id: string; + level: NotificationLevel; + readAt?: string; + title: string; + "type": NotificationType; +}; +export type TemplateDto = { + template: string; +}; +export type TemplateResponseDto = { + html: string; + name: string; +}; +export type SystemConfigSmtpTransportDto = { + host: string; + ignoreCert: boolean; + password: string; + port: number; + username: string; +}; +export type SystemConfigSmtpDto = { + enabled: boolean; + "from": string; + replyTo: string; + transport: SystemConfigSmtpTransportDto; +}; +export type TestEmailResponseDto = { + messageId: string; +}; export type UserLicense = { activatedAt: string; activationKey: string; @@ -64,6 +106,7 @@ export type UserAdminResponseDto = { updatedAt: string; }; export type UserAdminCreateDto = { + avatarColor?: (UserAvatarColor) | null; email: string; name: string; notify?: boolean; @@ -76,6 +119,7 @@ export type UserAdminDeleteDto = { force?: boolean; }; export type UserAdminUpdateDto = { + avatarColor?: (UserAvatarColor) | null; email?: string; name?: string; password?: string; @@ -83,9 +127,6 @@ export type UserAdminUpdateDto = { shouldChangePassword?: boolean; storageLabel?: string | null; }; -export type AvatarResponse = { - color: UserAvatarColor; -}; export type DownloadResponse = { archiveSize: number; includeEmbeddedVideos: boolean; @@ -122,7 +163,6 @@ export type TagsResponse = { sidebarWeb: boolean; }; export type UserPreferencesResponseDto = { - avatar: AvatarResponse; download: DownloadResponse; emailNotifications: EmailNotificationsResponse; folders: FoldersResponse; @@ -663,28 +703,15 @@ export type MemoryUpdateDto = { memoryAt?: string; seenAt?: string; }; -export type TemplateDto = { - template: string; +export type NotificationDeleteAllDto = { + ids: string[]; }; -export type TemplateResponseDto = { - html: string; - name: string; +export type NotificationUpdateAllDto = { + ids: string[]; + readAt?: string | null; }; -export type SystemConfigSmtpTransportDto = { - host: string; - ignoreCert: boolean; - password: string; - port: number; - username: string; -}; -export type SystemConfigSmtpDto = { - enabled: boolean; - "from": string; - replyTo: string; - transport: SystemConfigSmtpTransportDto; -}; -export type TestEmailResponseDto = { - messageId: string; +export type NotificationUpdateDto = { + readAt?: string | null; }; export type OAuthConfigDto = { codeChallenge?: string; @@ -1288,6 +1315,8 @@ export type SystemConfigOAuthDto = { signingAlgorithm: string; storageLabelClaim: string; storageQuotaClaim: string; + timeout: number; + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; }; export type SystemConfigPasswordLoginDto = { enabled: boolean; @@ -1388,6 +1417,7 @@ export type TrashResponseDto = { count: number; }; export type UserUpdateMeDto = { + avatarColor?: (UserAvatarColor) | null; email?: string; name?: string; password?: string; @@ -1454,6 +1484,43 @@ export function deleteActivity({ id }: { method: "DELETE" })); } +export function createNotification({ notificationCreateDto }: { + notificationCreateDto: NotificationCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: NotificationDto; + }>("/admin/notifications", oazapfts.json({ + ...opts, + method: "POST", + body: notificationCreateDto + }))); +} +export function getNotificationTemplateAdmin({ name, templateDto }: { + name: string; + templateDto: TemplateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TemplateResponseDto; + }>(`/admin/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({ + ...opts, + method: "POST", + body: templateDto + }))); +} +export function sendTestEmailAdmin({ systemConfigSmtpDto }: { + systemConfigSmtpDto: SystemConfigSmtpDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TestEmailResponseDto; + }>("/admin/notifications/test-email", oazapfts.json({ + ...opts, + method: "POST", + body: systemConfigSmtpDto + }))); +} export function searchUsersAdmin({ withDeleted }: { withDeleted?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -2322,29 +2389,71 @@ export function addMemoryAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function getNotificationTemplateAdmin({ name, templateDto }: { - name: string; - templateDto: TemplateDto; +export function deleteNotifications({ notificationDeleteAllDto }: { + notificationDeleteAllDto: NotificationDeleteAllDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: TemplateResponseDto; - }>(`/notifications/admin/templates/${encodeURIComponent(name)}`, oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({ ...opts, - method: "POST", - body: templateDto + method: "DELETE", + body: notificationDeleteAllDto }))); } -export function sendTestEmailAdmin({ systemConfigSmtpDto }: { - systemConfigSmtpDto: SystemConfigSmtpDto; +export function getNotifications({ id, level, $type, unread }: { + id?: string; + level?: NotificationLevel; + $type?: NotificationType; + unread?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: TestEmailResponseDto; - }>("/notifications/admin/test-email", oazapfts.json({ + data: NotificationDto[]; + }>(`/notifications${QS.query(QS.explode({ + id, + level, + "type": $type, + unread + }))}`, { + ...opts + })); +} +export function updateNotifications({ notificationUpdateAllDto }: { + notificationUpdateAllDto: NotificationUpdateAllDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({ ...opts, - method: "POST", - body: systemConfigSmtpDto + method: "PUT", + body: notificationUpdateAllDto + }))); +} +export function deleteNotification({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/notifications/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +export function getNotification({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: NotificationDto; + }>(`/notifications/${encodeURIComponent(id)}`, { + ...opts + })); +} +export function updateNotification({ id, notificationUpdateDto }: { + id: string; + notificationUpdateDto: NotificationUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: NotificationDto; + }>(`/notifications/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: notificationUpdateDto }))); } export function startOAuth({ oAuthConfigDto }: { @@ -3453,6 +3562,18 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } +export enum NotificationLevel { + Success = "success", + Error = "error", + Warning = "warning", + Info = "info" +} +export enum NotificationType { + JobFailed = "JobFailed", + BackupFailed = "BackupFailed", + SystemMessage = "SystemMessage", + Custom = "Custom" +} export enum UserStatus { Active = "active", Removing = "removing", @@ -3527,6 +3648,10 @@ export enum Permission { MemoryRead = "memory.read", MemoryUpdate = "memory.update", MemoryDelete = "memory.delete", + NotificationCreate = "notification.create", + NotificationRead = "notification.read", + NotificationUpdate = "notification.update", + NotificationDelete = "notification.delete", PartnerCreate = "partner.create", PartnerRead = "partner.read", PartnerUpdate = "partner.update", @@ -3736,6 +3861,10 @@ export enum LogLevel { Error = "error", Fatal = "fatal" } +export enum OAuthTokenEndpointAuthMethod { + ClientSecretPost = "client_secret_post", + ClientSecretBasic = "client_secret_basic" +} export enum TimeBucketSize { Day = "DAY", Month = "MONTH" diff --git a/server/Dockerfile b/server/Dockerfile index 969666f18d..5c0ef076c4 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -26,7 +26,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:22.14.0-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS web +FROM node:22.15.0-alpine3.20@sha256:686b8892b69879ef5bfd6047589666933508f9a5451c67320df3070ba0e9807b AS web WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/server/package-lock.json b/server/package-lock.json index b1fdfa1f9d..4e451cc8e1 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -32,6 +32,7 @@ "chokidar": "^3.5.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "compression": "^1.8.0", "cookie": "^1.0.2", "cookie-parser": "^1.4.7", "exiftool-vendored": "^28.3.1", @@ -83,6 +84,7 @@ "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/bcrypt": "^5.0.0", + "@types/compression": "^1.7.5", "@types/cookie-parser": "^1.4.8", "@types/express": "^4.17.17", "@types/fluent-ffmpeg": "^2.1.21", @@ -173,14 +175,14 @@ } }, "node_modules/@angular-devkit/schematics-cli": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.6.tgz", - "integrity": "sha512-OCLVk1YbTWfaZwpKPnd+9A34eMAZIRjntdugGvfw21ok9dUA8gICGDhfYATSfnU8/AbVQMTPK5sgG0xhUEm3UA==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.8.tgz", + "integrity": "sha512-RFnlyu4Ld8I4xvu/eqrhjbQ6kQTr27w79omMiTbQcQZvP3E6oUyZdBjobyih4Np+1VVQrbdEeNz76daP2iUDig==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.6", - "@angular-devkit/schematics": "19.2.6", + "@angular-devkit/core": "19.2.8", + "@angular-devkit/schematics": "19.2.8", "@inquirer/prompts": "7.3.2", "ansi-colors": "4.1.3", "symbol-observable": "4.0.0", @@ -196,9 +198,9 @@ } }, "node_modules/@angular-devkit/schematics-cli/node_modules/@angular-devkit/core": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.6.tgz", - "integrity": "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.8.tgz", + "integrity": "sha512-kcxUHKf5Hi98r4gAvMP3ntJV8wuQ3/i6wuU9RcMP0UKUt2Rer5Ryis3MPqT92jvVVwg6lhrLIhXsFuWJMiYjXQ==", "dev": true, "license": "MIT", "dependencies": { @@ -223,6 +225,25 @@ } } }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@angular-devkit/schematics": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.8.tgz", + "integrity": "sha512-QsmFuYdAyeCyg9WF/AJBhFXDUfCwmDFTEbsv5t5KPSP6slhk0GoLNZApniiFytU2siRlSxVNpve2uATyYuAYkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.8", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", @@ -945,9 +966,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -995,9 +1016,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", - "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", + "version": "9.25.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz", + "integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==", "dev": true, "license": "MIT", "engines": { @@ -1028,19 +1049,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -2152,15 +2160,15 @@ } }, "node_modules/@nestjs/cli": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.6.tgz", - "integrity": "sha512-Xco8pTdWHCpTXPTYMkUGAE+C7JXvAv38oVUaQeL81o7UOAi39w8p456r+IjONN/7ekjzakWnqepDzuTtH5Xk5w==", + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.7.tgz", + "integrity": "sha512-svrP8j1R0/lQVJ8ZI3BlDtuZxmkvVJokUJSB04sr6uibunk2wHeVDDVLZvYBUorCdGU/RHJl1IufhqUBM91vAQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.6", - "@angular-devkit/schematics": "19.2.6", - "@angular-devkit/schematics-cli": "19.2.6", + "@angular-devkit/core": "19.2.8", + "@angular-devkit/schematics": "19.2.8", + "@angular-devkit/schematics-cli": "19.2.8", "@inquirer/prompts": "7.4.1", "@nestjs/schematics": "^11.0.1", "ansis": "3.17.0", @@ -2174,8 +2182,8 @@ "tree-kill": "1.2.2", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.2.0", - "typescript": "5.7.3", - "webpack": "5.98.0", + "typescript": "5.8.3", + "webpack": "5.99.6", "webpack-node-externals": "3.0.0" }, "bin": { @@ -2185,7 +2193,7 @@ "node": ">= 20.11" }, "peerDependencies": { - "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0", "@swc/core": "^1.3.62" }, "peerDependenciesMeta": { @@ -2198,9 +2206,9 @@ } }, "node_modules/@nestjs/cli/node_modules/@angular-devkit/core": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.6.tgz", - "integrity": "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.8.tgz", + "integrity": "sha512-kcxUHKf5Hi98r4gAvMP3ntJV8wuQ3/i6wuU9RcMP0UKUt2Rer5Ryis3MPqT92jvVVwg6lhrLIhXsFuWJMiYjXQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2225,6 +2233,25 @@ } } }, + "node_modules/@nestjs/cli/node_modules/@angular-devkit/schematics": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.8.tgz", + "integrity": "sha512-QsmFuYdAyeCyg9WF/AJBhFXDUfCwmDFTEbsv5t5KPSP6slhk0GoLNZApniiFytU2siRlSxVNpve2uATyYuAYkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.8", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, "node_modules/@nestjs/cli/node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -2299,27 +2326,15 @@ "node": ">= 8" } }, - "node_modules/@nestjs/cli/node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@nestjs/common": { - "version": "11.0.17", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.0.17.tgz", - "integrity": "sha512-FwKylI/hVxaNvzBJdWMMG1LH0cLKz4Oh4jKOHet2JUVMM9j6CuodRbrSnL++KL6PJY/b2E6AY58UDPLNeCqJWw==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.0.tgz", + "integrity": "sha512-8MrajltjtIN6eW9cTpv+1IZogqz2Zsrc8YDt0LwQPUq8cSq0j50DETdQpPsNMeib+p9avkV41+NrzGk1z2o5Wg==", "license": "MIT", "dependencies": { + "file-type": "20.4.1", "iterare": "1.2.1", + "load-esm": "1.0.2", "tslib": "2.8.1", "uid": "2.0.2" }, @@ -2330,7 +2345,6 @@ "peerDependencies": { "class-transformer": "*", "class-validator": "*", - "file-type": "^20.4.1", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, @@ -2340,16 +2354,13 @@ }, "class-validator": { "optional": true - }, - "file-type": { - "optional": true } } }, "node_modules/@nestjs/core": { - "version": "11.0.17", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.0.17.tgz", - "integrity": "sha512-ImK6qNxtegKqK7EJLGTBpP5Ild/DTpcduEtAOS+WLLjZOMjK1k214G9roXvlrNQwlVt9ALAY2jcqnsasdEd7Ow==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.0.tgz", + "integrity": "sha512-IeXbTRPrr6xAVbETlDE+miSkNmYf/cPhCa9GU9gFtPO6pVNuAeG/dNrjLVc23mJtUlT/ibdsoW35TlSyHLkzEA==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2421,9 +2432,9 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.0.17", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.0.17.tgz", - "integrity": "sha512-et6Ydd6dR0FlcE/WR/9VRnQoTqEpDdzBgGK+aWadA0dFJ65wlN+snJRg/9JGP4ngj90S6xwe0VKD/BbfUGj9cw==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.0.tgz", + "integrity": "sha512-lxv73GT9VdQaxndciqKcyzLsT2j3gMRX+tO6J06oa7RIfp4Dp4oMTIu57lM1gkIJ+gLGq29bob+mfPv/K8RIuw==", "license": "MIT", "dependencies": { "cors": "2.8.5", @@ -2442,9 +2453,9 @@ } }, "node_modules/@nestjs/platform-socket.io": { - "version": "11.0.17", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.0.17.tgz", - "integrity": "sha512-l9b8VNb7N7rB9IUwKeln2bMQDltsR9mpenzHOaYYqDkz5BtuQSiyT8NpLR2vWhxDjppxMY3DkW8fQAvXh54pMg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.0.tgz", + "integrity": "sha512-aCNuHln9RmT/qHkCr0/bcHxUP4rNU9hXK8O1Rd6EpDhJ9UcgMhatjkYDE95Tc7QgSgjLVscQ47pI2J8ik9b0VQ==", "license": "MIT", "dependencies": { "socket.io": "4.8.1", @@ -2597,9 +2608,9 @@ } }, "node_modules/@nestjs/swagger": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.1.3.tgz", - "integrity": "sha512-vhbW/Xu05Diti/EwYQp3Ea7Hj2M++wiakCcxqUUDA2n7NvCZC8LKsrcGynw6/x/lugdXyklYS+s2FhdAfeAikg==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.1.5.tgz", + "integrity": "sha512-qVkyUSCvEmfTVWK92hsCeOQaOODlyBGkZC4ldqb4Fi0Gg8/kOWlcPJVN6i4a9edYYSdICUkGnt6UVFgi59fSrQ==", "license": "MIT", "dependencies": { "@microsoft/tsdoc": "0.15.1", @@ -2630,9 +2641,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.0.17", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.0.17.tgz", - "integrity": "sha512-ryEx6fCYZFCsjEBZo8jOVikQluEHMESocVqHdXWOkkG7UqMPMHimf9gT2qij0GpNnYeDAGw+i7FhSJN3Cajoug==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.0.tgz", + "integrity": "sha512-gQ+NGshkHbNrDNXMVaPiwduqZ8YHpXrnsQqhSsnyNYOcDNPdBbB+0FDq7XiiklluXqjdLAN8i+bS7MbGlZIhKw==", "dev": true, "license": "MIT", "dependencies": { @@ -2658,9 +2669,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "11.0.17", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.0.17.tgz", - "integrity": "sha512-2LSjxA/lUKs5hv/g5lPk555CoRNTCt/XywHFteKMSrxo09Cq3yfOQOAPwEWG929EnqAjAAsQaDVbfUHUFisFCg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.0.tgz", + "integrity": "sha512-nb96cbmk7u6XIj4yIieezX9qqDshauyQJ4SLtdg2BaxOrkeQSx2j34CQWn/DZHHoYIQimfnAj2ry3RYWET4+zw==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -4913,6 +4924,30 @@ "testcontainers": "^10.24.2" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@turf/boolean-point-in-polygon": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.2.0.tgz", @@ -5009,6 +5044,16 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz", + "integrity": "sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -5482,17 +5527,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.30.1.tgz", - "integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz", + "integrity": "sha512-evaQJZ/J/S4wisevDvC1KFZkPzRetH8kYZbkgcTRyql3mcKsf+ZFDV1BVWUGTCAW5pQHoqn5gK5b8kn7ou9aFQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/type-utils": "8.30.1", - "@typescript-eslint/utils": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/type-utils": "8.31.0", + "@typescript-eslint/utils": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -5512,16 +5557,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.30.1.tgz", - "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.0.tgz", + "integrity": "sha512-67kYYShjBR0jNI5vsf/c3WG4u+zDnCTHTPqVMQguffaWWFs7artgwKmfwdifl+r6XyM5LYLas/dInj2T0SgJyw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/typescript-estree": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/typescript-estree": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "debug": "^4.3.4" }, "engines": { @@ -5537,14 +5582,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.30.1.tgz", - "integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.0.tgz", + "integrity": "sha512-knO8UyF78Nt8O/B64i7TlGXod69ko7z6vJD9uhSlm0qkAbGeRUSudcm0+K/4CrRjrpiHfBCjMWlc08Vav1xwcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1" + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5555,14 +5600,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.30.1.tgz", - "integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.0.tgz", + "integrity": "sha512-DJ1N1GdjI7IS7uRlzJuEDCgDQix3ZVYVtgeWEyhyn4iaoitpMBX6Ndd488mXSx0xah/cONAkEaYyylDyAeHMHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.30.1", - "@typescript-eslint/utils": "8.30.1", + "@typescript-eslint/typescript-estree": "8.31.0", + "@typescript-eslint/utils": "8.31.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -5579,9 +5624,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.30.1.tgz", - "integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.0.tgz", + "integrity": "sha512-Ch8oSjVyYyJxPQk8pMiP2FFGYatqXQfQIaMp+TpuuLlDachRWpUAeEu1u9B/v/8LToehUIWyiKcA/w5hUFRKuQ==", "dev": true, "license": "MIT", "engines": { @@ -5593,14 +5638,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.30.1.tgz", - "integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.0.tgz", + "integrity": "sha512-xLmgn4Yl46xi6aDSZ9KkyfhhtnYI15/CvHbpOy/eR5NWhK/BK8wc709KKwhAR0m4ZKRP7h07bm4BWUYOCuRpQQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -5646,16 +5691,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.30.1.tgz", - "integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.0.tgz", + "integrity": "sha512-qi6uPLt9cjTFxAb1zGNgTob4x9ur7xC6mHQJ8GwEzGMGE9tYniublmJaowOJ9V2jUzxrltTPfdG2nKlWsq0+Ww==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/typescript-estree": "8.30.1" + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/typescript-estree": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5670,13 +5715,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.30.1.tgz", - "integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.0.tgz", + "integrity": "sha512-QcGHmlRHWOl93o64ZUMNewCdwKGU6WItOU52H0djgNmn1EOrhVudrDzXz4OycCRSCPwFCDrE2iIt5vmuUdHxuQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", + "@typescript-eslint/types": "8.31.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -5688,9 +5733,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.1.tgz", - "integrity": "sha512-MgV6D2dhpD6Hp/uroUoAIvFqA8AuvXEFBC2eepG3WFc1pxTfdk1LEqqkWoWhjz+rytoqrnUUCdf6Lzco3iHkLQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.2.tgz", + "integrity": "sha512-XDdaDOeaTMAMYW7N63AqoK32sYUWbXnTkC6tEbVcu3RlU1bB9of32T+PGf8KZvxqLNqeXhafDFqCkwpf2+dyaQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5703,7 +5748,7 @@ "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", - "std-env": "^3.8.1", + "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, @@ -5711,8 +5756,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.1.1", - "vitest": "3.1.1" + "@vitest/browser": "3.1.2", + "vitest": "3.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5721,14 +5766,14 @@ } }, "node_modules/@vitest/expect": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", - "integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.2.tgz", + "integrity": "sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@vitest/spy": "3.1.2", + "@vitest/utils": "3.1.2", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -5737,13 +5782,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", - "integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.2.tgz", + "integrity": "sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", + "@vitest/spy": "3.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -5774,9 +5819,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz", + "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==", "dev": true, "license": "MIT", "dependencies": { @@ -5787,13 +5832,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", - "integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.2.tgz", + "integrity": "sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.1", + "@vitest/utils": "3.1.2", "pathe": "^2.0.3" }, "funding": { @@ -5801,13 +5846,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", - "integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.2.tgz", + "integrity": "sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.2", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -5816,9 +5861,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", - "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.2.tgz", + "integrity": "sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==", "dev": true, "license": "MIT", "dependencies": { @@ -5829,13 +5874,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz", + "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.2", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -7603,6 +7648,60 @@ "node": ">= 14" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", + "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -8683,20 +8782,20 @@ } }, "node_modules/eslint": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", - "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "version": "9.25.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.1.tgz", + "integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.13.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.24.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.25.1", + "@eslint/plugin-kit": "^0.2.8", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -9228,6 +9327,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -9274,6 +9379,24 @@ "stream-source": "0.3" } }, + "node_modules/file-type": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", + "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -11067,9 +11190,9 @@ } }, "node_modules/kysely": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.0.tgz", - "integrity": "sha512-hq8VcLy57Ww7oPTTVEOrT9ml+g8ehbbmEUkHmW4Xtubu+NHdKZi6SH6egmD4cjDhn3b/0s0h/6AjdPayOTJhNw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.2.tgz", + "integrity": "sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -11181,6 +11304,25 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/load-esm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.2.tgz", + "integrity": "sha512-nVAvWk/jeyrWyXEAs84mpQCYccxRqgKY4OznLuJhJCa0XsPSfdOIr2zvBZEj3IHEHbX97jjscKRRV539bW0Gpw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -12049,9 +12191,9 @@ } }, "node_modules/nestjs-cls": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-5.4.2.tgz", - "integrity": "sha512-KQPOhD7ya82gSEc+XDwFKERPMaWK95bzV4E2pLmx8oC1hfMNuVc4dkWmEKJiu+o0hCWP/v51iWNgOGHKnJ9Raw==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-5.4.3.tgz", + "integrity": "sha512-yHEHyVoe6rsvj3XRPFonBKPXPjDREyHfKZ9PTStSLJTZAV3wey1Q89TquSj6QciqXB5387GiHv9DG+ja6iAUHw==", "license": "MIT", "engines": { "node": ">=18" @@ -12995,15 +13137,28 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/peek-readable": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-7.0.0.tgz", + "integrity": "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/pg": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", - "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", + "version": "8.15.5", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.15.5.tgz", + "integrity": "sha512-EpAhHFQc+aH9VfeffWIVC+XXk6lmAhS9W1FxtxcPXs94yxhrI1I6w/zkWfIOII/OkBv3Be04X3xMOj0kQ78l6w==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.7.0", - "pg-pool": "^3.8.0", - "pg-protocol": "^1.8.0", + "pg-connection-string": "^2.8.5", + "pg-pool": "^3.9.5", + "pg-protocol": "^1.9.5", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -13011,7 +13166,7 @@ "node": ">= 8.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.1.1" + "pg-cloudflare": "^1.2.5" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -13023,16 +13178,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", - "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz", + "integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", - "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.8.5.tgz", + "integrity": "sha512-Ni8FuZ8yAF+sWZzojvtLE2b03cqjO5jNULcHFfM9ZZ0/JXrgom5pBREbtnAw7oxsxJqHw9Nz/XWORUEL3/IFow==", "license": "MIT" }, "node_modules/pg-int8": { @@ -13045,18 +13200,18 @@ } }, "node_modules/pg-pool": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", - "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.9.6.tgz", + "integrity": "sha512-rFen0G7adh1YmgvrmE5IPIqbb+IgEzENUm+tzm6MLLDSlPRoZVhzU1WdML9PV2W5GOdRA9qBKURlbt1OsXOsPw==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", - "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.9.5.tgz", + "integrity": "sha512-DYTWtWpfd5FOro3UnAfwvhD8jh59r2ig8bPtc9H8Ds7MscE/9NYruUQWFAOuraRl29jwcT2kyMFQ3MxeaVjUhg==", "license": "MIT" }, "node_modules/pg-types": { @@ -14431,9 +14586,9 @@ } }, "node_modules/sanitize-html": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.15.0.tgz", - "integrity": "sha512-wIjst57vJGpLyBP8ioUbg6ThwJie5SuSIjHxJg53v5Fg+kUK+AXlb7bK3RNXpp315MvwM+0OBGCV6h5pPHsVhA==", + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.16.0.tgz", + "integrity": "sha512-0s4caLuHHaZFVxFTG74oW91+j6vW7gKbGD6CD2+miP73CE6z6YtOBN0ArtLd2UGyi4IC7K47v3ENUbQX4jV3Mg==", "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", @@ -15098,9 +15253,9 @@ "license": "BSD-3-Clause" }, "node_modules/sql-formatter": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.5.2.tgz", - "integrity": "sha512-+9xZgiv1DP/c7GxkkBUHRZOf4j35gquVdwEm0rg16qKRYeFkv1+/vEeO13fsUbbz06KUotIyASJ+hyau8LM8Kg==", + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.6.1.tgz", + "integrity": "sha512-uoKbRLVbjzwa8ouY4lI9YM387zRxDv9Gg5kZBzu2iNls2wVBlDLshhudCstczddRvj7J+xOpHTTWX6Z0lRgYGA==", "dev": true, "license": "MIT", "dependencies": { @@ -15392,6 +15547,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strtok3": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.2.2.tgz", + "integrity": "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -16267,6 +16439,23 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.0.tgz", + "integrity": "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -16676,15 +16865,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.30.1.tgz", - "integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.31.0.tgz", + "integrity": "sha512-u+93F0sB0An8WEAPtwxVhFby573E8ckdjwUUQUj9QA4v8JAvgtoDdIyYR3XFwFHq2W1KJ1AurwJCO+w+Y1ixyQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.30.1", - "@typescript-eslint/parser": "8.30.1", - "@typescript-eslint/utils": "8.30.1" + "@typescript-eslint/eslint-plugin": "8.31.0", + "@typescript-eslint/parser": "8.31.0", + "@typescript-eslint/utils": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -16785,6 +16974,18 @@ "node": ">= 4.0.0" } }, + "node_modules/uint8array-extras": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", + "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", @@ -16877,9 +17078,9 @@ } }, "node_modules/unplugin-swc": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.5.1.tgz", - "integrity": "sha512-/ZLrPNjChhGx3Z95pxJ4tQgfI6rWqukgYHKflrNB4zAV1izOQuDhkTn55JWeivpBxDCoK7M/TStb2aS/14PS/g==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.5.2.tgz", + "integrity": "sha512-bf8DJO8lD1wpnwFglQpVH2XEaFfVsSU5C7yFyLwGT1gxskPtejlDeuttKxjtmHTSqrDsQrK0FCFdhw3Ny+K7hA==", "dev": true, "license": "MIT", "dependencies": { @@ -17080,9 +17281,9 @@ } }, "node_modules/vite-node": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", - "integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.2.tgz", + "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", "dev": true, "license": "MIT", "dependencies": { @@ -17210,31 +17411,32 @@ } }, "node_modules/vitest": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", - "integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.2.tgz", + "integrity": "sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.1.1", - "@vitest/mocker": "3.1.1", - "@vitest/pretty-format": "^3.1.1", - "@vitest/runner": "3.1.1", - "@vitest/snapshot": "3.1.1", - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@vitest/expect": "3.1.2", + "@vitest/mocker": "3.1.2", + "@vitest/pretty-format": "^3.1.2", + "@vitest/runner": "3.1.2", + "@vitest/snapshot": "3.1.2", + "@vitest/spy": "3.1.2", + "@vitest/utils": "3.1.2", "chai": "^5.2.0", "debug": "^4.4.0", - "expect-type": "^1.2.0", + "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", - "std-env": "^3.8.1", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.1", + "vite-node": "3.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -17250,8 +17452,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.1", - "@vitest/ui": "3.1.1", + "@vitest/browser": "3.1.2", + "@vitest/ui": "3.1.2", "happy-dom": "*", "jsdom": "*" }, @@ -17322,9 +17524,9 @@ "license": "BSD-2-Clause" }, "node_modules/webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", + "version": "5.99.6", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.6.tgz", + "integrity": "sha512-TJOLrJ6oeccsGWPl7ujCYuc0pIq2cNsuD6GZDma8i5o5Npvcco/z+NKvZSFsP0/x6SShVb0+X2JK/JHUjKY9dQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/server/package.json b/server/package.json index f68ba71564..33d1450a53 100644 --- a/server/package.json +++ b/server/package.json @@ -57,6 +57,7 @@ "chokidar": "^3.5.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "compression": "^1.8.0", "cookie": "^1.0.2", "cookie-parser": "^1.4.7", "exiftool-vendored": "^28.3.1", @@ -108,6 +109,7 @@ "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/bcrypt": "^5.0.0", + "@types/compression": "^1.7.5", "@types/cookie-parser": "^1.4.8", "@types/express": "^4.17.17", "@types/fluent-ffmpeg": "^2.1.21", diff --git a/server/src/config.ts b/server/src/config.ts index 566adbd693..a9fdffbd62 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -5,6 +5,7 @@ import { CQMode, ImageFormat, LogLevel, + OAuthTokenEndpointAuthMethod, QueueName, ToneMapping, TranscodeHWAccel, @@ -96,6 +97,8 @@ export interface SystemConfig { scope: string; signingAlgorithm: string; profileSigningAlgorithm: string; + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; + timeout: number; storageLabelClaim: string; storageQuotaClaim: string; }; @@ -260,6 +263,8 @@ export const defaults = Object.freeze({ profileSigningAlgorithm: 'none', storageLabelClaim: 'preferred_username', storageQuotaClaim: 'immich_quota', + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST, + timeout: 30_000, }, passwordLogin: { enabled: true, diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 0da0aac8b1..e36793b3d7 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -14,6 +14,7 @@ import { LibraryController } from 'src/controllers/library.controller'; import { MapController } from 'src/controllers/map.controller'; import { MemoryController } from 'src/controllers/memory.controller'; import { NotificationAdminController } from 'src/controllers/notification-admin.controller'; +import { NotificationController } from 'src/controllers/notification.controller'; import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; @@ -47,6 +48,7 @@ export const controllers = [ LibraryController, MapController, MemoryController, + NotificationController, NotificationAdminController, OAuthController, PartnerController, diff --git a/server/src/controllers/notification-admin.controller.ts b/server/src/controllers/notification-admin.controller.ts index 937244fc56..9bac865bdf 100644 --- a/server/src/controllers/notification-admin.controller.ts +++ b/server/src/controllers/notification-admin.controller.ts @@ -1,16 +1,28 @@ import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto'; +import { + NotificationCreateDto, + NotificationDto, + TemplateDto, + TemplateResponseDto, + TestEmailResponseDto, +} from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { EmailTemplate } from 'src/repositories/email.repository'; -import { NotificationService } from 'src/services/notification.service'; +import { NotificationAdminService } from 'src/services/notification-admin.service'; @ApiTags('Notifications (Admin)') -@Controller('notifications/admin') +@Controller('admin/notifications') export class NotificationAdminController { - constructor(private service: NotificationService) {} + constructor(private service: NotificationAdminService) {} + + @Post() + @Authenticated({ admin: true }) + createNotification(@Auth() auth: AuthDto, @Body() dto: NotificationCreateDto): Promise { + return this.service.create(auth, dto); + } @Post('test-email') @HttpCode(HttpStatus.OK) diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts new file mode 100644 index 0000000000..c64f786850 --- /dev/null +++ b/server/src/controllers/notification.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + NotificationDeleteAllDto, + NotificationDto, + NotificationSearchDto, + NotificationUpdateAllDto, + NotificationUpdateDto, +} from 'src/dtos/notification.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { NotificationService } from 'src/services/notification.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Notifications') +@Controller('notifications') +export class NotificationController { + constructor(private service: NotificationService) {} + + @Get() + @Authenticated({ permission: Permission.NOTIFICATION_READ }) + getNotifications(@Auth() auth: AuthDto, @Query() dto: NotificationSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Put() + @Authenticated({ permission: Permission.NOTIFICATION_UPDATE }) + updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise { + return this.service.updateAll(auth, dto); + } + + @Delete() + @Authenticated({ permission: Permission.NOTIFICATION_DELETE }) + deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise { + return this.service.deleteAll(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.NOTIFICATION_READ }) + getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.NOTIFICATION_UPDATE }) + updateNotification( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: NotificationUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @Authenticated({ permission: Permission.NOTIFICATION_DELETE }) + deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 9bd43a662d..1a8e31e86b 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -90,7 +90,7 @@ export class StorageCore { return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`); } - static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: ImageFormat) { + static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') { return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}-${type}.${format}`); } diff --git a/server/src/database.ts b/server/src/database.ts index 27094958ed..a93873ef42 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -9,6 +9,7 @@ import { Permission, SharedLinkType, SourceType, + UserAvatarColor, UserStatus, } from 'src/enum'; import { OnThisDayData, UserMetadataItem } from 'src/types'; @@ -122,6 +123,7 @@ export type User = { id: string; name: string; email: string; + avatarColor: UserAvatarColor | null; profileImagePath: string; profileChangedAt: Date; }; @@ -264,7 +266,15 @@ export type AssetFace = { person?: Person | null; }; -const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; +const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; +const userWithPrefixColumns = [ + 'users.id', + 'users.name', + 'users.email', + 'users.avatarColor', + 'users.profileImagePath', + 'users.profileChangedAt', +] as const; export const columns = { asset: [ @@ -306,7 +316,7 @@ export const columns = { 'shared_links.password', ], user: userColumns, - userWithPrefix: ['users.id', 'users.name', 'users.email', 'users.profileImagePath', 'users.profileChangedAt'], + userWithPrefix: userWithPrefixColumns, userAdmin: [ ...userColumns, 'createdAt', @@ -323,6 +333,7 @@ export const columns = { ], tag: ['tags.id', 'tags.value', 'tags.createdAt', 'tags.updatedAt', 'tags.color', 'tags.parentId'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], + notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'], syncAsset: [ 'id', 'ownerId', diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 4e9738ecec..85be9d5208 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -11,6 +11,8 @@ import { AssetStatus, AssetType, MemoryType, + NotificationLevel, + NotificationType, Permission, SharedLinkType, SourceType, @@ -263,6 +265,21 @@ export interface Memories { updateId: Generated; } +export interface Notifications { + id: Generated; + createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; + updateId: Generated; + userId: string; + level: Generated; + type: NotificationType; + title: string; + description: string | null; + data: any | null; + readAt: Timestamp | null; +} + export interface MemoriesAssetsAssets { assetsId: string; memoriesId: string; @@ -463,6 +480,7 @@ export interface DB { memories: Memories; memories_assets_assets: MemoriesAssetsAssets; migrations: Migrations; + notifications: Notifications; move_history: MoveHistory; naturalearth_countries: NaturalearthCountries; partners_audit: PartnersAudit; diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts index c1a09c801c..d9847cda17 100644 --- a/server/src/dtos/notification.dto.ts +++ b/server/src/dtos/notification.dto.ts @@ -1,4 +1,7 @@ -import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsString } from 'class-validator'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export class TestEmailResponseDto { messageId!: string; @@ -11,3 +14,106 @@ export class TemplateDto { @IsString() template!: string; } + +export class NotificationDto { + id!: string; + @ValidateDate() + createdAt!: Date; + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level!: NotificationLevel; + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type!: NotificationType; + title!: string; + description?: string; + data?: any; + readAt?: Date; +} + +export class NotificationSearchDto { + @Optional() + @ValidateUUID({ optional: true }) + id?: string; + + @IsEnum(NotificationLevel) + @Optional() + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level?: NotificationLevel; + + @IsEnum(NotificationType) + @Optional() + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type?: NotificationType; + + @ValidateBoolean({ optional: true }) + unread?: boolean; +} + +export class NotificationCreateDto { + @Optional() + @IsEnum(NotificationLevel) + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level?: NotificationLevel; + + @IsEnum(NotificationType) + @Optional() + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type?: NotificationType; + + @IsString() + title!: string; + + @IsString() + @Optional({ nullable: true }) + description?: string | null; + + @Optional({ nullable: true }) + data?: any; + + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; + + @ValidateUUID() + userId!: string; +} + +export class NotificationUpdateDto { + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; +} + +export class NotificationUpdateAllDto { + @ValidateUUID({ each: true, optional: true }) + ids!: string[]; + + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; +} + +export class NotificationDeleteAllDto { + @ValidateUUID({ each: true }) + ids!: string[]; +} + +export type MapNotification = { + id: string; + createdAt: Date; + updateId?: string; + level: NotificationLevel; + type: NotificationType; + data: any | null; + title: string; + description: string | null; + readAt: Date | null; +}; +export const mapNotification = (notification: MapNotification): NotificationDto => { + return { + id: notification.id, + createdAt: notification.createdAt, + level: notification.level, + type: notification.type, + title: notification.title, + description: notification.description ?? undefined, + data: notification.data ?? undefined, + readAt: notification.readAt ?? undefined, + }; +}; diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index eaef40a5e1..6991baf109 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -25,6 +25,7 @@ import { Colorspace, ImageFormat, LogLevel, + OAuthTokenEndpointAuthMethod, QueueName, ToneMapping, TranscodeHWAccel, @@ -33,7 +34,7 @@ import { VideoContainer, } from 'src/enum'; import { ConcurrentQueueName } from 'src/types'; -import { IsCronExpression, ValidateBoolean } from 'src/validation'; +import { IsCronExpression, Optional, ValidateBoolean } from 'src/validation'; const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled; const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled; @@ -344,10 +345,19 @@ class SystemConfigOAuthDto { clientId!: string; @ValidateIf(isOAuthEnabled) - @IsNotEmpty() @IsString() clientSecret!: string; + @IsEnum(OAuthTokenEndpointAuthMethod) + @ApiProperty({ enum: OAuthTokenEndpointAuthMethod, enumName: 'OAuthTokenEndpointAuthMethod' }) + tokenEndpointAuthMethod!: OAuthTokenEndpointAuthMethod; + + @IsInt() + @IsPositive() + @Optional() + @ApiProperty({ type: 'integer' }) + timeout!: number; + @IsNumber() @Min(0) defaultStorageQuota!: number; diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index fe92838fdb..a9d32523ae 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -137,11 +137,6 @@ export class UserPreferencesUpdateDto { purchase?: PurchaseUpdate; } -class AvatarResponse { - @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) - color!: UserAvatarColor; -} - class RatingsResponse { enabled: boolean = false; } @@ -195,7 +190,6 @@ export class UserPreferencesResponseDto implements UserPreferences { ratings!: RatingsResponse; sharedLinks!: SharedLinksResponse; tags!: TagsResponse; - avatar!: AvatarResponse; emailNotifications!: EmailNotificationsResponse; download!: DownloadResponse; purchase!: PurchaseResponse; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 72e5c83b35..31275f9c28 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,10 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; +import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; import { User, UserAdmin } from 'src/database'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserMetadataItem } from 'src/types'; -import { getPreferences } from 'src/utils/preferences'; import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; export class UserUpdateMeDto { @@ -23,6 +22,11 @@ export class UserUpdateMeDto { @IsString() @IsNotEmpty() name?: string; + + @Optional({ nullable: true }) + @IsEnum(UserAvatarColor) + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + avatarColor?: UserAvatarColor | null; } export class UserResponseDto { @@ -41,13 +45,21 @@ export class UserLicense { activatedAt!: Date; } +const emailToAvatarColor = (email: string): UserAvatarColor => { + const values = Object.values(UserAvatarColor); + const randomIndex = Math.floor( + [...email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length, + ); + return values[randomIndex]; +}; + export const mapUser = (entity: User | UserAdmin): UserResponseDto => { return { id: entity.id, email: entity.email, name: entity.name, profileImagePath: entity.profileImagePath, - avatarColor: getPreferences(entity.email, (entity as UserAdmin).metadata || []).avatar.color, + avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email), profileChangedAt: entity.profileChangedAt, }; }; @@ -69,6 +81,11 @@ export class UserAdminCreateDto { @IsString() name!: string; + @Optional({ nullable: true }) + @IsEnum(UserAvatarColor) + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + avatarColor?: UserAvatarColor | null; + @Optional({ nullable: true }) @IsString() @Transform(toSanitized) @@ -104,6 +121,11 @@ export class UserAdminUpdateDto { @IsNotEmpty() name?: string; + @Optional({ nullable: true }) + @IsEnum(UserAvatarColor) + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + avatarColor?: UserAvatarColor | null; + @Optional({ nullable: true }) @IsString() @Transform(toSanitized) diff --git a/server/src/enum.ts b/server/src/enum.ts index b9a914671a..4e725e1c13 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -126,6 +126,11 @@ export enum Permission { MEMORY_UPDATE = 'memory.update', MEMORY_DELETE = 'memory.delete', + NOTIFICATION_CREATE = 'notification.create', + NOTIFICATION_READ = 'notification.read', + NOTIFICATION_UPDATE = 'notification.update', + NOTIFICATION_DELETE = 'notification.delete', + PARTNER_CREATE = 'partner.create', PARTNER_READ = 'partner.read', PARTNER_UPDATE = 'partner.update', @@ -332,6 +337,11 @@ export enum ImageFormat { WEBP = 'webp', } +export enum RawExtractedFormat { + JPEG = 'jpeg', + JXL = 'jxl', +} + export enum LogLevel { VERBOSE = 'verbose', DEBUG = 'debug', @@ -515,6 +525,7 @@ export enum JobName { NOTIFY_SIGNUP = 'notify-signup', NOTIFY_ALBUM_INVITE = 'notify-album-invite', NOTIFY_ALBUM_UPDATE = 'notify-album-update', + NOTIFICATIONS_CLEANUP = 'notifications-cleanup', SEND_EMAIL = 'notification-send-email', // Version check @@ -580,3 +591,22 @@ export enum SyncEntityType { PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', PartnerAssetExifV1 = 'PartnerAssetExifV1', } + +export enum NotificationLevel { + Success = 'success', + Error = 'error', + Warning = 'warning', + Info = 'info', +} + +export enum NotificationType { + JobFailed = 'JobFailed', + BackupFailed = 'BackupFailed', + SystemMessage = 'SystemMessage', + Custom = 'Custom', +} + +export enum OAuthTokenEndpointAuthMethod { + CLIENT_SECRET_POST = 'client_secret_post', + CLIENT_SECRET_BASIC = 'client_secret_basic', +} diff --git a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts index 993e12f822..b5d47bb8cd 100644 --- a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts +++ b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts @@ -1,5 +1,5 @@ -import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; +import { vectorIndexQuery } from 'src/utils/database'; import { MigrationInterface, QueryRunner } from 'typeorm'; const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; @@ -8,15 +8,9 @@ export class AddCLIPEmbeddingIndex1700713994428 implements MigrationInterface { name = 'AddCLIPEmbeddingIndex1700713994428'; public async up(queryRunner: QueryRunner): Promise { - if (vectorExtension === DatabaseExtension.VECTORS) { - await queryRunner.query(`SET vectors.pgvector_compatibility=on`); - } await queryRunner.query(`SET search_path TO "$user", public, vectors`); - await queryRunner.query(` - CREATE INDEX IF NOT EXISTS clip_index ON smart_search - USING hnsw (embedding vector_cosine_ops) - WITH (ef_construction = 300, m = 16)`); + await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })); } public async down(queryRunner: QueryRunner): Promise { diff --git a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts index 182aae4e42..2b41788fe4 100644 --- a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts +++ b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts @@ -1,5 +1,5 @@ -import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; +import { vectorIndexQuery } from 'src/utils/database'; import { MigrationInterface, QueryRunner } from 'typeorm'; const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; @@ -8,15 +8,9 @@ export class AddFaceEmbeddingIndex1700714033632 implements MigrationInterface { name = 'AddFaceEmbeddingIndex1700714033632'; public async up(queryRunner: QueryRunner): Promise { - if (vectorExtension === DatabaseExtension.VECTORS) { - await queryRunner.query(`SET vectors.pgvector_compatibility=on`); - } await queryRunner.query(`SET search_path TO "$user", public, vectors`); - await queryRunner.query(` - CREATE INDEX IF NOT EXISTS face_index ON asset_faces - USING hnsw (embedding vector_cosine_ops) - WITH (ef_construction = 300, m = 16)`); + await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'asset_faces', indexName: 'face_index' })); } public async down(queryRunner: QueryRunner): Promise { diff --git a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts index e08bcb8e25..64849708d2 100644 --- a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts +++ b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts @@ -1,5 +1,6 @@ import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; +import { vectorIndexQuery } from 'src/utils/database'; import { MigrationInterface, QueryRunner } from 'typeorm'; const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; @@ -8,7 +9,6 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET search_path TO "$user", public, vectors`); - await queryRunner.query(`SET vectors.pgvector_compatibility=on`); } const hasEmbeddings = async (tableName: string): Promise => { @@ -47,21 +47,14 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE real[]`); await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512)`); - await queryRunner.query(` - CREATE INDEX IF NOT EXISTS clip_index ON smart_search - USING hnsw (embedding vector_cosine_ops) - WITH (ef_construction = 300, m = 16)`); + await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })); - await queryRunner.query(` - CREATE INDEX face_index ON face_search - USING hnsw (embedding vector_cosine_ops) - WITH (ef_construction = 300, m = 16)`); + await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'face_search', indexName: 'face_index' })); } public async down(queryRunner: QueryRunner): Promise { if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET search_path TO "$user", public, vectors`); - await queryRunner.query(`SET vectors.pgvector_compatibility=on`); } await queryRunner.query(`ALTER TABLE asset_faces ADD COLUMN "embedding" vector(512)`); @@ -74,9 +67,6 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { WHERE id = fs."faceId"`); await queryRunner.query(`DROP TABLE face_search`); - await queryRunner.query(` - CREATE INDEX face_index ON asset_faces - USING hnsw (embedding vector_cosine_ops) - WITH (ef_construction = 300, m = 16)`); + await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'asset_faces', indexName: 'face_index' })); } } diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index dd58aebcb2..03f1af3b28 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -157,6 +157,15 @@ where and "memories"."ownerId" = $2 and "memories"."deletedAt" is null +-- AccessRepository.notification.checkOwnerAccess +select + "notifications"."id" +from + "notifications" +where + "notifications"."id" in ($1) + and "notifications"."userId" = $2 + -- AccessRepository.person.checkOwnerAccess select "person"."id" diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql index c6e4c60a19..3040de8e03 100644 --- a/server/src/queries/activity.repository.sql +++ b/server/src/queries/activity.repository.sql @@ -13,6 +13,7 @@ from "users"."id", "users"."name", "users"."email", + "users"."avatarColor", "users"."profileImagePath", "users"."profileChangedAt" from @@ -44,6 +45,7 @@ returning "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index b89cbfb0b9..f4eb6a9929 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -12,6 +12,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -36,6 +37,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -100,6 +102,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -124,6 +127,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -191,6 +195,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -215,6 +220,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -269,6 +275,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -292,6 +299,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -353,6 +361,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index bf96ae80d6..67a3b8021d 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -194,6 +194,24 @@ where "asset_files"."assetId" = $1 and "asset_files"."type" = $2 +-- AssetJobRepository.streamForEncodeClip +select + "assets"."id" +from + "assets" + inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id" +where + "job_status"."previewAt" is not null + and "assets"."isVisible" = $1 + and not exists ( + select + from + "smart_search" + where + "assetId" = "assets"."id" + ) + and "assets"."deletedAt" is null + -- AssetJobRepository.getForClipEncoding select "assets"."id", @@ -259,6 +277,130 @@ from where "assets"."id" = $2 +-- AssetJobRepository.getForSyncAssets +select + "assets"."id", + "assets"."isOffline", + "assets"."libraryId", + "assets"."originalPath", + "assets"."status", + "assets"."fileModifiedAt" +from + "assets" +where + "assets"."id" = any ($1::uuid[]) + +-- AssetJobRepository.getForAssetDeletion +select + "assets"."id", + "assets"."isVisible", + "assets"."libraryId", + "assets"."ownerId", + "assets"."livePhotoVideoId", + "assets"."sidecarPath", + "assets"."encodedVideoPath", + "assets"."originalPath", + to_json("exif") as "exifInfo", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_faces".*, + "person" as "person" + from + "asset_faces" + left join lateral ( + select + "person".* + from + "person" + where + "asset_faces"."personId" = "person"."id" + ) as "person" on true + where + "asset_faces"."assetId" = "assets"."id" + and "asset_faces"."deletedAt" is null + ) as agg + ) as "faces", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_files"."id", + "asset_files"."path", + "asset_files"."type" + from + "asset_files" + where + "asset_files"."assetId" = "assets"."id" + ) as agg + ) as "files", + to_json("stacked_assets") as "stack" +from + "assets" + left join "exif" on "assets"."id" = "exif"."assetId" + left join "asset_stack" on "asset_stack"."id" = "assets"."stackId" + left join lateral ( + select + "asset_stack"."id", + "asset_stack"."primaryAssetId", + array_agg("stacked") as "assets" + from + "assets" as "stacked" + where + "stacked"."deletedAt" is not null + and "stacked"."isArchived" = $1 + and "stacked"."stackId" = "asset_stack"."id" + group by + "asset_stack"."id" + ) as "stacked_assets" on "asset_stack"."id" is not null +where + "assets"."id" = $2 + +-- AssetJobRepository.streamForVideoConversion +select + "assets"."id" +from + "assets" +where + "assets"."type" = $1 + and ( + "assets"."encodedVideoPath" is null + or "assets"."encodedVideoPath" = $2 + ) + and "assets"."isVisible" = $3 + and "assets"."deletedAt" is null + +-- AssetJobRepository.getForVideoConversion +select + "assets"."id", + "assets"."ownerId", + "assets"."originalPath", + "assets"."encodedVideoPath" +from + "assets" +where + "assets"."id" = $1 + and "assets"."type" = $2 + +-- AssetJobRepository.streamForMetadataExtraction +select + "assets"."id" +from + "assets" + left join "asset_job_status" on "asset_job_status"."assetId" = "assets"."id" +where + ( + "asset_job_status"."metadataExtractedAt" is null + or "asset_job_status"."assetId" is null + ) + and "assets"."isVisible" = $1 + and "assets"."deletedAt" is null + -- AssetJobRepository.getForStorageTemplateJob select "assets"."id", diff --git a/server/src/queries/notification.repository.sql b/server/src/queries/notification.repository.sql new file mode 100644 index 0000000000..c55e00d226 --- /dev/null +++ b/server/src/queries/notification.repository.sql @@ -0,0 +1,58 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- NotificationRepository.cleanup +delete from "notifications" +where + ( + ( + "deletedAt" is not null + and "deletedAt" < $1 + ) + or ( + "readAt" > $2 + and "createdAt" < $3 + ) + or ( + "readAt" = $4 + and "createdAt" < $5 + ) + ) + +-- NotificationRepository.search +select + "id", + "createdAt", + "level", + "type", + "title", + "description", + "data", + "readAt" +from + "notifications" +where + "userId" = $1 + and "deletedAt" is null +order by + "createdAt" desc + +-- NotificationRepository.search (unread) +select + "id", + "createdAt", + "level", + "type", + "title", + "description", + "data", + "readAt" +from + "notifications" +where + ( + "userId" = $1 + and "readAt" is null + ) + and "deletedAt" is null +order by + "createdAt" desc diff --git a/server/src/queries/partner.repository.sql b/server/src/queries/partner.repository.sql index e115dc34b9..e7170f367e 100644 --- a/server/src/queries/partner.repository.sql +++ b/server/src/queries/partner.repository.sql @@ -12,6 +12,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -29,6 +30,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -61,6 +63,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -78,6 +81,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -112,6 +116,7 @@ returning "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -129,6 +134,7 @@ returning "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -156,6 +162,7 @@ returning "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -173,6 +180,7 @@ returning "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 1212d0f2bd..e8ab5018fc 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -5,6 +5,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -43,6 +44,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -90,6 +92,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -128,6 +131,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -152,6 +156,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -198,6 +203,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -235,6 +241,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 961cccbf3e..c24209e482 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -279,6 +279,26 @@ class AuthDeviceAccess { } } +class NotificationAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, notificationIds: Set) { + if (notificationIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('notifications') + .select('notifications.id') + .where('notifications.id', 'in', [...notificationIds]) + .where('notifications.userId', '=', userId) + .execute() + .then((stacks) => new Set(stacks.map((stack) => stack.id))); + } +} + class StackAccess { constructor(private db: Kysely) {} @@ -426,6 +446,7 @@ export class AccessRepository { asset: AssetAccess; authDevice: AuthDeviceAccess; memory: MemoryAccess; + notification: NotificationAccess; person: PersonAccess; partner: PartnerAccess; stack: StackAccess; @@ -438,6 +459,7 @@ export class AccessRepository { this.asset = new AssetAccess(db); this.authDevice = new AuthDeviceAccess(db); this.memory = new MemoryAccess(db); + this.notification = new NotificationAccess(db); this.person = new PersonAccess(db); this.partner = new PartnerAccess(db); this.stack = new StackAccess(db); diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 4a2d52566f..262cf6217c 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -2,12 +2,21 @@ import { Injectable } from '@nestjs/common'; import { Kysely } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; -import { columns } from 'src/database'; +import { Asset, columns } from 'src/database'; import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileType } from 'src/enum'; +import { AssetFileType, AssetType } from 'src/enum'; import { StorageAsset } from 'src/types'; -import { anyUuid, asUuid, withExifInner, withFaces, withFiles } from 'src/utils/database'; +import { + anyUuid, + asUuid, + toJson, + withExif, + withExifInner, + withFaces, + withFacesAndPeople, + withFiles, +} from 'src/utils/database'; @Injectable() export class AssetJobRepository { @@ -126,6 +135,23 @@ export class AssetJobRepository { .execute(); } + @GenerateSql({ params: [], stream: true }) + streamForEncodeClip(force?: boolean) { + return this.db + .selectFrom('assets') + .select(['assets.id']) + .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') + .where('job_status.previewAt', 'is not', null) + .where('assets.isVisible', '=', true) + .$if(!force, (qb) => + qb.where((eb) => + eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))), + ), + ) + .where('assets.deletedAt', 'is', null) + .stream(); + } + @GenerateSql({ params: [DummyValue.UUID] }) getForClipEncoding(id: string) { return this.db @@ -148,6 +174,7 @@ export class AssetJobRepository { .executeTakeFirst(); } + @GenerateSql({ params: [[DummyValue.UUID]] }) getForSyncAssets(ids: string[]) { return this.db .selectFrom('assets') @@ -163,6 +190,84 @@ export class AssetJobRepository { .execute(); } + @GenerateSql({ params: [DummyValue.UUID] }) + getForAssetDeletion(id: string) { + return this.db + .selectFrom('assets') + .select([ + 'assets.id', + 'assets.isVisible', + 'assets.libraryId', + 'assets.ownerId', + 'assets.livePhotoVideoId', + 'assets.sidecarPath', + 'assets.encodedVideoPath', + 'assets.originalPath', + ]) + .$call(withExif) + .select(withFacesAndPeople) + .select(withFiles) + .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') + .leftJoinLateral( + (eb) => + eb + .selectFrom('assets as stacked') + .select(['asset_stack.id', 'asset_stack.primaryAssetId']) + .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) + .where('stacked.deletedAt', 'is not', null) + .where('stacked.isArchived', '=', false) + .whereRef('stacked.stackId', '=', 'asset_stack.id') + .groupBy('asset_stack.id') + .as('stacked_assets'), + (join) => join.on('asset_stack.id', 'is not', null), + ) + .select((eb) => toJson(eb, 'stacked_assets').as('stack')) + .where('assets.id', '=', id) + .executeTakeFirst(); + } + + @GenerateSql({ params: [], stream: true }) + streamForVideoConversion(force?: boolean) { + return this.db + .selectFrom('assets') + .select(['assets.id']) + .where('assets.type', '=', AssetType.VIDEO) + .$if(!force, (qb) => + qb + .where((eb) => eb.or([eb('assets.encodedVideoPath', 'is', null), eb('assets.encodedVideoPath', '=', '')])) + .where('assets.isVisible', '=', true), + ) + .where('assets.deletedAt', 'is', null) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getForVideoConversion(id: string) { + return this.db + .selectFrom('assets') + .select(['assets.id', 'assets.ownerId', 'assets.originalPath', 'assets.encodedVideoPath']) + .where('assets.id', '=', id) + .where('assets.type', '=', AssetType.VIDEO) + .executeTakeFirst(); + } + + @GenerateSql({ params: [], stream: true }) + streamForMetadataExtraction(force?: boolean) { + return this.db + .selectFrom('assets') + .select(['assets.id']) + .$if(!force, (qb) => + qb + .leftJoin('asset_job_status', 'asset_job_status.assetId', 'assets.id') + .where((eb) => + eb.or([eb('asset_job_status.metadataExtractedAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]), + ) + .where('assets.isVisible', '=', true), + ) + .where('assets.deletedAt', 'is', null) + .stream(); + } + private storageTemplateAssetQuery() { return this.db .selectFrom('assets') diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 7a68ba907f..ab7d9c80ea 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -49,7 +49,6 @@ export enum WithoutProperty { THUMBNAIL = 'thumbnail', ENCODED_VIDEO = 'encoded-video', EXIF = 'exif', - SMART_SEARCH = 'smart-search', DUPLICATE = 'duplicate', FACES = 'faces', SIDECAR = 'sidecar', @@ -571,15 +570,6 @@ export class AssetRepository { .where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)])) .where('assets.isVisible', '=', true), ) - .$if(property === WithoutProperty.SMART_SEARCH, (qb) => - qb - .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') - .where('job_status.previewAt', 'is not', null) - .where('assets.isVisible', '=', true) - .where((eb) => - eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))), - ), - ) .$if(property === WithoutProperty.THUMBNAIL, (qb) => qb .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index a402c9d28d..addf6bcff0 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -12,6 +12,7 @@ import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; +import { vectorIndexQuery } from 'src/utils/database'; import { isValidInteger } from 'src/validation'; import { DataSource } from 'typeorm'; @@ -119,12 +120,7 @@ export class DatabaseRepository { await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE vector(${sql.raw(String(dimSize))})`.execute( tx, ); - await sql`SET vectors.pgvector_compatibility=on`.execute(tx); - await sql` - CREATE INDEX IF NOT EXISTS ${sql.raw(index)} ON ${sql.raw(table)} - USING hnsw (embedding vector_cosine_ops) - WITH (ef_construction = 300, m = 16) - `.execute(tx); + await sql.raw(vectorIndexQuery({ vectorExtension: this.vectorExtension, table, indexName: index })).execute(tx); }); } } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 3156804d09..b41c007ef5 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -14,6 +14,7 @@ import { SystemConfig } from 'src/config'; import { EventConfig } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { NotificationDto } from 'src/dtos/notification.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ImmichWorker, MetadataKey, QueueName } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -64,6 +65,7 @@ type EventMap = { 'assets.restore': [{ assetIds: string[]; userId: string }]; 'job.start': [QueueName, JobItem]; + 'job.failed': [{ job: JobItem; error: Error | any }]; // session events 'session.delete': [{ sessionId: string }]; @@ -104,6 +106,7 @@ export interface ClientEventMap { on_server_version: [ServerVersionResponseDto]; on_config_update: []; on_new_release: [ReleaseNotification]; + on_notification: [NotificationDto]; on_session_delete: [string]; } diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index bd2e5c6774..453e515fe0 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -22,6 +22,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -55,6 +56,7 @@ export const repositories = [ CryptoRepository, DatabaseRepository, DownloadRepository, + EmailRepository, EventRepository, JobRepository, LibraryRepository, @@ -65,7 +67,7 @@ export const repositories = [ MemoryRepository, MetadataRepository, MoveRepository, - EmailRepository, + NotificationRepository, OAuthRepository, PartnerRepository, PersonRepository, diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 05d2d45f4d..2ac3715a50 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -5,7 +5,7 @@ import { Telemetry } from 'src/decorators'; import { LogLevel } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; -type LogDetails = any[]; +type LogDetails = any; type LogFunction = () => string; const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 1e41dd6bb2..d0ced19a6e 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -7,7 +7,7 @@ import { Writable } from 'node:stream'; import sharp from 'sharp'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; import { Exif } from 'src/database'; -import { Colorspace, LogLevel } from 'src/enum'; +import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { DecodeToBufferOptions, @@ -36,34 +36,51 @@ type ProgressEvent = { percent?: number; }; +export type ExtractResult = { + buffer: Buffer; + format: RawExtractedFormat; +}; + @Injectable() export class MediaRepository { constructor(private logger: LoggingRepository) { this.logger.setContext(MediaRepository.name); } - async extract(input: string, output: string): Promise { + /** + * + * @param input file path to the input image + * @returns ExtractResult if succeeded, or null if failed + */ + async extract(input: string): Promise { try { - // remove existing output file if it exists - // as exiftool-vendored does not support overwriting via "-w!" flag - // and throws "1 files could not be read" error when the output file exists - await fs.unlink(output).catch(() => null); - await exiftool.extractBinaryTag('JpgFromRaw2', input, output); - } catch { - try { - this.logger.debug('Extracting JPEG from RAW image:', input); - await exiftool.extractJpgFromRaw(input, output); - } catch (error: any) { - this.logger.debug('Could not extract JPEG from image, trying preview', error.message); - try { - await exiftool.extractPreview(input, output); - } catch (error: any) { - this.logger.debug('Could not extract preview from image', error.message); - return false; - } - } + const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input); + return { buffer, format: RawExtractedFormat.JPEG }; + } catch (error: any) { + this.logger.debug('Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next', error.message); + } + + try { + const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input); + return { buffer, format: RawExtractedFormat.JPEG }; + } catch (error: any) { + this.logger.debug('Could not extract JPEG buffer from image, trying PreviewJXL next', error.message); + } + + try { + const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input); + return { buffer, format: RawExtractedFormat.JXL }; + } catch (error: any) { + this.logger.debug('Could not extract PreviewJXL buffer from image, trying PreviewImage next', error.message); + } + + try { + const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input); + return { buffer, format: RawExtractedFormat.JPEG }; + } catch (error: any) { + this.logger.debug('Could not extract preview buffer from image', error.message); + return null; } - return true; } async writeExif(tags: Partial, output: string): Promise { @@ -104,7 +121,7 @@ export class MediaRepository { } } - decodeImage(input: string, options: DecodeToBufferOptions) { + decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); } @@ -235,7 +252,7 @@ export class MediaRepository { }); } - async getImageDimensions(input: string): Promise { + async getImageDimensions(input: string | Buffer): Promise { const { width = 0, height = 0 } = await sharp(input).metadata(); return { width, height }; } diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts new file mode 100644 index 0000000000..112bb97e60 --- /dev/null +++ b/server/src/repositories/notification.repository.ts @@ -0,0 +1,103 @@ +import { Insertable, Kysely, Updateable } from 'kysely'; +import { DateTime } from 'luxon'; +import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; +import { DB, Notifications } from 'src/db'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { NotificationSearchDto } from 'src/dtos/notification.dto'; + +export class NotificationRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID] }) + cleanup() { + return this.db + .deleteFrom('notifications') + .where((eb) => + eb.or([ + // remove soft-deleted notifications + eb.and([eb('deletedAt', 'is not', null), eb('deletedAt', '<', DateTime.now().minus({ days: 3 }).toJSDate())]), + + // remove old, read notifications + eb.and([ + // keep recently read messages around for a few days + eb('readAt', '>', DateTime.now().minus({ days: 2 }).toJSDate()), + eb('createdAt', '<', DateTime.now().minus({ days: 15 }).toJSDate()), + ]), + + eb.and([ + // remove super old, unread notifications + eb('readAt', '=', null), + eb('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate()), + ]), + ]), + ) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, {}] }, { name: 'unread', params: [DummyValue.UUID, { unread: true }] }) + search(userId: string, dto: NotificationSearchDto) { + return this.db + .selectFrom('notifications') + .select(columns.notification) + .where((qb) => + qb.and({ + userId, + id: dto.id, + level: dto.level, + type: dto.type, + readAt: dto.unread ? null : undefined, + }), + ) + .where('deletedAt', 'is', null) + .orderBy('createdAt', 'desc') + .execute(); + } + + create(notification: Insertable) { + return this.db + .insertInto('notifications') + .values(notification) + .returning(columns.notification) + .executeTakeFirstOrThrow(); + } + + get(id: string) { + return this.db + .selectFrom('notifications') + .select(columns.notification) + .where('id', '=', id) + .where('deletedAt', 'is not', null) + .executeTakeFirst(); + } + + update(id: string, notification: Updateable) { + return this.db + .updateTable('notifications') + .set(notification) + .where('deletedAt', 'is', null) + .where('id', '=', id) + .returning(columns.notification) + .executeTakeFirstOrThrow(); + } + + async updateAll(ids: string[], notification: Updateable) { + await this.db.updateTable('notifications').set(notification).where('id', 'in', ids).execute(); + } + + async delete(id: string) { + await this.db + .updateTable('notifications') + .set({ deletedAt: DateTime.now().toJSDate() }) + .where('id', '=', id) + .execute(); + } + + async deleteAll(ids: string[]) { + await this.db + .updateTable('notifications') + .set({ deletedAt: DateTime.now().toJSDate() }) + .where('id', 'in', ids) + .execute(); + } +} diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index d3e0372089..ea9f0b1901 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -1,16 +1,19 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import type { UserInfoResponse } from 'openid-client' with { 'resolution-mode': 'import' }; +import { OAuthTokenEndpointAuthMethod } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; export type OAuthConfig = { clientId: string; - clientSecret: string; + clientSecret?: string; issuerUrl: string; mobileOverrideEnabled: boolean; mobileRedirectUri: string; profileSigningAlgorithm: string; scope: string; signingAlgorithm: string; + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; + timeout: number; }; export type OAuthProfile = UserInfoResponse; @@ -76,12 +79,10 @@ export class OAuthRepository { ); } - if (error.code === 'OAUTH_INVALID_RESPONSE') { - this.logger.warn(`Invalid response from authorization server. Cause: ${error.cause?.message}`); - throw error.cause; - } + this.logger.error(`OAuth login failed: ${error.message}`); + this.logger.error(error); - throw error; + throw new Error('OAuth login failed', { cause: error }); } } @@ -103,6 +104,8 @@ export class OAuthRepository { clientSecret, profileSigningAlgorithm, signingAlgorithm, + tokenEndpointAuthMethod, + timeout, }: OAuthConfig) { try { const { allowInsecureRequests, discovery } = await import('openid-client'); @@ -114,14 +117,38 @@ export class OAuthRepository { response_types: ['code'], userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm, id_token_signed_response_alg: signingAlgorithm, - timeout: 30_000, }, - undefined, - { execute: [allowInsecureRequests] }, + await this.getTokenAuthMethod(tokenEndpointAuthMethod, clientSecret), + { + execute: [allowInsecureRequests], + timeout, + }, ); } catch (error: any | AggregateError) { this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors); throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error }); } } + + private async getTokenAuthMethod(tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod, clientSecret?: string) { + const { None, ClientSecretPost, ClientSecretBasic } = await import('openid-client'); + + if (!clientSecret) { + return None(); + } + + switch (tokenEndpointAuthMethod) { + case OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST: { + return ClientSecretPost(clientSecret); + } + + case OAuthTokenEndpointAuthMethod.CLIENT_SECRET_BASIC: { + return ClientSecretBasic(clientSecret); + } + + default: { + return None(); + } + } + } } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 5c1ebae69d..0c958fec02 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -6,7 +6,8 @@ import { DB, Exif } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetStatus, AssetType } from 'src/enum'; -import { anyUuid, asUuid, searchAssetBuilder } from 'src/utils/database'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database'; import { isValidInteger } from 'src/validation'; export interface SearchResult { @@ -201,7 +202,10 @@ export interface GetCameraMakesOptions { @Injectable() export class SearchRepository { - constructor(@InjectKysely() private db: Kysely) {} + constructor( + @InjectKysely() private db: Kysely, + private configRepository: ConfigRepository, + ) {} @GenerateSql({ params: [ @@ -446,8 +450,8 @@ export class SearchRepository { async upsert(assetId: string, embedding: string): Promise { await this.db .insertInto('smart_search') - .values({ assetId: asUuid(assetId), embedding } as any) - .onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding } as any)) + .values({ assetId, embedding }) + .onConflict((oc) => oc.column('assetId').doUpdateSet((eb) => ({ embedding: eb.ref('excluded.embedding') }))) .execute(); } @@ -469,19 +473,32 @@ export class SearchRepository { return dimSize; } - setDimensionSize(dimSize: number): Promise { + async setDimensionSize(dimSize: number): Promise { if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { throw new Error(`Invalid CLIP dimension size: ${dimSize}`); } - return this.db.transaction().execute(async (trx) => { - await sql`truncate ${sql.table('smart_search')}`.execute(trx); + // this is done in two transactions to handle concurrent writes + await this.db.transaction().execute(async (trx) => { + await sql`delete from ${sql.table('smart_search')}`.execute(trx); + await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute(); + await sql`alter table ${sql.table('smart_search')} add constraint dim_size_constraint check (array_length(embedding::real[], 1) = ${sql.lit(dimSize)})`.execute( + trx, + ); + }); + + const vectorExtension = this.configRepository.getEnv().database.vectorExtension; + await this.db.transaction().execute(async (trx) => { + await sql`drop index if exists clip_index`.execute(trx); await trx.schema .alterTable('smart_search') .alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`))) .execute(); - await sql`reindex index clip_index`.execute(trx); + await sql.raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })).execute(trx); + await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute(); }); + + await sql`vacuum analyze ${sql.table('smart_search')}`.execute(this.db); } async deleteAllSearchEmbeddings(): Promise { diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index fe4b86d65c..c62681d049 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -28,6 +28,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table'; import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; import { MoveTable } from 'src/schema/tables/move.table'; import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table'; +import { NotificationTable } from 'src/schema/tables/notification.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonTable } from 'src/schema/tables/person.table'; @@ -46,14 +47,8 @@ import { UserTable } from 'src/schema/tables/user.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools'; -@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'vectors', 'plpgsql']) +@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' }) -@ConfigurationParameter({ - name: 'vectors.pgvector_compatibility', - value: () => 'on', - scope: 'user', - synchronize: false, -}) @Database({ name: 'immich' }) export class ImmichDatabase { tables = [ @@ -76,6 +71,7 @@ export class ImmichDatabase { MemoryTable, MoveTable, NaturalEarthCountriesTable, + NotificationTable, PartnerAuditTable, PartnerTable, PersonTable, diff --git a/server/src/schema/migrations/1744910873969-InitialMigration.ts b/server/src/schema/migrations/1744910873969-InitialMigration.ts index 459534a26a..ce4a37ae3b 100644 --- a/server/src/schema/migrations/1744910873969-InitialMigration.ts +++ b/server/src/schema/migrations/1744910873969-InitialMigration.ts @@ -2,6 +2,7 @@ import { Kysely, sql } from 'kysely'; import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { vectorIndexQuery } from 'src/utils/database'; const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; const lastMigrationSql = sql<{ name: string }>`SELECT "name" FROM "migrations" ORDER BY "timestamp" DESC LIMIT 1;`; @@ -29,7 +30,7 @@ export async function up(db: Kysely): Promise { await sql`CREATE EXTENSION IF NOT EXISTS "cube";`.execute(db); await sql`CREATE EXTENSION IF NOT EXISTS "earthdistance";`.execute(db); await sql`CREATE EXTENSION IF NOT EXISTS "pg_trgm";`.execute(db); - await sql`CREATE EXTENSION IF NOT EXISTS "vectors";`.execute(db); + await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(vectorExtension)}`.execute(db); await sql`CREATE OR REPLACE FUNCTION immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp()) RETURNS uuid VOLATILE LANGUAGE SQL @@ -108,7 +109,6 @@ export async function up(db: Kysely): Promise { $$;`.execute(db); if (vectorExtension === DatabaseExtension.VECTORS) { await sql`SET search_path TO "$user", public, vectors`.execute(db); - await sql`SET vectors.pgvector_compatibility=on`.execute(db); } await sql`CREATE TYPE "assets_status_enum" AS ENUM ('active','trashed','deleted');`.execute(db); await sql`CREATE TYPE "sourcetype" AS ENUM ('machine-learning','exif','manual');`.execute(db); @@ -293,7 +293,7 @@ export async function up(db: Kysely): Promise { await sql`CREATE INDEX "IDX_live_photo_cid" ON "exif" ("livePhotoCID")`.execute(db); await sql`CREATE INDEX "IDX_auto_stack_id" ON "exif" ("autoStackId")`.execute(db); await sql`CREATE INDEX "IDX_asset_exif_update_id" ON "exif" ("updateId")`.execute(db); - await sql`CREATE INDEX "face_index" ON "face_search" USING hnsw (embedding vector_cosine_ops) WITH (ef_construction = 300, m = 16)`.execute(db); + await sql.raw(vectorIndexQuery({ vectorExtension, table: 'face_search', indexName: 'face_index' })).execute(db); await sql`CREATE INDEX "IDX_geodata_gist_earthcoord" ON "geodata_places" (ll_to_earth_public(latitude, longitude))`.execute(db); await sql`CREATE INDEX "idx_geodata_places_name" ON "geodata_places" USING gin (f_unaccent("name") gin_trgm_ops)`.execute(db); await sql`CREATE INDEX "idx_geodata_places_admin2_name" ON "geodata_places" USING gin (f_unaccent("admin2Name") gin_trgm_ops)`.execute(db); @@ -316,7 +316,7 @@ export async function up(db: Kysely): Promise { await sql`CREATE INDEX "IDX_sharedlink_albumId" ON "shared_links" ("albumId")`.execute(db); await sql`CREATE INDEX "IDX_5b7decce6c8d3db9593d6111a6" ON "shared_link__asset" ("assetsId")`.execute(db); await sql`CREATE INDEX "IDX_c9fab4aa97ffd1b034f3d6581a" ON "shared_link__asset" ("sharedLinksId")`.execute(db); - await sql`CREATE INDEX "clip_index" ON "smart_search" USING hnsw (embedding vector_cosine_ops) WITH (ef_construction = 300, m = 16)`.execute(db); + await sql.raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })).execute(db); await sql`CREATE INDEX "IDX_d8ddd9d687816cc490432b3d4b" ON "session_sync_checkpoints" ("sessionId")`.execute(db); await sql`CREATE INDEX "IDX_session_sync_checkpoints_update_id" ON "session_sync_checkpoints" ("updateId")`.execute(db); await sql`CREATE INDEX "IDX_92e67dc508c705dd66c9461557" ON "tags" ("userId")`.execute(db); diff --git a/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts b/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts new file mode 100644 index 0000000000..28dca6658c --- /dev/null +++ b/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts @@ -0,0 +1,22 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), "userId" uuid, "level" character varying NOT NULL DEFAULT 'info', "type" character varying NOT NULL DEFAULT 'info', "data" jsonb, "title" character varying NOT NULL, "description" text, "readAt" timestamp with time zone);`.execute(db); + await sql`ALTER TABLE "notifications" ADD CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "notifications" ADD CONSTRAINT "FK_692a909ee0fa9383e7859f9b406" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`CREATE INDEX "IDX_notifications_update_id" ON "notifications" ("updateId")`.execute(db); + await sql`CREATE INDEX "IDX_692a909ee0fa9383e7859f9b40" ON "notifications" ("userId")`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "notifications_updated_at" + BEFORE UPDATE ON "notifications" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "notifications_updated_at" ON "notifications";`.execute(db); + await sql`DROP INDEX "IDX_notifications_update_id";`.execute(db); + await sql`DROP INDEX "IDX_692a909ee0fa9383e7859f9b40";`.execute(db); + await sql`ALTER TABLE "notifications" DROP CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a";`.execute(db); + await sql`ALTER TABLE "notifications" DROP CONSTRAINT "FK_692a909ee0fa9383e7859f9b406";`.execute(db); + await sql`DROP TABLE "notifications";`.execute(db); +} diff --git a/server/src/schema/migrations/1745244781846-AddUserAvatarColorColumn.ts b/server/src/schema/migrations/1745244781846-AddUserAvatarColorColumn.ts new file mode 100644 index 0000000000..5f3fdbedc8 --- /dev/null +++ b/server/src/schema/migrations/1745244781846-AddUserAvatarColorColumn.ts @@ -0,0 +1,14 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "users" ADD "avatarColor" character varying;`.execute(db); + await sql` + UPDATE "users" + SET "avatarColor" = "user_metadata"."value"->'avatar'->>'color' + FROM "user_metadata" + WHERE "users"."id" = "user_metadata"."userId" AND "user_metadata"."key" = 'preferences';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "users" DROP COLUMN "avatarColor";`.execute(db); +} diff --git a/server/src/schema/tables/notification.table.ts b/server/src/schema/tables/notification.table.ts new file mode 100644 index 0000000000..bf9b8bdf3b --- /dev/null +++ b/server/src/schema/tables/notification.table.ts @@ -0,0 +1,52 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { UserTable } from 'src/schema/tables/user.table'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, +} from 'src/sql-tools'; + +@Table('notifications') +@UpdatedAtTrigger('notifications_updated_at') +export class NotificationTable { + @PrimaryGeneratedColumn() + id!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @DeleteDateColumn() + deletedAt?: Date; + + @UpdateIdColumn({ indexName: 'IDX_notifications_update_id' }) + updateId?: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) + userId!: string; + + @Column({ default: NotificationLevel.Info }) + level!: NotificationLevel; + + @Column({ default: NotificationLevel.Info }) + type!: NotificationType; + + @Column({ type: 'jsonb', nullable: true }) + data!: any | null; + + @Column() + title!: string; + + @Column({ type: 'text', nullable: true }) + description!: string; + + @Column({ type: 'timestamp with time zone', nullable: true }) + readAt?: Date | null; +} diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index eeef923796..7525a739a6 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -1,6 +1,6 @@ import { ColumnType } from 'kysely'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserStatus } from 'src/enum'; +import { UserAvatarColor, UserStatus } from 'src/enum'; import { users_delete_audit } from 'src/schema/functions'; import { AfterDeleteTrigger, @@ -49,6 +49,9 @@ export class UserTable { @Column({ type: 'boolean', default: true }) shouldChangePassword!: Generated; + @Column({ default: null }) + avatarColor!: UserAvatarColor | null; + @DeleteDateColumn() deletedAt!: Timestamp | null; diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index a3f536ac33..af4fef5bd5 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -565,7 +565,7 @@ describe(AssetService.name, () => { it('should remove faces', async () => { const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] }; - mocks.asset.getById.mockResolvedValue(assetWithFace); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetWithFace); await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true }); @@ -592,7 +592,7 @@ describe(AssetService.name, () => { it('should update stack primary asset if deleted asset was primary asset in a stack', async () => { mocks.stack.update.mockResolvedValue(factory.stack() as any); - mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.primaryImage); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); @@ -604,7 +604,7 @@ describe(AssetService.name, () => { it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { mocks.stack.delete.mockResolvedValue(); - mocks.asset.getById.mockResolvedValue({ + mocks.assetJob.getForAssetDeletion.mockResolvedValue({ ...assetStub.primaryImage, stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) }, }); @@ -615,7 +615,7 @@ describe(AssetService.name, () => { }); it('should delete a live photo', async () => { - mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any); mocks.asset.getLivePhotoCount.mockResolvedValue(0); await sut.handleAssetDeletion({ @@ -653,7 +653,7 @@ describe(AssetService.name, () => { it('should not delete a live motion part if it is being used by another asset', async () => { mocks.asset.getLivePhotoCount.mockResolvedValue(2); - mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any); await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id, @@ -680,12 +680,13 @@ describe(AssetService.name, () => { }); it('should update usage', async () => { - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.image); await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); }); it('should fail if asset could not be found', async () => { + mocks.assetJob.getForAssetDeletion.mockResolvedValue(void 0); await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe( JobStatus.FAILED, ); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 16f60907ba..3e13ed0b8e 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -189,13 +189,7 @@ export class AssetService extends BaseService { async handleAssetDeletion(job: JobOf): Promise { const { id, deleteOnDisk } = job; - const asset = await this.assetRepository.getById(id, { - faces: { person: true }, - library: true, - stack: { assets: true }, - exifInfo: true, - files: true, - }); + const asset = await this.assetJobRepository.getForAssetDeletion(id); if (!asset) { return JobStatus.FAILED; diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 704087ab05..aa72fd588a 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -142,52 +142,55 @@ describe(BackupService.name, () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); }); + it('should run a database backup successfully', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); expect(mocks.storage.createWriteStream).toHaveBeenCalled(); }); + it('should rename file on success', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); expect(mocks.storage.rename).toHaveBeenCalled(); }); + it('should fail if pg_dumpall fails', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); }); + it('should not rename file if pgdump fails and gzip succeeds', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); expect(mocks.storage.rename).not.toHaveBeenCalled(); }); + it('should fail if gzip fails', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Gzip failed with code 1'); }); + it('should fail if write stream fails', async () => { mocks.storage.createWriteStream.mockImplementation(() => { throw new Error('error'); }); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('error'); }); + it('should fail if rename fails', async () => { mocks.storage.rename.mockRejectedValue(new Error('error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('error'); }); + it('should ignore unlink failing and still return failed job status', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); mocks.storage.unlink.mockRejectedValue(new Error('error')); - const result = await sut.handleBackupDatabase(); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); expect(mocks.storage.unlink).toHaveBeenCalled(); - expect(result).toBe(JobStatus.FAILED); }); + it.each` postgresVersion | expectedVersion ${'14.10'} | ${14} diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index 409d34ab73..10f7becc7d 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -174,7 +174,7 @@ export class BackupService extends BaseService { await this.storageRepository .unlink(backupFilePath) .catch((error) => this.logger.error('Failed to delete failed backup file', error)); - return JobStatus.FAILED; + throw error; } this.logger.log(`Database Backup Success`); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 23ddb1b63e..3381ad7222 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -80,6 +81,7 @@ export class BaseService { protected memoryRepository: MemoryRepository, protected metadataRepository: MetadataRepository, protected moveRepository: MoveRepository, + protected notificationRepository: NotificationRepository, protected oauthRepository: OAuthRepository, protected partnerRepository: PartnerRepository, protected personRepository: PersonRepository, diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index cb664aea32..02711b9bfd 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -33,7 +33,7 @@ export class DownloadService extends BaseService { const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; const metadata = await this.userRepository.getMetadata(auth.user.id); - const preferences = getPreferences(auth.user.email, metadata); + const preferences = getPreferences(metadata); const motionIds = new Set(); const archives: DownloadArchiveInfo[] = []; let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index b214dd14f6..88b68d2c13 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -17,6 +17,7 @@ import { MapService } from 'src/services/map.service'; import { MediaService } from 'src/services/media.service'; import { MemoryService } from 'src/services/memory.service'; import { MetadataService } from 'src/services/metadata.service'; +import { NotificationAdminService } from 'src/services/notification-admin.service'; import { NotificationService } from 'src/services/notification.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; @@ -60,6 +61,7 @@ export const services = [ MemoryService, MetadataService, NotificationService, + NotificationAdminService, PartnerService, PersonService, SearchService, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index b81256de81..a387e6e099 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -215,11 +215,7 @@ export class JobService extends BaseService { await this.onDone(job); } } catch (error: Error | any) { - this.logger.error( - `Unable to run job handler (${queueName}/${job.name}): ${error}`, - error?.stack, - JSON.stringify(job.data), - ); + await this.eventRepository.emit('job.failed', { job, error }); } finally { this.telemetryRepository.jobs.addToGauge(queueMetric, -1); } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 8990ad86a6..f39147309b 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,7 +1,6 @@ import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; import { Exif } from 'src/database'; -import { AssetMediaSize } from 'src/dtos/asset-media.dto'; import { AssetFileType, AssetPathType, @@ -11,11 +10,11 @@ import { ImageFormat, JobName, JobStatus, + RawExtractedFormat, TranscodeHWAccel, TranscodePolicy, VideoCodec, } from 'src/enum'; -import { WithoutProperty } from 'src/repositories/asset.repository'; import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -232,17 +231,19 @@ describe(MediaService.name, () => { describe('handleGenerateThumbnails', () => { let rawBuffer: Buffer; let fullsizeBuffer: Buffer; + let extractedBuffer: Buffer; let rawInfo: RawImageInfo; beforeEach(() => { fullsizeBuffer = Buffer.from('embedded image data'); - rawBuffer = Buffer.from('image data'); + rawBuffer = Buffer.from('raw image data'); + extractedBuffer = Buffer.from('embedded image file'); rawInfo = { width: 100, height: 100, channels: 3 }; - mocks.media.decodeImage.mockImplementation((path) => + mocks.media.decodeImage.mockImplementation((input) => Promise.resolve( - path.includes(AssetMediaSize.FULLSIZE) - ? { data: fullsizeBuffer, info: rawInfo as OutputInfo } - : { data: rawBuffer, info: rawInfo as OutputInfo }, + typeof input === 'string' + ? { data: rawBuffer, info: rawInfo as OutputInfo } // string implies original file + : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted ), ); }); @@ -585,16 +586,15 @@ describe(MediaService.name, () => { }); it('should extract embedded image if enabled and available', async () => { - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const convertedPath = mocks.media.extract.mock.lastCall?.[1].toString(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(convertedPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -602,16 +602,13 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image is too small', async () => { - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mocks.media.extract.mock.lastCall?.[1].toString(); - expect(extractedPath).toMatch(/-fullsize\.jpeg$/); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, @@ -666,38 +663,40 @@ describe(MediaService.name, () => { expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, - expect.objectContaining({ processInvalidImages: true }), + expect.objectContaining({ processInvalidImages: false }), 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', ); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, - expect.objectContaining({ processInvalidImages: true }), + expect.objectContaining({ processInvalidImages: false }), 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', ); expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce(); expect(mocks.media.generateThumbhash).toHaveBeenCalledWith( rawBuffer, - expect.objectContaining({ processInvalidImages: true }), + expect.objectContaining({ processInvalidImages: false }), ); expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); vi.unstubAllEnvs(); }); - it('should generate full-size preview using embedded JPEG from RAW images when extractEmbedded is true', async () => { - mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: true } }); - mocks.media.extract.mockResolvedValue(true); + it('should extract full-size JPEG preview from RAW', async () => { + mocks.systemMetadata.get.mockResolvedValue({ + image: { fullsize: { enabled: true, format: ImageFormat.WEBP }, extractEmbedded: true }, + }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mocks.media.extract.mock.lastCall?.[1].toString(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { colorspace: Colorspace.P3, processInvalidImages: false, + size: 1440, // capped to preview size as fullsize conversion is skipped }); expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); @@ -715,9 +714,51 @@ describe(MediaService.name, () => { ); }); + it('should convert full-size WEBP preview from JXL preview of RAW', async () => { + mocks.systemMetadata.get.mockResolvedValue({ + image: { fullsize: { enabled: true, format: ImageFormat.WEBP }, extractEmbedded: true }, + }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JXL }); + mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { + colorspace: Colorspace.P3, + processInvalidImages: false, + }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + fullsizeBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.WEBP, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-fullsize.webp', + ); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + fullsizeBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + }); + it('should generate full-size preview directly from RAW images when extractEmbedded is false', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); @@ -757,7 +798,7 @@ describe(MediaService.name, () => { it('should generate full-size preview from non-web-friendly images', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); @@ -786,7 +827,7 @@ describe(MediaService.name, () => { it('should skip generating full-size preview for web-friendly images', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); @@ -811,7 +852,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true, format: ImageFormat.WEBP, quality: 90 } }, }); - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); @@ -841,16 +882,12 @@ describe(MediaService.name, () => { describe('handleQueueVideoConversion', () => { it('should queue all video assets', async () => { - mocks.asset.getAll.mockResolvedValue({ - items: [assetStub.video], - hasNextPage: false, - }); + mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([assetStub.video])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueVideoConversion({ force: true }); - expect(mocks.asset.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO }); - expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.assetJob.streamForVideoConversion).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.VIDEO_CONVERSION, @@ -860,15 +897,11 @@ describe(MediaService.name, () => { }); it('should queue all video assets without encoded videos', async () => { - mocks.asset.getWithout.mockResolvedValue({ - items: [assetStub.video], - hasNextPage: false, - }); + mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([assetStub.video])); await sut.handleQueueVideoConversion({}); - expect(mocks.asset.getAll).not.toHaveBeenCalled(); - expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO); + expect(mocks.assetJob.streamForVideoConversion).toHaveBeenCalledWith(void 0); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.VIDEO_CONVERSION, @@ -880,26 +913,18 @@ describe(MediaService.name, () => { describe('handleVideoConversion', () => { beforeEach(() => { - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.assetJob.getForVideoConversion.mockResolvedValue(assetStub.video); sut.videoInterfaces = { dri: ['renderD128'], mali: true }; }); it('should skip transcoding if asset not found', async () => { - mocks.asset.getByIds.mockResolvedValue([]); + mocks.assetJob.getForVideoConversion.mockResolvedValue(void 0); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.probe).not.toHaveBeenCalled(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); - it('should skip transcoding if non-video asset', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); - await sut.handleVideoConversion({ id: assetStub.image.id }); - expect(mocks.media.probe).not.toHaveBeenCalled(); - expect(mocks.media.transcode).not.toHaveBeenCalled(); - }); - it('should transcode the longest stream', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); mocks.logger.isLevelEnabled.mockReturnValue(false); mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); @@ -921,14 +946,12 @@ describe(MediaService.name, () => { it('should skip a video without any streams', async () => { mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should skip a video without any height', async () => { mocks.media.probe.mockResolvedValue(probeStub.noHeight); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -936,7 +959,6 @@ describe(MediaService.name, () => { it('should throw an error if an unknown transcode policy is configured', async () => { mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); @@ -947,7 +969,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, accel: TranscodeHWAccel.DISABLED }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); mocks.media.transcode.mockRejectedValue(new Error('Error transcoding video')); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); @@ -957,7 +978,6 @@ describe(MediaService.name, () => { it('should transcode when set to all', async () => { mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1035,7 +1055,6 @@ describe(MediaService.name, () => { it('should scale horizontally when video is horizontal', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1051,7 +1070,6 @@ describe(MediaService.name, () => { it('should scale vertically when video is vertical', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1069,7 +1087,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1087,7 +1104,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1105,7 +1121,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedAudioCodecs: [AudioCodec.AAC] }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1127,7 +1142,6 @@ describe(MediaService.name, () => { acceptedAudioCodecs: [AudioCodec.AAC], }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1149,7 +1163,6 @@ describe(MediaService.name, () => { acceptedAudioCodecs: [AudioCodec.AAC], }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1165,7 +1178,6 @@ describe(MediaService.name, () => { it('should copy audio stream when audio matches target', async () => { mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1180,7 +1192,6 @@ describe(MediaService.name, () => { it('should remux when input is not an accepted container', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamAvi); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1204,7 +1215,6 @@ describe(MediaService.name, () => { it('should not transcode if transcoding is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -1212,7 +1222,6 @@ describe(MediaService.name, () => { it('should not remux when input is not an accepted container and transcoding is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -1220,7 +1229,6 @@ describe(MediaService.name, () => { it('should not transcode if target codec is invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -1229,7 +1237,7 @@ describe(MediaService.name, () => { const asset = assetStub.hasEncodedVideo; mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - mocks.asset.getByIds.mockResolvedValue([asset]); + mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); await sut.handleVideoConversion({ id: asset.id }); @@ -1243,7 +1251,6 @@ describe(MediaService.name, () => { it('should set max bitrate if above 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1259,7 +1266,6 @@ describe(MediaService.name, () => { it('should default max bitrate to kbps if no unit is provided', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1275,7 +1281,6 @@ describe(MediaService.name, () => { it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1291,7 +1296,6 @@ describe(MediaService.name, () => { it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1313,7 +1317,6 @@ describe(MediaService.name, () => { targetVideoCodec: VideoCodec.VP9, }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1335,7 +1338,6 @@ describe(MediaService.name, () => { targetVideoCodec: VideoCodec.VP9, }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1351,7 +1353,6 @@ describe(MediaService.name, () => { it('should configure preset for vp9', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1367,7 +1368,6 @@ describe(MediaService.name, () => { it('should not configure preset for vp9 if invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1383,7 +1383,6 @@ describe(MediaService.name, () => { it('should configure threads if above 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1399,7 +1398,6 @@ describe(MediaService.name, () => { it('should disable thread pooling for h264 if thread limit is 1', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1415,7 +1413,6 @@ describe(MediaService.name, () => { it('should omit thread flags for h264 if thread limit is at or below 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1431,7 +1428,6 @@ describe(MediaService.name, () => { it('should disable thread pooling for hevc if thread limit is 1', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1447,7 +1443,6 @@ describe(MediaService.name, () => { it('should omit thread flags for hevc if thread limit is at or below 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1463,7 +1458,6 @@ describe(MediaService.name, () => { it('should use av1 if specified', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1489,7 +1483,6 @@ describe(MediaService.name, () => { it('should map `veryslow` preset to 4 for av1', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1505,7 +1498,6 @@ describe(MediaService.name, () => { it('should set max bitrate for av1 if specified', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1521,7 +1513,6 @@ describe(MediaService.name, () => { it('should set threads for av1 if specified', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1539,7 +1530,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1561,7 +1551,6 @@ describe(MediaService.name, () => { targetResolution: '1080p', }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -1571,7 +1560,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -1579,7 +1567,6 @@ describe(MediaService.name, () => { it('should fail if hwaccel option is invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -1587,7 +1574,6 @@ describe(MediaService.name, () => { it('should set options for nvenc', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1625,7 +1611,6 @@ describe(MediaService.name, () => { twoPass: true, }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1641,7 +1626,6 @@ describe(MediaService.name, () => { it('should set vbr options for nvenc when max bitrate is enabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1657,7 +1641,6 @@ describe(MediaService.name, () => { it('should set cq options for nvenc when max bitrate is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1673,7 +1656,6 @@ describe(MediaService.name, () => { it('should omit preset for nvenc if invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1689,7 +1671,6 @@ describe(MediaService.name, () => { it('should ignore two pass for nvenc if max bitrate is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1707,7 +1688,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1730,7 +1710,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1752,7 +1731,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1768,7 +1746,6 @@ describe(MediaService.name, () => { it('should set options for qsv', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1809,7 +1786,6 @@ describe(MediaService.name, () => { preferredHwDevice: '/dev/dri/renderD128', }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1828,7 +1804,6 @@ describe(MediaService.name, () => { it('should omit preset for qsv if invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1849,7 +1824,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1869,7 +1843,6 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: [], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); @@ -1880,7 +1853,6 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1901,7 +1873,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -1928,7 +1899,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -1958,7 +1928,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true, preferredHwDevice: 'renderD129' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -1977,7 +1946,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2000,7 +1968,6 @@ describe(MediaService.name, () => { it('should set options for vaapi', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2031,7 +1998,6 @@ describe(MediaService.name, () => { it('should set vbr options for vaapi when max bitrate is enabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2056,7 +2022,6 @@ describe(MediaService.name, () => { it('should set cq options for vaapi when max bitrate is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2081,7 +2046,6 @@ describe(MediaService.name, () => { it('should omit preset for vaapi if invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2101,7 +2065,6 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2123,7 +2086,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2144,7 +2106,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2170,7 +2131,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2194,7 +2154,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2215,7 +2174,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true, preferredHwDevice: 'renderD129' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2232,7 +2190,6 @@ describe(MediaService.name, () => { it('should fallback to hw encoding and sw decoding if hw transcoding fails and hw decoding is enabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledTimes(2); @@ -2253,7 +2210,6 @@ describe(MediaService.name, () => { it('should fallback to sw decoding if fallback to sw decoding + hw encoding fails', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2272,7 +2228,6 @@ describe(MediaService.name, () => { it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledTimes(2); @@ -2291,7 +2246,6 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: [], mali: true }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -2299,7 +2253,6 @@ describe(MediaService.name, () => { it('should set options for rkmpp', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2340,7 +2293,6 @@ describe(MediaService.name, () => { targetVideoCodec: VideoCodec.HEVC, }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2358,7 +2310,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2376,7 +2327,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2399,7 +2349,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2419,7 +2368,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2442,7 +2390,6 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2462,7 +2409,6 @@ describe(MediaService.name, () => { it('should tonemap when policy is required and video is hdr', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2482,7 +2428,6 @@ describe(MediaService.name, () => { it('should tonemap when policy is optimal and video is hdr', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2502,7 +2447,6 @@ describe(MediaService.name, () => { it('should transcode when policy is required and video is not yuv420p', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2518,7 +2462,6 @@ describe(MediaService.name, () => { it('should convert to yuv420p when scaling without tone-mapping', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream4K10Bit); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2534,7 +2477,6 @@ describe(MediaService.name, () => { it('should count frames for progress when log level is debug', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.logger.isLevelEnabled.mockReturnValue(true); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2557,7 +2499,6 @@ describe(MediaService.name, () => { it('should not count frames for progress when log level is not debug', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.logger.isLevelEnabled.mockReturnValue(false); - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); @@ -2582,48 +2523,39 @@ describe(MediaService.name, () => { describe('isSRGB', () => { it('should return true for srgb colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({ colorspace: 'sRGB' } as Exif)).toEqual(true); }); it('should return true for srgb profile description', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as Exif)).toEqual(true); }); it('should return true for 8-bit image with no colorspace metadata', () => { - const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({ bitsPerSample: 8 } as Exif)).toEqual(true); }); it('should return true for image with no colorspace or bit depth metadata', () => { - const asset = { ...assetStub.image, exifInfo: {} as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({} as Exif)).toEqual(true); }); it('should return false for non-srgb colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as Exif }; - expect(sut.isSRGB(asset)).toEqual(false); + expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as Exif)).toEqual(false); }); it('should return false for non-srgb profile description', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as Exif }; - expect(sut.isSRGB(asset)).toEqual(false); + expect(sut.isSRGB({ profileDescription: 'sP3C' } as Exif)).toEqual(false); }); it('should return false for 16-bit image with no colorspace metadata', () => { - const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as Exif }; - expect(sut.isSRGB(asset)).toEqual(false); + expect(sut.isSRGB({ bitsPerSample: 16 } as Exif)).toEqual(false); }); it('should return true for 16-bit image with sRGB colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true); }); it('should return true for 16-bit image with sRGB profile', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true); }); }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 59d708772b..a0bc1b0906 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -10,11 +10,11 @@ import { AssetType, AudioCodec, Colorspace, - ImageFormat, JobName, JobStatus, LogLevel, QueueName, + RawExtractedFormat, StorageFolder, TranscodeHWAccel, TranscodePolicy, @@ -22,12 +22,11 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { UpsertFileOptions, WithoutProperty } from 'src/repositories/asset.repository'; +import { UpsertFileOptions } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; import { AudioStreamInfo, DecodeToBufferOptions, - GenerateThumbnailOptions, JobItem, JobOf, VideoFormat, @@ -213,6 +212,29 @@ export class MediaService extends BaseService { return JobStatus.SUCCESS; } + private async extractImage(originalPath: string, minSize: number) { + let extracted = await this.mediaRepository.extract(originalPath); + if (extracted && !(await this.shouldUseExtractedImage(extracted.buffer, minSize))) { + extracted = null; + } + + return extracted; + } + + private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number) { + const { image } = await this.getConfig({ withCache: true }); + const colorspace = this.isSRGB(exifInfo) ? Colorspace.SRGB : image.colorspace; + const decodeOptions: DecodeToBufferOptions = { + colorspace, + processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', + size: targetSize, + orientation: exifInfo.orientation ? Number(exifInfo.orientation) : undefined, + }; + + const { info, data } = await this.mediaRepository.decodeImage(thumbSource, decodeOptions); + return { info, data, colorspace }; + } + private async generateImageThumbnails(asset: { id: string; ownerId: string; @@ -225,68 +247,48 @@ export class MediaService extends BaseService { const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); - const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + // Handle embedded preview extraction for RAW files + const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); + const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null; + const generateFullsize = image.fullsize.enabled && !mimeTypes.isWebSupportedImage(asset.originalPath); + const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`)); - // prevents this extra "enabled" from leaking into fullsizeOptions later - const { enabled: imageFullsizeEnabled, ...imageFullsizeConfig } = image.fullsize; + const { info, data, colorspace } = await this.decodeImage( + extracted ? extracted.buffer : asset.originalPath, + asset.exifInfo, + convertFullsize ? undefined : image.preview.size, + ); - const shouldConvertFullsize = imageFullsizeEnabled && !mimeTypes.isWebSupportedImage(asset.originalFileName); - const shouldExtractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); - const decodeOptions: DecodeToBufferOptions = { colorspace, processInvalidImages, size: image.preview.size }; - - let useExtracted = false; - let decodeInputPath: string = asset.originalPath; - // Converted or extracted image from non-web-supported formats (e.g. RAW) - let fullsizePath: string | undefined; - - if (shouldConvertFullsize) { - // unset size to decode fullsize image - decodeOptions.size = undefined; - fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.fullsize.format); - } - - if (shouldExtractEmbedded) { - // For RAW files, try extracting embedded preview first - // Assume extracted image from RAW always in JPEG format, as implied from the `jpgFromRaw` tag name - const extractedPath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, ImageFormat.JPEG); - const didExtract = await this.mediaRepository.extract(asset.originalPath, extractedPath); - useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); - - if (useExtracted) { - if (shouldConvertFullsize) { - // skip re-encoding and directly use extracted as fullsize preview - // as usually the extracted image is already heavily compressed, no point doing lossy conversion again - fullsizePath = extractedPath; - } - // use this as origin of preview and thumbnail - decodeInputPath = extractedPath; - if (asset.exifInfo) { - // write essential orientation and colorspace EXIF for correct fullsize preview and subsequent processing - const exif = { orientation: asset.exifInfo.orientation, colorspace: asset.exifInfo.colorspace }; - await this.mediaRepository.writeExif(exif, extractedPath); - } - } - } - - const { info, data } = await this.mediaRepository.decodeImage(decodeInputPath, decodeOptions); - - const thumbnailOptions = { colorspace, processInvalidImages, raw: info }; + // generate final images + const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info }; const promises = [ this.mediaRepository.generateThumbhash(data, thumbnailOptions), this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailPath), this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewPath), ]; - // did not extract a usable image from RAW - if (fullsizePath && !useExtracted) { - const fullsizeOptions: GenerateThumbnailOptions = { - ...imageFullsizeConfig, - ...thumbnailOptions, - size: undefined, - }; + let fullsizePath: string | undefined; + + if (convertFullsize) { + // convert a new fullsize image from the same source as the thumbnail + fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.fullsize.format); + const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, ...thumbnailOptions }; promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); + } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.JPEG) { + fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, extracted.format); + this.storageCore.ensureFolders(fullsizePath); + + // Write the buffer to disk with essential EXIF data + await this.storageRepository.createOrOverwriteFile(fullsizePath, extracted.buffer); + await this.mediaRepository.writeExif( + { + orientation: asset.exifInfo.orientation, + colorspace: asset.exifInfo.colorspace, + }, + fullsizePath, + ); } + const outputs = await Promise.all(promises); return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer }; @@ -330,25 +332,25 @@ export class MediaService extends BaseService { async handleQueueVideoConversion(job: JobOf): Promise { const { force } = job; - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { - return force - ? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO }) - : this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO); - }); + let queue: { name: JobName.VIDEO_CONVERSION; data: { id: string } }[] = []; + for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) { + queue.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } }); - for await (const assets of assetPagination) { - await this.jobRepository.queueAll( - assets.map((asset) => ({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } })), - ); + if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { + await this.jobRepository.queueAll(queue); + queue = []; + } } + await this.jobRepository.queueAll(queue); + return JobStatus.SUCCESS; } @OnJob({ name: JobName.VIDEO_CONVERSION, queue: QueueName.VIDEO_CONVERSION }) async handleVideoConversion({ id }: JobOf): Promise { - const [asset] = await this.assetRepository.getByIds([id]); - if (!asset || asset.type !== AssetType.VIDEO) { + const asset = await this.assetJobRepository.getForVideoConversion(id); + if (!asset) { return JobStatus.FAILED; } @@ -521,8 +523,7 @@ export class MediaService extends BaseService { return name !== VideoContainer.MP4 && !ffmpegConfig.acceptedContainers.includes(name); } - isSRGB(asset: { exifInfo: Exif }): boolean { - const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo; + isSRGB({ colorspace, profileDescription, bitsPerSample }: Exif): boolean { if (colorspace || profileDescription) { return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb')); } else if (bitsPerSample) { @@ -550,10 +551,9 @@ export class MediaService extends BaseService { } } - private async shouldUseExtractedImage(extractedPath: string, targetSize: number) { - const { width, height } = await this.mediaRepository.getImageDimensions(extractedPath); + private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) { + const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer); const extractedSize = Math.min(width, height); - return extractedSize >= targetSize; } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 4fbb2cc48c..dfa2140824 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -14,7 +14,7 @@ import { probeStub } from 'test/fixtures/media.stub'; import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; import { factory } from 'test/small.factory'; -import { newTestService, ServiceMocks } from 'test/utils'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const makeFaceTags = (face: Partial<{ Name: string }> = {}) => ({ RegionInfo: { @@ -104,10 +104,10 @@ describe(MetadataService.name, () => { describe('handleQueueMetadataExtraction', () => { it('should queue metadata extraction for all assets without exif values', async () => { - mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([assetStub.image])); await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.SUCCESS); - expect(mocks.asset.getWithout).toHaveBeenCalled(); + expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.METADATA_EXTRACTION, @@ -117,10 +117,10 @@ describe(MetadataService.name, () => { }); it('should queue metadata extraction for all assets', async () => { - mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([assetStub.image])); await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.SUCCESS); - expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.METADATA_EXTRACTION, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 71b8e2de47..7c9a4c4b19 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -168,18 +168,18 @@ export class MetadataService extends BaseService { @OnJob({ name: JobName.QUEUE_METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) async handleQueueMetadataExtraction(job: JobOf): Promise { const { force } = job; - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { - return force - ? this.assetRepository.getAll(pagination) - : this.assetRepository.getWithout(pagination, WithoutProperty.EXIF); - }); - for await (const assets of assetPagination) { - await this.jobRepository.queueAll( - assets.map((asset) => ({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } })), - ); + let queue: { name: JobName.METADATA_EXTRACTION; data: { id: string } }[] = []; + for await (const asset of this.assetJobRepository.streamForMetadataExtraction(force)) { + queue.push({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } }); + + if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { + await this.jobRepository.queueAll(queue); + queue = []; + } } + await this.jobRepository.queueAll(queue); return JobStatus.SUCCESS; } diff --git a/server/src/services/notification-admin.service.spec.ts b/server/src/services/notification-admin.service.spec.ts new file mode 100644 index 0000000000..4a747d41a3 --- /dev/null +++ b/server/src/services/notification-admin.service.spec.ts @@ -0,0 +1,111 @@ +import { defaults, SystemConfig } from 'src/config'; +import { EmailTemplate } from 'src/repositories/email.repository'; +import { NotificationService } from 'src/services/notification.service'; +import { userStub } from 'test/fixtures/user.stub'; +import { newTestService, ServiceMocks } from 'test/utils'; + +const smtpTransport = Object.freeze({ + ...defaults, + notifications: { + smtp: { + ...defaults.notifications.smtp, + enabled: true, + transport: { + ignoreCert: false, + host: 'localhost', + port: 587, + username: 'test', + password: 'test', + }, + }, + }, +}); + +describe(NotificationService.name, () => { + let sut: NotificationService; + let mocks: ServiceMocks; + + beforeEach(() => { + ({ sut, mocks } = newTestService(NotificationService)); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('sendTestEmail', () => { + it('should throw error if user could not be found', async () => { + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); + }); + + it('should throw error if smtp validation fails', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockRejectedValue(''); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow( + 'Failed to verify SMTP configuration', + ); + }); + + it('should send email to default domain', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + }), + ); + }); + + it('should send email to external domain', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + }), + ); + }); + + it('should send email with replyTo', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect( + sut.sendTestEmail('', { ...smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), + ).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + replyTo: 'demo@immich.app', + }), + ); + }); + }); +}); diff --git a/server/src/services/notification-admin.service.ts b/server/src/services/notification-admin.service.ts new file mode 100644 index 0000000000..bf0d2bba41 --- /dev/null +++ b/server/src/services/notification-admin.service.ts @@ -0,0 +1,120 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto'; +import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { EmailTemplate } from 'src/repositories/email.repository'; +import { BaseService } from 'src/services/base.service'; +import { getExternalDomain } from 'src/utils/misc'; + +@Injectable() +export class NotificationAdminService extends BaseService { + async create(auth: AuthDto, dto: NotificationCreateDto) { + const item = await this.notificationRepository.create({ + userId: dto.userId, + type: dto.type ?? NotificationType.Custom, + level: dto.level ?? NotificationLevel.Info, + title: dto.title, + description: dto.description, + data: dto.data, + }); + + return mapNotification(item); + } + + async sendTestEmail(id: string, dto: SystemConfigSmtpDto, tempTemplate?: string) { + const user = await this.userRepository.get(id, { withDeleted: false }); + if (!user) { + throw new Error('User not found'); + } + + try { + await this.emailRepository.verifySmtp(dto.transport); + } catch (error) { + throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); + } + + const { server } = await this.getConfig({ withCache: false }); + const { html, text } = await this.emailRepository.renderEmail({ + template: EmailTemplate.TEST_EMAIL, + data: { + baseUrl: getExternalDomain(server), + displayName: user.name, + }, + customTemplate: tempTemplate!, + }); + const { messageId } = await this.emailRepository.sendEmail({ + to: user.email, + subject: 'Test email from Immich', + html, + text, + from: dto.from, + replyTo: dto.replyTo || dto.from, + smtp: dto.transport, + }); + + return { messageId }; + } + + async getTemplate(name: EmailTemplate, customTemplate: string) { + const { server, templates } = await this.getConfig({ withCache: false }); + + let templateResponse = ''; + + switch (name) { + case EmailTemplate.WELCOME: { + const { html: _welcomeHtml } = await this.emailRepository.renderEmail({ + template: EmailTemplate.WELCOME, + data: { + baseUrl: getExternalDomain(server), + displayName: 'John Doe', + username: 'john@doe.com', + password: 'thisIsAPassword123', + }, + customTemplate: customTemplate || templates.email.welcomeTemplate, + }); + + templateResponse = _welcomeHtml; + break; + } + case EmailTemplate.ALBUM_UPDATE: { + const { html: _updateAlbumHtml } = await this.emailRepository.renderEmail({ + template: EmailTemplate.ALBUM_UPDATE, + data: { + baseUrl: getExternalDomain(server), + albumId: '1', + albumName: 'Favorite Photos', + recipientName: 'Jane Doe', + cid: undefined, + }, + customTemplate: customTemplate || templates.email.albumInviteTemplate, + }); + templateResponse = _updateAlbumHtml; + break; + } + + case EmailTemplate.ALBUM_INVITE: { + const { html } = await this.emailRepository.renderEmail({ + template: EmailTemplate.ALBUM_INVITE, + data: { + baseUrl: getExternalDomain(server), + albumId: '1', + albumName: "John Doe's Favorites", + senderName: 'John Doe', + recipientName: 'Jane Doe', + cid: undefined, + }, + customTemplate: customTemplate || templates.email.albumInviteTemplate, + }); + templateResponse = html; + break; + } + default: { + templateResponse = ''; + break; + } + } + + return { name, html: templateResponse }; + } +} diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 5830260753..133eb9e7f6 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -3,7 +3,6 @@ import { defaults, SystemConfig } from 'src/config'; import { AlbumUser } from 'src/database'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; -import { EmailTemplate } from 'src/repositories/email.repository'; import { NotificationService } from 'src/services/notification.service'; import { INotifyAlbumUpdateJob } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; @@ -241,82 +240,6 @@ describe(NotificationService.name, () => { }); }); - describe('sendTestEmail', () => { - it('should throw error if user could not be found', async () => { - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); - }); - - it('should throw error if smtp validation fails', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockRejectedValue(''); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow( - 'Failed to verify SMTP configuration', - ); - }); - - it('should send email to default domain', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - }), - ); - }); - - it('should send email to external domain', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - }), - ); - }); - - it('should send email with replyTo', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect( - sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), - ).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - replyTo: 'demo@immich.app', - }), - ); - }); - }); - describe('handleUserSignup', () => { it('should skip if user could not be found', async () => { await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SKIPPED); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 2e456718ca..be475d1dca 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,7 +1,24 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { OnEvent, OnJob } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + mapNotification, + NotificationDeleteAllDto, + NotificationDto, + NotificationSearchDto, + NotificationUpdateAllDto, + NotificationUpdateDto, +} from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; -import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum'; +import { + AssetFileType, + JobName, + JobStatus, + NotificationLevel, + NotificationType, + Permission, + QueueName, +} from 'src/enum'; import { EmailTemplate } from 'src/repositories/email.repository'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; @@ -15,6 +32,80 @@ import { getPreferences } from 'src/utils/preferences'; export class NotificationService extends BaseService { private static albumUpdateEmailDelayMs = 300_000; + async search(auth: AuthDto, dto: NotificationSearchDto): Promise { + const items = await this.notificationRepository.search(auth.user.id, dto); + return items.map((item) => mapNotification(item)); + } + + async updateAll(auth: AuthDto, dto: NotificationUpdateAllDto) { + await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_UPDATE }); + await this.notificationRepository.updateAll(dto.ids, { + readAt: dto.readAt, + }); + } + + async deleteAll(auth: AuthDto, dto: NotificationDeleteAllDto) { + await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_DELETE }); + await this.notificationRepository.deleteAll(dto.ids); + } + + async get(auth: AuthDto, id: string) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_READ }); + const item = await this.notificationRepository.get(id); + if (!item) { + throw new BadRequestException('Notification not found'); + } + return mapNotification(item); + } + + async update(auth: AuthDto, id: string, dto: NotificationUpdateDto) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_UPDATE }); + const item = await this.notificationRepository.update(id, { + readAt: dto.readAt, + }); + return mapNotification(item); + } + + async delete(auth: AuthDto, id: string) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_DELETE }); + await this.notificationRepository.delete(id); + } + + @OnJob({ name: JobName.NOTIFICATIONS_CLEANUP, queue: QueueName.BACKGROUND_TASK }) + async onNotificationsCleanup() { + await this.notificationRepository.cleanup(); + } + + @OnEvent({ name: 'job.failed' }) + async onJobFailed({ job, error }: ArgOf<'job.failed'>) { + const admin = await this.userRepository.getAdmin(); + if (!admin) { + return; + } + + this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data)); + + switch (job.name) { + case JobName.BACKUP_DATABASE: { + const errorMessage = error instanceof Error ? error.message : error; + const item = await this.notificationRepository.create({ + userId: admin.id, + type: NotificationType.JobFailed, + level: NotificationLevel.Error, + title: 'Job Failed', + description: `Job ${[job.name]} failed with error: ${errorMessage}`, + }); + + this.eventRepository.clientSend('on_notification', admin.id, mapNotification(item)); + break; + } + + default: { + return; + } + } + } + @OnEvent({ name: 'config.update' }) onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { this.eventRepository.clientBroadcast('on_config_update'); @@ -271,7 +362,7 @@ export class NotificationService extends BaseService { return JobStatus.SKIPPED; } - const { emailNotifications } = getPreferences(recipient.email, recipient.metadata); + const { emailNotifications } = getPreferences(recipient.metadata); if (!emailNotifications.enabled || !emailNotifications.albumInvite) { return JobStatus.SKIPPED; @@ -333,7 +424,7 @@ export class NotificationService extends BaseService { continue; } - const { emailNotifications } = getPreferences(user.email, user.metadata); + const { emailNotifications } = getPreferences(user.metadata); if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { continue; diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index df26e69108..e1c21841f0 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,11 +1,10 @@ import { SystemConfig } from 'src/config'; import { ImmichWorker, JobName, JobStatus } from 'src/enum'; -import { WithoutProperty } from 'src/repositories/asset.repository'; import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { newTestService, ServiceMocks } from 'test/utils'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; describe(SmartInfoService.name, () => { let sut: SmartInfoService; @@ -58,10 +57,6 @@ describe(SmartInfoService.name, () => { expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); - expect(mocks.job.pause).not.toHaveBeenCalled(); - expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should return if model and DB dimension size are equal', async () => { @@ -72,38 +67,15 @@ describe(SmartInfoService.name, () => { expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); - expect(mocks.job.pause).not.toHaveBeenCalled(); - expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should update DB dimension size if model and DB have different values', async () => { mocks.search.getDimensionSize.mockResolvedValue(768); - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); - expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); - expect(mocks.job.pause).toHaveBeenCalledTimes(1); - expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(mocks.job.resume).toHaveBeenCalledTimes(1); - }); - - it('should skip pausing and resuming queue if already paused', async () => { - mocks.search.getDimensionSize.mockResolvedValue(768); - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); - - await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - - expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); - expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); - expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); - expect(mocks.job.pause).not.toHaveBeenCalled(); - expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(mocks.job.resume).not.toHaveBeenCalled(); }); }); @@ -120,10 +92,6 @@ describe(SmartInfoService.name, () => { expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); - expect(mocks.job.pause).not.toHaveBeenCalled(); - expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should return if model and DB dimension size are equal', async () => { @@ -141,15 +109,10 @@ describe(SmartInfoService.name, () => { expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); - expect(mocks.job.pause).not.toHaveBeenCalled(); - expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should update DB dimension size if model and DB have different values', async () => { mocks.search.getDimensionSize.mockResolvedValue(512); - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigUpdate({ newConfig: { @@ -162,15 +125,10 @@ describe(SmartInfoService.name, () => { expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768); - expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); - expect(mocks.job.pause).toHaveBeenCalledTimes(1); - expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); it('should clear embeddings if old and new models are different', async () => { mocks.search.getDimensionSize.mockResolvedValue(512); - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigUpdate({ newConfig: { @@ -184,31 +142,6 @@ describe(SmartInfoService.name, () => { expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); - expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); - expect(mocks.job.pause).toHaveBeenCalledTimes(1); - expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(mocks.job.resume).toHaveBeenCalledTimes(1); - }); - - it('should skip pausing and resuming queue if already paused', async () => { - mocks.search.getDimensionSize.mockResolvedValue(512); - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); - - await sut.onConfigUpdate({ - newConfig: { - machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, - } as SystemConfig, - oldConfig: { - machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true }, - } as SystemConfig, - }); - - expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); - expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); - expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); - expect(mocks.job.pause).not.toHaveBeenCalled(); - expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(mocks.job.resume).not.toHaveBeenCalled(); }); }); @@ -218,38 +151,32 @@ describe(SmartInfoService.name, () => { await sut.handleQueueEncodeClip({}); - expect(mocks.asset.getAll).not.toHaveBeenCalled(); expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); }); it('should queue the assets without clip embeddings', async () => { - mocks.asset.getWithout.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); + mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([assetStub.image])); await sut.handleQueueEncodeClip({ force: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, ]); - expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH); - expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); }); it('should queue all the assets', async () => { - mocks.asset.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); + mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([assetStub.image])); await sut.handleQueueEncodeClip({ force: true }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, ]); - expect(mocks.asset.getAll).toHaveBeenCalled(); - expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); + expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true); + expect(mocks.search.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512); }); }); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 411114eb17..5ee5dac57e 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -3,12 +3,10 @@ import { SystemConfig } from 'src/config'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; -import { WithoutProperty } from 'src/repositories/asset.repository'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; -import { JobOf } from 'src/types'; +import { JobItem, JobOf } from 'src/types'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; -import { usePagination } from 'src/utils/pagination'; @Injectable() export class SmartInfoService extends BaseService { @@ -50,12 +48,6 @@ export class SmartInfoService extends BaseService { return; } - const { isPaused } = await this.jobRepository.getQueueStatus(QueueName.SMART_SEARCH); - if (!isPaused) { - await this.jobRepository.pause(QueueName.SMART_SEARCH); - } - await this.jobRepository.waitForQueueCompletion(QueueName.SMART_SEARCH); - if (dimSizeChange) { this.logger.log( `Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`, @@ -67,9 +59,8 @@ export class SmartInfoService extends BaseService { await this.searchRepository.deleteAllSearchEmbeddings(); } - if (!isPaused) { - await this.jobRepository.resume(QueueName.SMART_SEARCH); - } + // TODO: A job to reindex all assets should be scheduled, though user + // confirmation should probably be requested before doing that. }); } @@ -81,21 +72,23 @@ export class SmartInfoService extends BaseService { } if (force) { - await this.searchRepository.deleteAllSearchEmbeddings(); + const { dimSize } = getCLIPModelInfo(machineLearning.clip.modelName); + // in addition to deleting embeddings, update the dimension size in case it failed earlier + await this.searchRepository.setDimensionSize(dimSize); } - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { - return force - ? this.assetRepository.getAll(pagination, { isVisible: true }) - : this.assetRepository.getWithout(pagination, WithoutProperty.SMART_SEARCH); - }); - - for await (const assets of assetPagination) { - await this.jobRepository.queueAll( - assets.map((asset) => ({ name: JobName.SMART_SEARCH, data: { id: asset.id } })), - ); + let queue: JobItem[] = []; + const assets = this.assetJobRepository.streamForEncodeClip(force); + for await (const asset of assets) { + queue.push({ name: JobName.SMART_SEARCH, data: { id: asset.id } }); + if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { + await this.jobRepository.queueAll(queue); + queue = []; + } } + await this.jobRepository.queueAll(queue); + return JobStatus.SUCCESS; } @@ -126,6 +119,12 @@ export class SmartInfoService extends BaseService { await this.databaseRepository.wait(DatabaseLock.CLIPDimSize); } + const newConfig = await this.getConfig({ withCache: true }); + if (machineLearning.clip.modelName !== newConfig.machineLearning.clip.modelName) { + // Skip the job if the the model has changed since the embedding was generated. + return JobStatus.SKIPPED; + } + await this.searchRepository.upsert(asset.id, embedding); return JobStatus.SUCCESS; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 936acf27ad..176e6d6f04 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -6,6 +6,7 @@ import { CQMode, ImageFormat, LogLevel, + OAuthTokenEndpointAuthMethod, QueueName, ToneMapping, TranscodeHWAccel, @@ -119,6 +120,8 @@ const updatedConfig = Object.freeze({ scope: 'openid email profile', signingAlgorithm: 'RS256', profileSigningAlgorithm: 'none', + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST, + timeout: 30_000, storageLabelClaim: 'preferred_username', storageQuotaClaim: 'immich_quota', }, diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 0cba749d36..c1c6cc49ec 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -106,21 +106,19 @@ export class UserAdminService extends BaseService { } async getPreferences(auth: AuthDto, id: string): Promise { - const { email } = await this.findOrFail(id, { withDeleted: true }); + await this.findOrFail(id, { withDeleted: true }); const metadata = await this.userRepository.getMetadata(id); - const preferences = getPreferences(email, metadata); - return mapPreferences(preferences); + return mapPreferences(getPreferences(metadata)); } async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) { - const { email } = await this.findOrFail(id, { withDeleted: false }); + await this.findOrFail(id, { withDeleted: false }); const metadata = await this.userRepository.getMetadata(id); - const preferences = getPreferences(email, metadata); - const newPreferences = mergePreferences(preferences, dto); + const newPreferences = mergePreferences(getPreferences(metadata), dto); await this.userRepository.upsertMetadata(id, { key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial({ email }, newPreferences), + value: getPreferencesPartial(newPreferences), }); return mapPreferences(newPreferences); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 327328eb1c..a0304d51ad 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -53,6 +53,7 @@ export class UserService extends BaseService { const update: Updateable = { email: dto.email, name: dto.name, + avatarColor: dto.avatarColor, }; if (dto.password) { @@ -68,18 +69,16 @@ export class UserService extends BaseService { async getMyPreferences(auth: AuthDto): Promise { const metadata = await this.userRepository.getMetadata(auth.user.id); - const preferences = getPreferences(auth.user.email, metadata); - return mapPreferences(preferences); + return mapPreferences(getPreferences(metadata)); } async updateMyPreferences(auth: AuthDto, dto: UserPreferencesUpdateDto) { const metadata = await this.userRepository.getMetadata(auth.user.id); - const current = getPreferences(auth.user.email, metadata); - const updated = mergePreferences(current, dto); + const updated = mergePreferences(getPreferences(metadata), dto); await this.userRepository.upsertMetadata(auth.user.id, { key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(auth.user, updated), + value: getPreferencesPartial(updated), }); return mapPreferences(updated); diff --git a/server/src/types.ts b/server/src/types.ts index 88ba644739..ba33e97aad 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -11,7 +11,6 @@ import { SyncEntityType, SystemMetadataKey, TranscodeTarget, - UserAvatarColor, UserMetadataKey, VideoCodec, } from 'src/enum'; @@ -298,6 +297,10 @@ export type JobItem = // Metadata Extraction | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } + + // Notifications + | { name: JobName.NOTIFICATIONS_CLEANUP; data?: IBaseJob } + // Sidecar Scanning | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } @@ -486,9 +489,6 @@ export interface UserPreferences { enabled: boolean; sidebarWeb: boolean; }; - avatar: { - color: UserAvatarColor; - }; emailNotifications: { enabled: boolean; albumInvite: boolean; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 4e21a9226e..b04d23f114 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -221,6 +221,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.person.checkFaceOwnerAccess(auth.user.id, ids); } + case Permission.NOTIFICATION_READ: + case Permission.NOTIFICATION_UPDATE: + case Permission.NOTIFICATION_DELETE: { + return access.notification.checkOwnerAccess(auth.user.id, ids); + } + case Permission.TAG_ASSET: case Permission.TAG_READ: case Permission.TAG_UPDATE: diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 8f0b56597a..b44ea5da46 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -17,10 +17,10 @@ import { parse } from 'pg-connection-string'; import postgres, { Notice } from 'postgres'; import { columns, Exif, Person } from 'src/database'; import { DB } from 'src/db'; -import { AssetFileType } from 'src/enum'; +import { AssetFileType, DatabaseExtension } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; -import { DatabaseConnectionParams } from 'src/types'; +import { DatabaseConnectionParams, VectorExtension } from 'src/types'; type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; @@ -373,3 +373,28 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople)) .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)); } + +type VectorIndexOptions = { vectorExtension: VectorExtension; table: string; indexName: string }; + +export function vectorIndexQuery({ vectorExtension, table, indexName }: VectorIndexOptions): string { + switch (vectorExtension) { + case DatabaseExtension.VECTORS: { + return ` + CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} + USING vectors (embedding vector_cos_ops) WITH (options = $$ + [indexing.hnsw] + m = 16 + ef_construction = 300 + $$)`; + } + case DatabaseExtension.VECTOR: { + return ` + CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} + USING hnsw (embedding vector_cosine_ops) + WITH (ef_construction = 300, m = 16)`; + } + default: { + throw new Error(`Unsupported vector extension: '${vectorExtension}'`); + } + } +} diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index b1a9c77588..6aad418d9f 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -34,45 +34,40 @@ const raw: Record = { '.x3f': ['image/x3f', 'image/x-sigma-x3f'], }; +/** + * list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg + * @TODO share with the client + * @see {@link web/src/lib/utils/asset-utils.ts#L329} + **/ +const webSupportedImage = { + '.avif': ['image/avif'], + '.gif': ['image/gif'], + '.jpeg': ['image/jpeg'], + '.jpg': ['image/jpeg'], + '.png': ['image/png', 'image/apng'], + '.webp': ['image/webp'], +}; + const image: Record = { ...raw, - '.avif': ['image/avif'], + ...webSupportedImage, '.bmp': ['image/bmp'], - '.gif': ['image/gif'], '.heic': ['image/heic'], '.heif': ['image/heif'], '.hif': ['image/hif'], '.insp': ['image/jpeg'], '.jp2': ['image/jp2'], '.jpe': ['image/jpeg'], - '.jpeg': ['image/jpeg'], - '.jpg': ['image/jpeg'], '.jxl': ['image/jxl'], - '.png': ['image/png'], '.svg': ['image/svg'], '.tif': ['image/tiff'], '.tiff': ['image/tiff'], - '.webp': ['image/webp'], }; const extensionOverrides: Record = { 'image/jpeg': '.jpg', }; -/** - * list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg - * @TODO share with the client - * @see {@link web/src/lib/utils/asset-utils.ts#L329} - **/ -const webSupportedImageMimeTypes = new Set([ - 'image/apng', - 'image/avif', - 'image/gif', - 'image/jpeg', - 'image/png', - 'image/webp', -]); - const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp', '.svg']); const profile: Record = Object.fromEntries( Object.entries(image).filter(([key]) => profileExtensions.has(key)), @@ -123,7 +118,7 @@ export const mimeTypes = { isAsset: (filename: string) => isType(filename, image) || isType(filename, video), isImage: (filename: string) => isType(filename, image), - isWebSupportedImage: (filename: string) => webSupportedImageMimeTypes.has(lookup(filename)), + isWebSupportedImage: (filename: string) => isType(filename, webSupportedImage), isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index 584c5300cd..a013c0b74e 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,16 +1,11 @@ import _ from 'lodash'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; -import { UserAvatarColor, UserMetadataKey } from 'src/enum'; +import { UserMetadataKey } from 'src/enum'; import { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types'; import { HumanReadableSize } from 'src/utils/bytes'; import { getKeysDeep } from 'src/utils/misc'; -const getDefaultPreferences = (user: { email: string }): UserPreferences => { - const values = Object.values(UserAvatarColor); - const randomIndex = Math.floor( - [...user.email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length, - ); - +const getDefaultPreferences = (): UserPreferences => { return { folders: { enabled: false, @@ -34,9 +29,6 @@ const getDefaultPreferences = (user: { email: string }): UserPreferences => { enabled: false, sidebarWeb: false, }, - avatar: { - color: values[randomIndex], - }, emailNotifications: { enabled: true, albumInvite: true, @@ -53,8 +45,8 @@ const getDefaultPreferences = (user: { email: string }): UserPreferences => { }; }; -export const getPreferences = (email: string, metadata: UserMetadataItem[]): UserPreferences => { - const preferences = getDefaultPreferences({ email }); +export const getPreferences = (metadata: UserMetadataItem[]): UserPreferences => { + const preferences = getDefaultPreferences(); const item = metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); const partial = item?.value || {}; for (const property of getKeysDeep(partial)) { @@ -64,8 +56,8 @@ export const getPreferences = (email: string, metadata: UserMetadataItem[]): Use return preferences; }; -export const getPreferencesPartial = (user: { email: string }, newPreferences: UserPreferences) => { - const defaultPreferences = getDefaultPreferences(user); +export const getPreferencesPartial = (newPreferences: UserPreferences) => { + const defaultPreferences = getDefaultPreferences(); const partial: DeepPartial = {}; for (const property of getKeysDeep(defaultPreferences)) { const newValue = _.get(newPreferences, property); diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index ddf6e50aa2..4248b23d30 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { json } from 'body-parser'; +import compression from 'compression'; import cookieParser from 'cookie-parser'; import { existsSync } from 'node:fs'; import sirv from 'sirv'; @@ -60,6 +61,7 @@ async function bootstrap() { ); } app.use(app.get(ApiService).ssr(excludePaths)); + app.use(compression()); const server = await (host ? app.listen(port, host) : app.listen(port)); server.requestTimeout = 24 * 60 * 60 * 1000; diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index f0043d174a..0db58e2eed 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -1,5 +1,5 @@ import { UserAdmin } from 'src/database'; -import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; +import { UserStatus } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const userStub = { @@ -12,6 +12,7 @@ export const userStub = { storageLabel: 'admin', oauthId: '', shouldChangePassword: false, + avatarColor: null, profileImagePath: '', createdAt: new Date('2021-01-01'), deletedAt: null, @@ -28,16 +29,12 @@ export const userStub = { storageLabel: null, oauthId: '', shouldChangePassword: false, + avatarColor: null, profileImagePath: '', createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - metadata: [ - { - key: UserMetadataKey.PREFERENCES, - value: { avatar: { color: UserAvatarColor.PRIMARY } }, - }, - ], + metadata: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, }, @@ -50,6 +47,7 @@ export const userStub = { storageLabel: null, oauthId: '', shouldChangePassword: false, + avatarColor: null, profileImagePath: '', createdAt: new Date('2021-01-01'), deletedAt: null, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 671a8a50ca..388c4df96b 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -13,9 +13,11 @@ import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { EmailRepository } from 'src/repositories/email.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { SearchRepository } from 'src/repositories/search.repository'; @@ -42,10 +44,12 @@ type RepositoriesTypes = { config: ConfigRepository; crypto: CryptoRepository; database: DatabaseRepository; + email: EmailRepository; job: JobRepository; user: UserRepository; logger: LoggingRepository; memory: MemoryRepository; + notification: NotificationRepository; partner: PartnerRepository; person: PersonRepository; search: SearchRepository; @@ -142,6 +146,11 @@ export const getRepository = (key: K, db: Kys return new DatabaseRepository(db, new LoggingRepository(undefined, configRepo), configRepo); } + case 'email': { + const logger = new LoggingRepository(undefined, new ConfigRepository()); + return new EmailRepository(logger); + } + case 'logger': { const configMock = { getEnv: () => ({ noColor: false }) }; return new LoggingRepository(undefined, configMock as ConfigRepository); @@ -151,6 +160,10 @@ export const getRepository = (key: K, db: Kys return new MemoryRepository(db); } + case 'notification': { + return new NotificationRepository(db); + } + case 'partner': { return new PartnerRepository(db); } @@ -160,7 +173,7 @@ export const getRepository = (key: K, db: Kys } case 'search': { - return new SearchRepository(db); + return new SearchRepository(db, new ConfigRepository()); } case 'session': { @@ -221,6 +234,10 @@ const getRepositoryMock = (key: K) => { }); } + case 'email': { + return automock(EmailRepository, { args: [{ setContext: () => {} }] }); + } + case 'job': { return automock(JobRepository, { args: [undefined, undefined, undefined, { setContext: () => {} }] }); } @@ -234,6 +251,10 @@ const getRepositoryMock = (key: K) => { return automock(MemoryRepository); } + case 'notification': { + return automock(NotificationRepository); + } + case 'partner': { return automock(PartnerRepository); } @@ -284,7 +305,7 @@ export const asDeps = (repositories: ServiceOverrides) => { repositories.crypto || getRepositoryMock('crypto'), repositories.database || getRepositoryMock('database'), repositories.downloadRepository, - repositories.email, + repositories.email || getRepositoryMock('email'), repositories.event, repositories.job || getRepositoryMock('job'), repositories.library, @@ -294,6 +315,7 @@ export const asDeps = (repositories: ServiceOverrides) => { repositories.memory || getRepositoryMock('memory'), repositories.metadata, repositories.move, + repositories.notification || getRepositoryMock('notification'), repositories.oauth, repositories.partner || getRepositoryMock('partner'), repositories.person || getRepositoryMock('person'), diff --git a/server/test/medium/specs/controllers/notification.controller.spec.ts b/server/test/medium/specs/controllers/notification.controller.spec.ts new file mode 100644 index 0000000000..f4a0ec82d5 --- /dev/null +++ b/server/test/medium/specs/controllers/notification.controller.spec.ts @@ -0,0 +1,86 @@ +import { NotificationController } from 'src/controllers/notification.controller'; +import { AuthService } from 'src/services/auth.service'; +import { NotificationService } from 'src/services/notification.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { createControllerTestApp, TestControllerApp } from 'test/medium/utils'; +import { factory } from 'test/small.factory'; + +describe(NotificationController.name, () => { + let realApp: TestControllerApp; + let mockApp: TestControllerApp; + + beforeEach(async () => { + realApp = await createControllerTestApp({ authType: 'real' }); + mockApp = await createControllerTestApp({ authType: 'mock' }); + }); + + describe('GET /notifications', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get('/notifications'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should call the service with an auth dto', async () => { + const auth = factory.auth({ user: factory.user() }); + mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth); + const service = mockApp.getMockedService(NotificationService); + + const { status } = await request(mockApp.getHttpServer()) + .get('/notifications') + .set('Authorization', `Bearer token`); + + expect(status).toBe(200); + expect(service.search).toHaveBeenCalledWith(auth, {}); + }); + + it(`should reject an invalid notification level`, async () => { + const auth = factory.auth({ user: factory.user() }); + mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth); + const service = mockApp.getMockedService(NotificationService); + + const { status, body } = await request(mockApp.getHttpServer()) + .get(`/notifications`) + .query({ level: 'invalid' }) + .set('Authorization', `Bearer token`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('level must be one of the following values')])); + expect(service.search).not.toHaveBeenCalled(); + }); + }); + + describe('PUT /notifications', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()) + .put(`/notifications`) + .send({ ids: [], readAt: new Date().toISOString() }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('GET /notifications/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get(`/notifications/${factory.uuid()}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('PUT /notifications/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()) + .put(`/notifications/${factory.uuid()}`) + .send({ readAt: factory.date() }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + afterAll(async () => { + await realApp.close(); + await mockApp.close(); + }); +}); diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index ec5115b839..5b98b95e27 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -37,6 +37,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + notification: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + person: { checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index e9f624d6bf..c6ab11aaa1 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -8,7 +8,7 @@ export const newMediaRepositoryMock = (): Mocked Promise.resolve()), generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), - extract: vitest.fn().mockResolvedValue(false), + extract: vitest.fn().mockResolvedValue(null), probe: vitest.fn(), transcode: vitest.fn(), getImageDimensions: vitest.fn(), diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 29eef7002e..d2742f7f80 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -140,6 +140,7 @@ const userFactory = (user: Partial = {}) => ({ id: newUuid(), name: 'Test User', email: 'test@immich.cloud', + avatarColor: null, profileImagePath: '', profileChangedAt: newDate(), ...user, @@ -155,6 +156,7 @@ const userAdminFactory = (user: Partial = {}) => { storageLabel = null, shouldChangePassword = false, isAdmin = false, + avatarColor = null, createdAt = newDate(), updatedAt = newDate(), deletedAt = null, @@ -173,6 +175,7 @@ const userAdminFactory = (user: Partial = {}) => { storageLabel, shouldChangePassword, isAdmin, + avatarColor, createdAt, updatedAt, deletedAt, @@ -311,4 +314,5 @@ export const factory = { sidecarWrite: assetSidecarWriteFactory, }, uuid: newUuid, + date: newDate, }; diff --git a/server/test/utils.ts b/server/test/utils.ts index c7c29d310e..2c444f491e 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -135,6 +136,7 @@ export type ServiceOverrides = { memory: MemoryRepository; metadata: MetadataRepository; move: MoveRepository; + notification: NotificationRepository; oauth: OAuthRepository; partner: PartnerRepository; person: PersonRepository; @@ -202,6 +204,7 @@ export const newTestService = ( memory: automock(MemoryRepository), metadata: newMetadataRepositoryMock(), move: automock(MoveRepository, { strict: false }), + notification: automock(NotificationRepository), oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: newPersonRepositoryMock(), @@ -250,6 +253,7 @@ export const newTestService = ( overrides.memory || (mocks.memory as As), overrides.metadata || (mocks.metadata as As), overrides.move || (mocks.move as As), + overrides.notification || (mocks.notification as As), overrides.oauth || (mocks.oauth as As), overrides.partner || (mocks.partner as As), overrides.person || (mocks.person as As), diff --git a/web/Dockerfile b/web/Dockerfile index 8c2e67e62e..c4244a1aa0 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.14.0-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 +FROM node:22.15.0-alpine3.20@sha256:686b8892b69879ef5bfd6047589666933508f9a5451c67320df3070ba0e9807b RUN apk add --no-cache tini USER node diff --git a/web/eslint.config.js b/web/eslint.config.js index 5c24cd1aeb..9ced619504 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -58,6 +58,8 @@ export default typescriptEslint.config( }, }, + ignores: ['**/service-worker/**'], + rules: { '@typescript-eslint/no-unused-vars': [ 'warn', diff --git a/web/package-lock.json b/web/package-lock.json index 37f944d3bb..c76dd64840 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.17.3", + "@immich/ui": "^0.18.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -1320,9 +1320,9 @@ "link": true }, "node_modules/@immich/ui": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.17.4.tgz", - "integrity": "sha512-a6M7Fxno5fwY5A0kxdluS8r+A4L6xZhSTKMW8c8hoFhQHvbBTHAsGFKQF3GOEQLOlUuvsS2Lt7dMevBlAPgo/A==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.18.1.tgz", + "integrity": "sha512-XWWO6OTfH3MektyxCn0hWefZyOGyWwwx/2zHinuShpxTHSyfveJ4mOkFP8DkyMz0dnvJ1EfdkPBMkld3y5R/Hw==", "license": "GNU Affero General Public License version 3", "dependencies": { "@mdi/js": "^7.4.47", @@ -2443,17 +2443,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.30.1.tgz", - "integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz", + "integrity": "sha512-evaQJZ/J/S4wisevDvC1KFZkPzRetH8kYZbkgcTRyql3mcKsf+ZFDV1BVWUGTCAW5pQHoqn5gK5b8kn7ou9aFQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/type-utils": "8.30.1", - "@typescript-eslint/utils": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/type-utils": "8.31.0", + "@typescript-eslint/utils": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2473,16 +2473,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.30.1.tgz", - "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.0.tgz", + "integrity": "sha512-67kYYShjBR0jNI5vsf/c3WG4u+zDnCTHTPqVMQguffaWWFs7artgwKmfwdifl+r6XyM5LYLas/dInj2T0SgJyw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/typescript-estree": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/typescript-estree": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "debug": "^4.3.4" }, "engines": { @@ -2498,14 +2498,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.30.1.tgz", - "integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.0.tgz", + "integrity": "sha512-knO8UyF78Nt8O/B64i7TlGXod69ko7z6vJD9uhSlm0qkAbGeRUSudcm0+K/4CrRjrpiHfBCjMWlc08Vav1xwcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1" + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2516,14 +2516,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.30.1.tgz", - "integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.0.tgz", + "integrity": "sha512-DJ1N1GdjI7IS7uRlzJuEDCgDQix3ZVYVtgeWEyhyn4iaoitpMBX6Ndd488mXSx0xah/cONAkEaYyylDyAeHMHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.30.1", - "@typescript-eslint/utils": "8.30.1", + "@typescript-eslint/typescript-estree": "8.31.0", + "@typescript-eslint/utils": "8.31.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -2540,9 +2540,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.30.1.tgz", - "integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.0.tgz", + "integrity": "sha512-Ch8oSjVyYyJxPQk8pMiP2FFGYatqXQfQIaMp+TpuuLlDachRWpUAeEu1u9B/v/8LToehUIWyiKcA/w5hUFRKuQ==", "dev": true, "license": "MIT", "engines": { @@ -2554,14 +2554,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.30.1.tgz", - "integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.0.tgz", + "integrity": "sha512-xLmgn4Yl46xi6aDSZ9KkyfhhtnYI15/CvHbpOy/eR5NWhK/BK8wc709KKwhAR0m4ZKRP7h07bm4BWUYOCuRpQQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/visitor-keys": "8.30.1", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2607,16 +2607,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.30.1.tgz", - "integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.0.tgz", + "integrity": "sha512-qi6uPLt9cjTFxAb1zGNgTob4x9ur7xC6mHQJ8GwEzGMGE9tYniublmJaowOJ9V2jUzxrltTPfdG2nKlWsq0+Ww==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.30.1", - "@typescript-eslint/types": "8.30.1", - "@typescript-eslint/typescript-estree": "8.30.1" + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/typescript-estree": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2631,13 +2631,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.30.1.tgz", - "integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.0.tgz", + "integrity": "sha512-QcGHmlRHWOl93o64ZUMNewCdwKGU6WItOU52H0djgNmn1EOrhVudrDzXz4OycCRSCPwFCDrE2iIt5vmuUdHxuQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.30.1", + "@typescript-eslint/types": "8.31.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -2649,9 +2649,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.1.tgz", - "integrity": "sha512-MgV6D2dhpD6Hp/uroUoAIvFqA8AuvXEFBC2eepG3WFc1pxTfdk1LEqqkWoWhjz+rytoqrnUUCdf6Lzco3iHkLQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.2.tgz", + "integrity": "sha512-XDdaDOeaTMAMYW7N63AqoK32sYUWbXnTkC6tEbVcu3RlU1bB9of32T+PGf8KZvxqLNqeXhafDFqCkwpf2+dyaQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2664,7 +2664,7 @@ "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", - "std-env": "^3.8.1", + "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, @@ -2672,8 +2672,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.1.1", - "vitest": "3.1.1" + "@vitest/browser": "3.1.2", + "vitest": "3.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2682,14 +2682,14 @@ } }, "node_modules/@vitest/expect": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", - "integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.2.tgz", + "integrity": "sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@vitest/spy": "3.1.2", + "@vitest/utils": "3.1.2", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -2698,13 +2698,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", - "integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.2.tgz", + "integrity": "sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", + "@vitest/spy": "3.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -2735,9 +2735,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz", + "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==", "dev": true, "license": "MIT", "dependencies": { @@ -2748,13 +2748,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", - "integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.2.tgz", + "integrity": "sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.1", + "@vitest/utils": "3.1.2", "pathe": "^2.0.3" }, "funding": { @@ -2762,13 +2762,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", - "integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.2.tgz", + "integrity": "sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.2", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -2777,9 +2777,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", - "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.2.tgz", + "integrity": "sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==", "dev": true, "license": "MIT", "dependencies": { @@ -2790,13 +2790,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz", + "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.1.2", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -4006,9 +4006,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -5965,9 +5965,9 @@ } }, "node_modules/maplibre-gl": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.3.1.tgz", - "integrity": "sha512-Ihx+oUUSsZkjMou1Cw5J6silE+5OtFFQSPslWF9+7v4yFC/XDHrpsORYO9lWE4KZI0djCEUpZQJpkpnMArAbeA==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.4.0.tgz", + "integrity": "sha512-ZVrtdFIhFAqt53H2k5Ssqn7QIKNI19fW+He5tr4loxZxWZffp1aZYY9ImNncAJaALU/NYlV6Eul7UVB56/N7WQ==", "license": "BSD-3-Clause", "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", @@ -8178,9 +8178,9 @@ } }, "node_modules/svelte": { - "version": "5.27.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.27.3.tgz", - "integrity": "sha512-MK16NUEFwAunCkdJpIIJ6hvKElx0zFlKMqQd7NAIugMfrL0YeOH8VEn5pg9g2Q6RLj2JrGJL6c0zaAwmXx/nHQ==", + "version": "5.28.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.28.2.tgz", + "integrity": "sha512-FbWBxgWOpQfhKvoGJv/TFwzqb4EhJbwCD17dB0tEpQiw1XyUEKZJtgm4nA4xq3LLsMo7hu5UY/BOFmroAxKTMg==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -8256,9 +8256,9 @@ } }, "node_modules/svelte-gestures": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/svelte-gestures/-/svelte-gestures-5.1.3.tgz", - "integrity": "sha512-ELOlzuH9E4+S1biCCTfusRlvzFpnqRPlljEqayoBTu5STH42u0kTT45D1m3Py3E9UmIyZTgrSLw6Fus/fh75Dw==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/svelte-gestures/-/svelte-gestures-5.1.4.tgz", + "integrity": "sha512-gfSO/GqWLu9nRMCz12jqdyA0+NTsojYcIBcRqZjwWrpQbqMXr0zWPFpZBtzfYbRHtuFxZImMZp9MrVaFCYbhDg==", "license": "MIT" }, "node_modules/svelte-i18n": { @@ -8922,15 +8922,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.30.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.30.1.tgz", - "integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==", + "version": "8.31.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.31.0.tgz", + "integrity": "sha512-u+93F0sB0An8WEAPtwxVhFby573E8ckdjwUUQUj9QA4v8JAvgtoDdIyYR3XFwFHq2W1KJ1AurwJCO+w+Y1ixyQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.30.1", - "@typescript-eslint/parser": "8.30.1", - "@typescript-eslint/utils": "8.30.1" + "@typescript-eslint/eslint-plugin": "8.31.0", + "@typescript-eslint/parser": "8.31.0", + "@typescript-eslint/utils": "8.31.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9057,18 +9057,18 @@ } }, "node_modules/vite": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", - "integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==", + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz", + "integrity": "sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.3", + "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", - "tinyglobby": "^0.2.12" + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -9147,9 +9147,9 @@ } }, "node_modules/vite-node": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", - "integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.2.tgz", + "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", "dev": true, "license": "MIT", "dependencies": { @@ -9621,31 +9621,32 @@ } }, "node_modules/vitest": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", - "integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.2.tgz", + "integrity": "sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.1.1", - "@vitest/mocker": "3.1.1", - "@vitest/pretty-format": "^3.1.1", - "@vitest/runner": "3.1.1", - "@vitest/snapshot": "3.1.1", - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@vitest/expect": "3.1.2", + "@vitest/mocker": "3.1.2", + "@vitest/pretty-format": "^3.1.2", + "@vitest/runner": "3.1.2", + "@vitest/snapshot": "3.1.2", + "@vitest/spy": "3.1.2", + "@vitest/utils": "3.1.2", "chai": "^5.2.0", "debug": "^4.4.0", - "expect-type": "^1.2.0", + "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", - "std-env": "^3.8.1", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.1", + "vite-node": "3.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -9661,8 +9662,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.1", - "@vitest/ui": "3.1.1", + "@vitest/browser": "3.1.2", + "@vitest/ui": "3.1.2", "happy-dom": "*", "jsdom": "*" }, diff --git a/web/package.json b/web/package.json index c32e7b04a8..9aa9bee6bc 100644 --- a/web/package.json +++ b/web/package.json @@ -12,7 +12,7 @@ "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte", "check:typescript": "tsc --noEmit", "check:watch": "npm run check:svelte -- --watch", - "check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript", + "check:code": "npm run format && npm run lint:p && npm run check:svelte && npm run check:typescript", "check:all": "npm run check:code && npm run test:cov", "lint": "eslint . --max-warnings 0", "lint:p": "eslint-p . --max-warnings 0 --concurrency=4", @@ -27,7 +27,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.17.3", + "@immich/ui": "^0.18.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 80dd29e0be..c77ff60f22 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -51,7 +51,7 @@ let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused); let multipleButtons = $derived(allText || refreshText); - const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6'; + const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pe-4 ps-6';

{$t('active')}

@@ -119,7 +119,7 @@

{waitingCount.toLocaleString($locale)} diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte index bb288511ac..8bae8fee4b 100644 --- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte +++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte @@ -79,7 +79,7 @@ {zeros(statsUsage)}{statsUsage} - {statsUsageUnit} + {statsUsageUnit}

@@ -88,7 +88,7 @@

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

- +
diff --git a/web/src/lib/components/admin-page/server-stats/stats-card.svelte b/web/src/lib/components/admin-page/server-stats/stats-card.svelte index 14d32c055f..b1804427e9 100644 --- a/web/src/lib/components/admin-page/server-stats/stats-card.svelte +++ b/web/src/lib/components/admin-page/server-stats/stats-card.svelte @@ -31,7 +31,7 @@ class="text-immich-primary dark:text-immich-dark-primary">{value} {#if unit} - {unit} + {unit} {/if} diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index 5380a76286..b2454b06c3 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -1,16 +1,17 @@ -
+
@@ -48,18 +48,18 @@ class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg" > toggleAlbumGroupCollapsing(albumGroup.id)} aria-expanded={!isCollapsed} > - diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index 1496c1ce66..9ee7cc550d 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -94,7 +94,7 @@ -
+

{user.name}

@@ -136,7 +136,7 @@ class="flex w-full place-items-center gap-4 p-4" > -
+

{user.name}

diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index caa1ced290..94b66d4c22 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -186,7 +186,7 @@ > {#each reactions as reaction, index (reaction.id)} {#if reaction.type === ReactionType.Comment} -
+
@@ -202,7 +202,7 @@ {/if} {#if reaction.user.id === user.id || albumOwnerId === user.id} -
+
-
+
@@ -255,7 +255,7 @@ {/if} {#if reaction.user.id === user.id || albumOwnerId === user.id} -
+
{#if isSendingMessage} -
+
{:else if message} -
+
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 91461d574d..b05c89f38f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -5,6 +5,7 @@ import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { updateNumberOfComments } from '$lib/stores/activity.store'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -12,7 +13,7 @@ import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; import { websocketEvents } from '$lib/stores/websocket'; - import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils'; + import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { @@ -116,7 +117,7 @@ let zoomToggle = $state(() => void 0); const refreshStack = async () => { - if (isSharedLink()) { + if (authManager.key) { return; } @@ -243,7 +244,7 @@ }); const handleGetAllAlbums = async () => { - if (isSharedLink()) { + if (authManager.key) { return; } @@ -412,7 +413,7 @@ } }); $effect(() => { - if (asset.id && !sharedLink) { + if (asset.id) { handlePromiseError(handleGetAllAlbums()); } }); @@ -422,7 +423,7 @@
@@ -547,7 +548,7 @@ /> {/if} {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)} -
+
($isShowDetail = false)} /> @@ -582,7 +583,7 @@
@@ -631,7 +632,7 @@
(isOwner ? (isShowChangeLocation = true) : null)} title={isOwner ? $t('edit_location') : ''} class:hover:dark:text-immich-dark-primary={isOwner} @@ -68,7 +68,7 @@ {:else if !asset.exifInfo?.city && isOwner} {$t('merge')} {/snippet} diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 4aff4e96f8..b740953340 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -54,7 +54,7 @@ circle /> {#if person.isFavorite} -
+
{/if} @@ -62,7 +62,7 @@
{#if showVerticalDots} -
+
($boundingBoxesArray = [peopleWithFaces[index]])} onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} onmouseleave={() => ($boundingBoxesArray = [])} @@ -303,7 +303,7 @@

{/if} -
+
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]} handleReset(face.id)} /> {:else} @@ -321,29 +321,29 @@ title={$t('select_new_face')} size="18" padding="1" - class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform" + class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform" onclick={() => handleFacePicker(face)} /> {/if}
-
+
{#if !selectedPersonToCreate[face.id] && !selectedPersonToReassign[face.id] && !face.person}
{/if}
{#if face.person != null} -
+
deleteAssetFace(face)} />
diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte index e808c98748..41c584d602 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -120,7 +120,7 @@
{#snippet leading()} @@ -140,7 +140,7 @@ {:else} {/if} - {$t('create_new_person')} {$t('create_new_person')} {$t('reassign')}
{/snippet} diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte index 639b81071f..64c32532ef 100644 --- a/web/src/lib/components/forms/library-import-paths-form.svelte +++ b/web/src/lib/components/forms/library-import-paths-form.svelte @@ -173,7 +173,7 @@ {/if} -
+ {albumGroup.name} - + ({$t('albums_count', { values: { count: albumGroup.albums.length } })})
+
{#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)} -
+ {#if validatedPath.isValid} - +
{#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)}

{tag.value} @@ -81,7 +81,7 @@ diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte new file mode 100644 index 0000000000..be9fcd2a44 --- /dev/null +++ b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte @@ -0,0 +1,82 @@ + + +

+ +
+ {$t('notifications')} +
+ +
+
+ +
+ + {#if noUnreadNotifications} + + + {$t('no_notifications')} + + {:else} + + + {#each notificationManager.notifications as notification (notification.id)} +
+ markAsRead(id)} /> +
+ {/each} +
+
+ {/if} +
+
diff --git a/web/src/lib/components/shared-components/navigation-loading-bar.svelte b/web/src/lib/components/shared-components/navigation-loading-bar.svelte index bd5f15ae4e..afdb8ee967 100644 --- a/web/src/lib/components/shared-components/navigation-loading-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-loading-bar.svelte @@ -26,7 +26,7 @@ {#if showing} -
+
{/if} 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 5054c18695..30c8622bba 100644 --- a/web/src/lib/components/shared-components/notification/notification-card.svelte +++ b/web/src/lib/components/shared-components/notification/notification-card.svelte @@ -100,7 +100,7 @@ />
-

+

{#if isComponentNotification(notification)} {:else} @@ -109,7 +109,7 @@

{#if notification.button} -

+

@@ -145,7 +145,7 @@ type="button" in:scale={{ duration: 250, easing: quartInOut }} onclick={() => (showDetail = true)} - class="absolute -right-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-error p-5 text-xs text-gray-200" + class="absolute -end-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-error p-5 text-xs text-gray-200" > {$stats.errors.toLocaleString($locale)} 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 63209ca289..f1d8e14787 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -1,11 +1,12 @@

-
+
diff --git a/web/src/lib/components/user-settings-page/user-profile-settings.svelte b/web/src/lib/components/user-settings-page/user-profile-settings.svelte index c36c36d7cc..90487f532f 100644 --- a/web/src/lib/components/user-settings-page/user-profile-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-profile-settings.svelte @@ -42,7 +42,7 @@
-
+
{#if isServerProduct}
@@ -152,7 +152,7 @@ {/if} {:else}
diff --git a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte index f7de1d8f64..ad77516d55 100644 --- a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte +++ b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte @@ -68,7 +68,7 @@

{$t('photos_and_videos')}

-
+
@@ -92,7 +92,7 @@

{$t('albums')}

-
+
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 97f44e3ec4..b8409cb0ef 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -44,14 +44,14 @@ {#if asset.isFavorite} -
+
{/if}
@@ -59,7 +59,7 @@
-
+
{#if isFromExternalLibrary}
{$t('external')} @@ -68,7 +68,7 @@ {#if asset.stack?.assetCount}
-
{asset.stack.assetCount}
+
{asset.stack.assetCount}
@@ -79,7 +79,7 @@
@@ -143,21 +132,11 @@
{#if trashCount === 0} - {:else} -
+ +
+ {#each assets as asset (asset.id)} + setAsset(asset)} + /> + {/each} +
{#if $showAssetViewer} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 2c21d04865..f5b044cd41 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -268,8 +268,8 @@ export const defaultLang = { name: 'English', code: 'en', loader: () => import(' export const langs = [ { name: 'Afrikaans', code: 'af', loader: () => import('$i18n/af.json') }, - { name: 'Arabic', code: 'ar', loader: () => import('$i18n/ar.json') }, - { name: 'Azerbaijani', code: 'az', loader: () => import('$i18n/az.json') }, + { name: 'Arabic', code: 'ar', loader: () => import('$i18n/ar.json'), rtl: true }, + { name: 'Azerbaijani', code: 'az', loader: () => import('$i18n/az.json'), rtl: true }, { name: 'Belarusian', code: 'be', loader: () => import('$i18n/be.json') }, { name: 'Bulgarian', code: 'bg', loader: () => import('$i18n/bg.json') }, { name: 'Bislama', code: 'bi', loader: () => import('$i18n/bi.json') }, @@ -284,12 +284,12 @@ export const langs = [ { name: 'Spanish', code: 'es', loader: () => import('$i18n/es.json') }, { name: 'Estonian', code: 'et', loader: () => import('$i18n/et.json') }, { name: 'Basque', code: 'eu', loader: () => import('$i18n/eu.json') }, - { name: 'Persian', code: 'fa', loader: () => import('$i18n/fa.json') }, + { name: 'Persian', code: 'fa', loader: () => import('$i18n/fa.json'), rtl: true }, { name: 'Finnish', code: 'fi', loader: () => import('$i18n/fi.json') }, { name: 'Filipino', code: 'fil', loader: () => import('$i18n/fil.json') }, { name: 'French', code: 'fr', loader: () => import('$i18n/fr.json') }, { name: 'Galician', code: 'gl', loader: () => import('$i18n/gl.json') }, - { name: 'Hebrew', code: 'he', loader: () => import('$i18n/he.json') }, + { name: 'Hebrew', code: 'he', loader: () => import('$i18n/he.json'), rtl: true }, { name: 'Hindi', code: 'hi', loader: () => import('$i18n/hi.json') }, { name: 'Croatian', code: 'hr', loader: () => import('$i18n/hr.json') }, { name: 'Hungarian', code: 'hu', loader: () => import('$i18n/hu.json') }, @@ -299,7 +299,7 @@ export const langs = [ { name: 'Japanese', code: 'ja', loader: () => import('$i18n/ja.json') }, { name: 'Georgian', code: 'ka', loader: () => import('$i18n/ka.json') }, { name: 'Kazakh', code: 'kk', loader: () => import('$i18n/kk.json') }, - { name: 'Kurdish (Northern)', code: 'kmr', loader: () => import('$i18n/kmr.json') }, + { name: 'Kurdish (Northern)', code: 'kmr', loader: () => import('$i18n/kmr.json'), rtl: true }, { name: 'Kannada', code: 'kn', loader: () => import('$i18n/kn.json') }, { name: 'Korean', code: 'ko', loader: () => import('$i18n/ko.json') }, { name: 'Luxembourgish', code: 'lb', loader: () => import('$i18n/lb.json') }, @@ -335,7 +335,7 @@ export const langs = [ { name: 'Thai', code: 'th', loader: () => import('$i18n/th.json') }, { name: 'Turkish', code: 'tr', loader: () => import('$i18n/tr.json') }, { name: 'Ukrainian', code: 'uk', loader: () => import('$i18n/uk.json') }, - { name: 'Urdu', code: 'ur', loader: () => import('$i18n/ur.json') }, + { name: 'Urdu', code: 'ur', loader: () => import('$i18n/ur.json'), rtl: true }, { name: 'Vietnamese', code: 'vi', loader: () => import('$i18n/vi.json') }, { name: 'Chinese (Traditional)', diff --git a/web/src/lib/stores/auth-manager.svelte.ts b/web/src/lib/managers/auth-manager.svelte.ts similarity index 74% rename from web/src/lib/stores/auth-manager.svelte.ts rename to web/src/lib/managers/auth-manager.svelte.ts index 72c966df0b..9e8e30b773 100644 --- a/web/src/lib/stores/auth-manager.svelte.ts +++ b/web/src/lib/managers/auth-manager.svelte.ts @@ -1,9 +1,13 @@ import { goto } from '$app/navigation'; +import { page } from '$app/state'; import { AppRoute } from '$lib/constants'; -import { eventManager } from '$lib/stores/event-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { isSharedLinkRoute } from '$lib/utils/navigation'; import { logout } from '@immich/sdk'; class AuthManager { + key = $derived(isSharedLinkRoute(page.route?.id) ? page.params.key : undefined); + async logout() { let redirectUri; diff --git a/web/src/lib/stores/download-store.svelte.ts b/web/src/lib/managers/download-manager.svelte.ts similarity index 74% rename from web/src/lib/stores/download-store.svelte.ts rename to web/src/lib/managers/download-manager.svelte.ts index 8c03671e73..107f80b8dc 100644 --- a/web/src/lib/stores/download-store.svelte.ts +++ b/web/src/lib/managers/download-manager.svelte.ts @@ -5,7 +5,7 @@ export interface DownloadProgress { abort: AbortController | null; } -class DownloadStore { +class DownloadManager { assets = $state>({}); isDownloading = $derived(Object.keys(this.assets).length > 0); @@ -42,10 +42,4 @@ class DownloadStore { } } -export const downloadStore = new DownloadStore(); - -export const downloadManager = { - add: (key: string, total: number, abort?: AbortController) => downloadStore.add(key, total, abort), - clear: (key: string) => downloadStore.clear(key), - update: (key: string, progress: number, total?: number) => downloadStore.update(key, progress, total), -}; +export const downloadManager = new DownloadManager(); diff --git a/web/src/lib/stores/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts similarity index 84% rename from web/src/lib/stores/event-manager.svelte.ts rename to web/src/lib/managers/event-manager.svelte.ts index 09e9b45c3c..f8e39411cf 100644 --- a/web/src/lib/stores/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -1,3 +1,6 @@ +import type { ThemeSetting } from '$lib/managers/theme-manager.svelte'; +import type { LoginResponseDto } from '@immich/sdk'; + type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void; class EventManager> { @@ -49,6 +52,10 @@ class EventManager> { } export const eventManager = new EventManager<{ + 'app.init': []; 'user.login': []; + 'auth.login': [LoginResponseDto]; 'auth.logout': []; + 'language.change': [{ name: string; code: string; rtl?: boolean }]; + 'theme.change': [ThemeSetting]; }>(); diff --git a/web/src/lib/managers/language-manager.svelte.ts b/web/src/lib/managers/language-manager.svelte.ts new file mode 100644 index 0000000000..5acae27aa3 --- /dev/null +++ b/web/src/lib/managers/language-manager.svelte.ts @@ -0,0 +1,26 @@ +import { langs } from '$lib/constants'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { lang } from '$lib/stores/preferences.store'; + +class LanguageManager { + constructor() { + eventManager.on('app.init', () => lang.subscribe((lang) => this.setLanguage(lang))); + } + + rtl = $state(false); + + setLanguage(code: string) { + const item = langs.find((item) => item.code === code); + if (!item) { + return; + } + + this.rtl = item.rtl ?? false; + + document.body.setAttribute('dir', item.rtl ? 'rtl' : 'ltr'); + + eventManager.emit('language.change', item); + } +} + +export const languageManager = new LanguageManager(); diff --git a/web/src/lib/managers/theme-manager.svelte.ts b/web/src/lib/managers/theme-manager.svelte.ts new file mode 100644 index 0000000000..a20e5f9a98 --- /dev/null +++ b/web/src/lib/managers/theme-manager.svelte.ts @@ -0,0 +1,78 @@ +import { browser } from '$app/environment'; +import { Theme } from '$lib/constants'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { PersistedLocalStorage } from '$lib/utils/persisted'; + +export interface ThemeSetting { + value: Theme; + system: boolean; +} + +const getDefaultTheme = () => { + if (!browser) { + return Theme.DARK; + } + + return globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT; +}; + +class ThemeManager { + #theme = new PersistedLocalStorage( + 'color-theme', + { value: getDefaultTheme(), system: false }, + { + valid: (value): value is ThemeSetting => { + return Object.values(Theme).includes((value as ThemeSetting)?.value); + }, + }, + ); + + get theme() { + return this.#theme.current; + } + + value = $derived(this.theme.value); + + isDark = $derived(this.value === Theme.DARK); + + constructor() { + eventManager.on('app.init', () => this.#onAppInit()); + } + + setSystem(system: boolean) { + this.#update(system ? 'system' : getDefaultTheme()); + } + + setTheme(theme: Theme) { + this.#update(theme); + } + + toggleTheme() { + this.#update(this.value === Theme.DARK ? Theme.LIGHT : Theme.DARK); + } + + #onAppInit() { + globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (this.theme.system) { + this.#update('system'); + } + }); + } + + #update(value: Theme | 'system') { + const theme: ThemeSetting = + value === 'system' ? { system: true, value: getDefaultTheme() } : { system: false, value }; + + if (theme.value === Theme.LIGHT) { + document.documentElement.classList.remove('dark'); + } else { + document.documentElement.classList.add('dark'); + } + + this.#theme.current = theme; + + eventManager.emit('theme.change', theme); + } +} + +export const themeManager = new ThemeManager(); diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index 689556b522..98b9e99aa2 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -1,4 +1,4 @@ -import { getKey } from '$lib/utils'; +import { authManager } from '$lib/managers/auth-manager.svelte'; import { type AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { readonly, writable } from 'svelte/store'; @@ -16,7 +16,7 @@ function createAssetViewingStore() { }; const setAssetId = async (id: string): Promise => { - const asset = await getAssetInfo({ id, key: getKey() }); + const asset = await getAssetInfo({ id, key: authManager.key }); setAsset(asset); return asset; }; diff --git a/web/src/lib/stores/assets-store.spec.ts b/web/src/lib/stores/assets-store.spec.ts index 0685103a1b..3d0292bde8 100644 --- a/web/src/lib/stores/assets-store.spec.ts +++ b/web/src/lib/stores/assets-store.spec.ts @@ -48,15 +48,15 @@ describe('AssetStore', () => { expect(plainBuckets).toEqual( expect.arrayContaining([ - expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 304 }), - expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4515.333_333_333_333 }), + expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 303 }), + expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4514.333_333_333_333 }), expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(assetStore.timelineHeight).toBe(5105.333_333_333_333); + expect(assetStore.timelineHeight).toBe(5103.333_333_333_333); }); }); diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index 25dc5d4b86..d96ab282da 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -1,5 +1,4 @@ import { locale } from '$lib/stores/preferences.store'; -import { getKey } from '$lib/utils'; import { CancellableTask } from '$lib/utils/cancellable-task'; import { getJustifiedLayoutFromAssets, @@ -21,6 +20,7 @@ import { clamp, debounce, isEqual, throttle } from 'lodash-es'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; +import { authManager } from '$lib/managers/auth-manager.svelte'; import { SvelteSet } from 'svelte/reactivity'; import { get, writable, type Unsubscriber } from 'svelte/store'; import { handleError } from '../utils/handle-error'; @@ -35,9 +35,7 @@ export type AssetStoreOptions = Omit & { timelineAlbumId?: string; deferInit?: boolean; }; -export type AssetStoreLayoutOptions = { - rowHeight: number; -}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any function updateObject(target: any, source: any): boolean { if (!target) { @@ -110,7 +108,6 @@ export class AssetDateGroup { readonly date: DateTime; readonly dayOfMonth: number; intersetingAssets: IntersectingAsset[] = $state([]); - dodo: IntersectingAsset[] = $state([]); height = $state(0); width = $state(0); @@ -121,6 +118,7 @@ export class AssetDateGroup { left: number = $state(0); row = $state(0); col = $state(0); + deferredLayout = false; constructor(bucket: AssetBucket, index: number, date: DateTime, dayOfMonth: number) { this.index = index; @@ -195,6 +193,10 @@ export class AssetDateGroup { } layout(options: CommonLayoutOptions) { + if (!this.bucket.intersecting) { + this.deferredLayout = true; + return; + } const assets = this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!); const geometry = getJustifiedLayoutFromAssets(assets, options); this.width = geometry.containerWidth; @@ -577,6 +579,11 @@ export type LiteBucket = { bucketDateFormattted: string; }; +type AssetStoreLayoutOptions = { + rowHeight?: number; + headerHeight?: number; + gap?: number; +}; export class AssetStore { // --- public ---- isInitialized = $state(false); @@ -626,7 +633,7 @@ export class AssetStore { #unsubscribers: Unsubscriber[] = []; #rowHeight = $state(235); - #headerHeight = $state(49); + #headerHeight = $state(48); #gap = $state(12); #options: AssetStoreOptions = AssetStore.#INIT_OPTIONS; @@ -638,36 +645,46 @@ export class AssetStore { constructor() {} - set headerHeight(value) { + setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: AssetStoreLayoutOptions) { + let changed = false; + changed ||= this.#setHeaderHeight(headerHeight); + changed ||= this.#setGap(gap); + changed ||= this.#setRowHeight(rowHeight); + if (changed) { + this.refreshLayout(); + } + } + + #setHeaderHeight(value: number) { if (this.#headerHeight == value) { - return; + return false; } this.#headerHeight = value; - this.refreshLayout(); + return true; } get headerHeight() { return this.#headerHeight; } - set gap(value) { + #setGap(value: number) { if (this.#gap == value) { - return; + return false; } this.#gap = value; - this.refreshLayout(); + return true; } get gap() { return this.#gap; } - set rowHeight(value) { + #setRowHeight(value: number) { if (this.#rowHeight == value) { - return; + return false; } this.#rowHeight = value; - this.refreshLayout(); + return true; } get rowHeight() { @@ -853,6 +870,15 @@ export class AssetStore { } bucket.intersecting = actuallyIntersecting || preIntersecting; bucket.actuallyIntersecting = actuallyIntersecting; + if (preIntersecting || actuallyIntersecting) { + const hasDeferred = bucket.dateGroups.some((group) => group.deferredLayout); + if (hasDeferred) { + this.#updateGeometry(bucket, true); + for (const group of bucket.dateGroups) { + group.deferredLayout = false; + } + } + } } #processPendingChanges = throttle(() => { @@ -877,7 +903,7 @@ export class AssetStore { const timebuckets = await getTimeBuckets({ ...this.#options, size: TimeBucketSize.Month, - key: getKey(), + key: authManager.key, }); this.buckets = timebuckets.map((bucket) => { @@ -1085,7 +1111,7 @@ export class AssetStore { ...this.#options, timeBucket: bucketDate, size: TimeBucketSize.Month, - key: getKey(), + key: authManager.key, }, { signal }, ); @@ -1096,7 +1122,7 @@ export class AssetStore { albumId: this.#options.timelineAlbumId, timeBucket: bucketDate, size: TimeBucketSize.Month, - key: getKey(), + key: authManager.key, }, { signal }, ); diff --git a/web/src/lib/stores/folders.svelte.ts b/web/src/lib/stores/folders.svelte.ts index c6fc7808b2..6f3eb8b66a 100644 --- a/web/src/lib/stores/folders.svelte.ts +++ b/web/src/lib/stores/folders.svelte.ts @@ -1,4 +1,4 @@ -import { eventManager } from '$lib/stores/event-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; import { getAssetsByOriginalPath, getUniqueOriginalPaths, diff --git a/web/src/lib/stores/memory.store.svelte.ts b/web/src/lib/stores/memory.store.svelte.ts index ef3f87a3aa..9ef3de4328 100644 --- a/web/src/lib/stores/memory.store.svelte.ts +++ b/web/src/lib/stores/memory.store.svelte.ts @@ -1,4 +1,4 @@ -import { eventManager } from '$lib/stores/event-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; import { asLocalTimeISO } from '$lib/utils/date-time'; import { type AssetResponseDto, diff --git a/web/src/lib/stores/notification-manager.svelte.ts b/web/src/lib/stores/notification-manager.svelte.ts new file mode 100644 index 0000000000..3eba15deed --- /dev/null +++ b/web/src/lib/stores/notification-manager.svelte.ts @@ -0,0 +1,41 @@ +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { handlePromiseError } from '$lib/utils'; +import { handleError } from '$lib/utils/handle-error'; +import { getNotifications, updateNotification, updateNotifications, type NotificationDto } from '@immich/sdk'; +import { t } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +class NotificationStore { + notifications = $state([]); + + constructor() { + eventManager.on('auth.login', () => handlePromiseError(this.refresh())); + eventManager.on('auth.logout', () => this.clear()); + } + + async refresh() { + try { + this.notifications = await getNotifications({ unread: true }); + } catch (error) { + const translate = get(t); + handleError(error, translate('errors.failed_to_load_notifications')); + } + } + + markAsRead = async (id: string) => { + this.notifications = this.notifications.filter((notification) => notification.id !== id); + await updateNotification({ id, notificationUpdateDto: { readAt: new Date().toISOString() } }); + }; + + markAllAsRead = async () => { + const ids = this.notifications.map(({ id }) => id); + this.notifications = []; + await updateNotifications({ notificationUpdateAllDto: { ids, readAt: new Date().toISOString() } }); + }; + + clear = () => { + this.notifications = []; + }; +} + +export const notificationManager = new NotificationStore(); diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index e268e8817d..e7f38eb6d0 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -2,39 +2,12 @@ import { browser } from '$app/environment'; import { Theme, defaultLang } from '$lib/constants'; import { getPreferredLocale } from '$lib/utils/i18n'; import { persisted } from 'svelte-persisted-store'; -import { get } from 'svelte/store'; export interface ThemeSetting { value: Theme; system: boolean; } -export const handleToggleTheme = () => { - const theme = get(colorTheme); - theme.value = theme.value === Theme.DARK ? Theme.LIGHT : Theme.DARK; - colorTheme.set(theme); -}; - -const initTheme = (): ThemeSetting => { - if (browser && globalThis.matchMedia && !globalThis.matchMedia('(prefers-color-scheme: dark)').matches) { - return { value: Theme.LIGHT, system: false }; - } - return { value: Theme.DARK, system: false }; -}; - -const initialTheme = initTheme(); - -// The 'color-theme' key is also used by app.html to prevent FOUC on page load. -export const colorTheme = persisted('color-theme', initialTheme, { - serializer: { - parse: (text: string): ThemeSetting => { - const parsedText: ThemeSetting = JSON.parse(text); - return Object.values(Theme).includes(parsedText.value) ? parsedText : initTheme(); - }, - stringify: (object) => JSON.stringify(object), - }, -}); - // Locale to use for formatting dates, numbers, etc. export const locale = persisted('locale', undefined, { serializer: { diff --git a/web/src/lib/stores/search.svelte.ts b/web/src/lib/stores/search.svelte.ts index f334f53460..32f2741955 100644 --- a/web/src/lib/stores/search.svelte.ts +++ b/web/src/lib/stores/search.svelte.ts @@ -1,4 +1,4 @@ -import { eventManager } from '$lib/stores/event-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; class SearchStore { savedSearchTerms = $state([]); diff --git a/web/src/lib/stores/user.store.ts b/web/src/lib/stores/user.store.ts index fe2288c252..0790788278 100644 --- a/web/src/lib/stores/user.store.ts +++ b/web/src/lib/stores/user.store.ts @@ -1,4 +1,4 @@ -import { eventManager } from '$lib/stores/event-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; import { purchaseStore } from '$lib/stores/purchase.store'; import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk'; import { writable } from 'svelte/store'; diff --git a/web/src/lib/stores/user.svelte.ts b/web/src/lib/stores/user.svelte.ts index 093d90e4b5..94d73efb9c 100644 --- a/web/src/lib/stores/user.svelte.ts +++ b/web/src/lib/stores/user.svelte.ts @@ -1,4 +1,4 @@ -import { eventManager } from '$lib/stores/event-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; import type { AlbumResponseDto, ServerAboutResponseDto, diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 90228a5cbd..6e896e8000 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,6 +1,7 @@ -import { authManager } from '$lib/stores/auth-manager.svelte'; +import { authManager } from '$lib/managers/auth-manager.svelte'; +import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { createEventEmitter } from '$lib/utils/eventemitter'; -import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk'; +import { type AssetResponseDto, type NotificationDto, type ServerVersionResponseDto } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; import { get, writable } from 'svelte/store'; import { user } from './user.store'; @@ -26,6 +27,7 @@ export interface Events { on_config_update: () => void; on_new_release: (newRelase: ReleaseEvent) => void; on_session_delete: (sessionId: string) => void; + on_notification: (notification: NotificationDto) => void; } const websocket: Socket = io({ @@ -50,6 +52,7 @@ websocket .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) .on('on_session_delete', () => authManager.logout()) + .on('on_notification', () => notificationManager.refresh()) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); export const openWebsocketConnection = () => { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 7d542a940a..b7466b5812 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,5 +1,6 @@ import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; import { defaultLang, langs, locales } from '$lib/constants'; +import { authManager } from '$lib/managers/auth-manager.svelte'; import { lang } from '$lib/stores/preferences.store'; import { handleError } from '$lib/utils/handle-error'; import { @@ -155,18 +156,11 @@ export const getJobName = derived(t, ($t) => { }; }); -let _key: string | undefined; let _sharedLink: SharedLinkResponseDto | undefined; -export const setKey = (key?: string) => (_key = key); -export const getKey = (): string | undefined => _key; export const setSharedLink = (sharedLink: SharedLinkResponseDto) => (_sharedLink = sharedLink); export const getSharedLink = (): SharedLinkResponseDto | undefined => _sharedLink; -export const isSharedLink = () => { - return !!_key; -}; - const createUrl = (path: string, parameters?: Record) => { const searchParameters = new URLSearchParams(); for (const key in parameters) { @@ -189,7 +183,7 @@ export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => { options = { id: options }; } const { id, cacheKey } = options; - return createUrl(getAssetOriginalPath(id), { key: getKey(), c: cacheKey }); + return createUrl(getAssetOriginalPath(id), { key: authManager.key, c: cacheKey }); }; export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size?: AssetMediaSize })) => { @@ -197,7 +191,7 @@ export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size options = { id: options }; } const { id, size, cacheKey } = options; - return createUrl(getAssetThumbnailPath(id), { size, key: getKey(), c: cacheKey }); + return createUrl(getAssetThumbnailPath(id), { size, key: authManager.key, c: cacheKey }); }; export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => { @@ -205,7 +199,7 @@ export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => { options = { id: options }; } const { id, cacheKey } = options; - return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: cacheKey }); + return createUrl(getAssetPlaybackPath(id), { key: authManager.key, c: cacheKey }); }; export const getProfileImageUrl = (user: UserResponseDto) => diff --git a/web/src/lib/utils/album-utils.ts b/web/src/lib/utils/album-utils.ts index 74a2e3c5e9..a5b830774c 100644 --- a/web/src/lib/utils/album-utils.ts +++ b/web/src/lib/utils/album-utils.ts @@ -59,7 +59,7 @@ export const sortOptionsMetadata: AlbumSortOptionMetadata[] = [ { id: AlbumSortBy.Title, defaultOrder: SortOrder.Asc, - columnStyle: 'text-left w-8/12 sm:w-4/12 md:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]', + columnStyle: 'text-start w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]', }, { id: AlbumSortBy.ItemCount, diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index bd3cb416b5..254b90f08d 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -3,11 +3,12 @@ import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte'; import type { InterpolationValues } from '$lib/components/i18n/format-message'; import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; +import { authManager } from '$lib/managers/auth-manager.svelte'; +import { downloadManager } from '$lib/managers/download-manager.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte'; -import { downloadManager } from '$lib/stores/download-store.svelte'; import { preferences } from '$lib/stores/user.store'; -import { downloadRequest, getKey, withError } from '$lib/utils'; +import { downloadRequest, withError } from '$lib/utils'; import { createAlbum } from '$lib/utils/album-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { getFormatter } from '$lib/utils/i18n'; @@ -44,7 +45,7 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show bulkIdsDto: { ids: assetIds, }, - key: getKey(), + key: authManager.key, }); const count = result.filter(({ success }) => success).length; const $t = get(t); @@ -178,7 +179,7 @@ export const downloadArchive = async (fileName: string, options: Omit(preferences); const dto = { ...options, archiveSize: $preferences?.download.archiveSize }; - const [error, downloadInfo] = await withError(() => getDownloadInfo({ downloadInfoDto: dto, key: getKey() })); + const [error, downloadInfo] = await withError(() => getDownloadInfo({ downloadInfoDto: dto, key: authManager.key })); if (error) { const $t = get(t); handleError(error, $t('errors.unable_to_download_files')); @@ -193,7 +194,7 @@ export const downloadArchive = async (fileName: string, options: Omit 1 ? `+${index + 1}` : ''; const archiveName = fileName.replace('.zip', `${suffix}-${DateTime.now().toFormat('yyyyLLdd_HHmmss')}.zip`); - const key = getKey(); + const key = authManager.key; let downloadKey = `${archiveName} `; if (downloadInfo.archives.length > 1) { @@ -240,7 +241,7 @@ export const downloadFile = async (asset: AssetResponseDto) => { }; if (asset.livePhotoVideoId) { - const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: getKey() }); + const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: authManager.key }); if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) { assets.push({ filename: motionAsset.originalFileName, @@ -252,7 +253,7 @@ export const downloadFile = async (asset: AssetResponseDto) => { for (const { filename, id } of assets) { try { - const key = getKey(); + const key = authManager.key; notificationController.show({ type: NotificationType.Info, diff --git a/web/src/lib/utils/byte-units.ts b/web/src/lib/utils/byte-units.ts index dae44009e2..218e22f671 100644 --- a/web/src/lib/utils/byte-units.ts +++ b/web/src/lib/utils/byte-units.ts @@ -34,6 +34,7 @@ export function getBytesWithUnit(bytes: number, maxPrecision = 1): [number, Byte * * de: `1,5 KiB` * * @param bytes number of bytes + * @param locale locale to use, default is `navigator.language` * @param maxPrecision maximum number of decimal places, default is `1` * @returns localized bytes with unit as string */ diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 04d4d78810..db43e92f94 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -1,6 +1,7 @@ +import { authManager } from '$lib/managers/auth-manager.svelte'; import { UploadState } from '$lib/models/upload-asset'; import { uploadAssetsStore } from '$lib/stores/upload'; -import { getKey, uploadRequest } from '$lib/utils'; +import { uploadRequest } from '$lib/utils'; import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { ExecutorQueue } from '$lib/utils/executor-queue'; import { @@ -134,7 +135,7 @@ async function fileUploader( } let responseData: { id: string; status: AssetMediaStatus; isTrashed?: boolean } | undefined; - const key = getKey(); + const key = authManager.key; if (crypto?.subtle?.digest && !key) { uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_hashing') }); await tick(); diff --git a/web/src/lib/utils/persisted.ts b/web/src/lib/utils/persisted.ts new file mode 100644 index 0000000000..73eb4de5db --- /dev/null +++ b/web/src/lib/utils/persisted.ts @@ -0,0 +1,81 @@ +import { browser } from '$app/environment'; +import { createSubscriber } from 'svelte/reactivity'; + +type PersistedBaseOptions = { + read: (key: string) => T | undefined; + write: (key: string, value: T) => void; +}; + +class PersistedBase { + #value: T; + #subscribe: () => void; + #update = () => {}; + + #write: (value: T) => void; + + get current() { + this.#subscribe(); + return this.#value as T; + } + + set current(value: T) { + this.#write(value); + this.#update(); + this.#value = value; + } + + constructor(key: string, defaultValue: T, options: PersistedBaseOptions) { + const value = options.read(key); + + this.#value = value === undefined ? defaultValue : value; + this.#write = (value: T) => options.write(key, value); + + this.#subscribe = createSubscriber((update) => { + this.#update = update; + + return () => { + this.#update = () => {}; + }; + }); + } +} + +type PersistedLocalStorageOptions = { + serializer?: { + stringify(value: T): string; + parse(text: string): T; + }; + valid?: (value: T | unknown) => value is T; +}; + +export class PersistedLocalStorage extends PersistedBase { + constructor(key: string, defaultValue: T, options: PersistedLocalStorageOptions = {}) { + const valid = options.valid || (() => true); + const serializer = options.serializer || JSON; + + super(key, defaultValue, { + read: (key: string) => { + if (!browser) { + return; + } + + const item = localStorage.getItem(key) ?? undefined; + if (item === undefined) { + return; + } + + const parsed = serializer.parse(item); + if (!valid(parsed)) { + return; + } + + return parsed; + }, + write: (key: string, value: T) => { + if (browser) { + localStorage.setItem(key, serializer.stringify(value)); + } + }, + }); + } +} diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts new file mode 100644 index 0000000000..1a19d3c134 --- /dev/null +++ b/web/src/lib/utils/sw-messaging.ts @@ -0,0 +1,8 @@ +const broadcast = new BroadcastChannel('immich'); + +export function cancelImageUrl(url: string) { + broadcast.postMessage({ type: 'cancel', url }); +} +export function preloadImageUrl(url: string) { + broadcast.postMessage({ type: 'preload', url }); +} diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts index a53691e716..f0043790ea 100644 --- a/web/src/lib/utils/thumbnail-util.ts +++ b/web/src/lib/utils/thumbnail-util.ts @@ -1,6 +1,7 @@ +import { locale } from '$lib/stores/preferences.store'; import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; -import { derived } from 'svelte/store'; +import { derived, get } from 'svelte/store'; import { fromLocalDateTime } from './timeline-util'; /** @@ -43,7 +44,7 @@ export const getAltText = derived(t, ($t) => { return asset.exifInfo.description; } - const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }); + const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) }); const hasPlace = !!asset.exifInfo?.city && !!asset.exifInfo?.country; const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? []; const peopleCount = names.length; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index f40e2bc3eb..21a7d23953 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,41 +1,13 @@ -import type { AssetBucket } from '$lib/stores/assets-store.svelte'; import { locale } from '$lib/stores/preferences.store'; -import { type CommonJustifiedLayout } from '$lib/utils/layout-utils'; - -import type { AssetResponseDto } from '@immich/sdk'; import { memoize } from 'lodash-es'; import { DateTime, type LocaleOptions } from 'luxon'; import { get } from 'svelte/store'; -export type DateGroup = { - bucket: AssetBucket; - index: number; - row: number; - col: number; - date: DateTime; - groupTitle: string; - assets: AssetResponseDto[]; - assetsIntersecting: boolean[]; - height: number; - intersecting: boolean; - geometry: CommonJustifiedLayout; -}; 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) }); @@ -43,31 +15,6 @@ export const fromLocalDateTime = (localDateTime: string) => export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) => DateTime.fromISO(dateTimeOriginal, { zone: timeZone }); -export type LayoutBox = { - aspectRatio: number; - top: number; - width: number; - height: number; - left: number; - forcedAspectRatio?: boolean; -}; - -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; -} - -export const groupDateFormat: Intl.DateTimeFormatOptions = { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric', -}; - export function formatGroupTitle(_date: DateTime): string { if (!_date.isValid) { return _date.toString(); @@ -87,20 +34,24 @@ export function formatGroupTitle(_date: DateTime): string { // Last week if (date >= today.minus({ days: 6 }) && date < today) { - return date.toLocaleString({ weekday: 'long' }); + return date.toLocaleString({ weekday: 'long' }, { locale: get(locale) }); } // This year if (today.hasSame(date, 'year')) { - return date.toLocaleString({ - weekday: 'short', - month: 'short', - day: 'numeric', - }); + return date.toLocaleString( + { + weekday: 'short', + month: 'short', + day: 'numeric', + }, + { locale: get(locale) }, + ); } - return getDateLocaleString(date); + return getDateLocaleString(date, { locale: get(locale) }); } + export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); 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 818f48fe4a..767902bd94 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 @@ -708,7 +708,7 @@ {#if showActivityStatus} -
+
{$t('people')}

{$t('view_all')}
@@ -64,7 +64,7 @@ widthStyle="100%" /> {#if person.isFavorite} -
+
{/if} @@ -82,7 +82,7 @@

{$t('places')}

{$t('view_all')}
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 index f5a4f6cd09..d1fc0bcd90 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -87,7 +87,7 @@ {#if assetInteraction.selectionActive} -
+
cancelMultiselect(assetInteraction)} @@ -133,7 +133,7 @@
-
{$t('explorer').toUpperCase()}
+
{$t('explorer').toUpperCase()}
{ @@ -542,7 +542,7 @@ heightStyle="3.375rem" />

{person.name || $t('add_a_name')}

@@ -598,7 +598,7 @@ widthStyle="2rem" heightStyle="2rem" /> -

{person.name}

+

{person.name}

{/each}
diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index c985104e3c..9d427e1ea7 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -251,7 +251,7 @@
{#if assetInteraction.selectionActive} -
+
cancelMultiselect(assetInteraction)} @@ -289,13 +289,13 @@
{:else} -
+
goto(previousRoute)} backIcon={mdiArrowLeft}>
-
+
@@ -313,13 +313,13 @@
{getHumanReadableSearchKey(key as keyof SearchTerms)}
{#if value !== true} -
+
{#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'} {getHumanReadableDate(value)} {:else if key === 'personIds' && Array.isArray(value)} @@ -349,7 +349,7 @@ > {#if searchResultAlbums.length > 0}
-
{$t('albums').toUpperCase()}
+
{$t('albums').toUpperCase()}
diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte index e3d6ac1ced..a55452b5d1 100644 --- a/web/src/routes/(user)/sharing/+page.svelte +++ b/web/src/routes/(user)/sharing/+page.svelte @@ -68,7 +68,7 @@ class="flex gap-4 rounded-lg px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700" > -
+

{partner.name}

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 index 8bb43676e8..8d33a2eb6e 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -146,7 +146,7 @@
-
{$t('explorer').toUpperCase()}
+
{$t('explorer').toUpperCase()}
{ - if (theme.system) { - theme.value = globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT; - } - - if (theme.value === Theme.LIGHT) { - document.documentElement.classList.remove('dark'); - } else { - document.documentElement.classList.add('dark'); - } - }; - - const handleChangeTheme = () => { - if ($colorTheme.system) { - handleToggleTheme(); - } - }; - const getMyImmichLink = () => { return new URL(page.url.pathname + page.url.search, 'https://my.immich.app'); }; @@ -65,20 +46,11 @@ const element = document.querySelector('#stencil'); element?.remove(); // if the browser theme changes, changes the Immich theme too - globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleChangeTheme); }); - onDestroy(() => { - document.removeEventListener('change', handleChangeTheme); - }); - - if (isSharedLinkRoute(page.route?.id)) { - setKey(page.params.key); - } + eventManager.emit('app.init'); beforeNavigate(({ from, to }) => { - setKey(isSharedLinkRoute(to?.route.id) ? to?.params?.key : undefined); - if (isAssetViewerRoute(from) && isAssetViewerRoute(to)) { return; } @@ -88,9 +60,6 @@ afterNavigate(() => { showNavigationLoadingBar = false; }); - run(() => { - changeTheme($colorTheme); - }); run(() => { if ($user) { openWebsocketConnection(); diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 21381081e0..07757614e5 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -107,7 +107,7 @@ > {#snippet promptSnippet()} -
+
{#if libraries.length > 0} -
+
@@ -369,7 +369,7 @@ {/if} {#if editScanSettings === index} -
+
{:else}
-
+
@@ -265,7 +265,7 @@
- +
@@ -295,7 +295,7 @@ -
copyToClipboard(orphan.pathValue)}> {}} /> + {orphan.pathValue} @@ -306,7 +306,7 @@
- +
@@ -337,11 +337,11 @@ -
copyToClipboard(extra.filename)}> {}} /> - + {extra.filename} - + {#if extra.checksum} [sha1:{extra.checksum}] {/if} diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 6512461ee9..1ac9f0b6fd 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -1,15 +1,16 @@