diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 13c2df740..c3c3fea01 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -58,7 +58,7 @@ jobs: uses: docker/setup-qemu-action@v3.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.1.0 + uses: docker/setup-buildx-action@v3.2.0 - name: Login to GitHub Container Registry uses: docker/login-action@v3 @@ -87,7 +87,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }} - name: Build and push image - uses: docker/build-push-action@v5.2.0 + uses: docker/build-push-action@v5.3.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5b92e44ff..c1df0a6ed 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -66,13 +66,7 @@ jobs: uses: docker/setup-qemu-action@v3.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.1.0 - # Workaround to fix error: - # failed to push: failed to copy: io: read/write on closed pipe - # See https://github.com/docker/build-push-action/issues/761 - with: - driver-opts: | - image=moby/buildkit:v0.10.6 + uses: docker/setup-buildx-action@v3.2.0 - name: Login to Docker Hub # Only push to Docker Hub when making a release @@ -121,7 +115,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v5.2.0 + uses: docker/build-push-action@v5.3.0 with: context: ${{ matrix.context }} file: ${{ matrix.file }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d704aa629..1d57b3a84 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,17 +91,13 @@ jobs: with: node-version: 20 - - name: Run setup typescript-sdk + - name: Setup typescript-sdk run: npm ci && npm run build working-directory: ./open-api/typescript-sdk - - name: Run npm install (cli) + - name: Install deps run: npm ci - - name: Run npm install (server) - run: npm ci - working-directory: ./server - - name: Run linter run: npm run lint if: ${{ !cancelled() }} diff --git a/README.md b/README.md index f4adc1af0..255013501 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

-

High performance self-hosted photo and video backup solution

+

High performance self-hosted photo and video management solution


diff --git a/cli/.eslintrc.cjs b/cli/.eslintrc.cjs index 33ee3bd1e..18a48ac7e 100644 --- a/cli/.eslintrc.cjs +++ b/cli/.eslintrc.cjs @@ -19,8 +19,9 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'error', 'unicorn/prefer-module': 'off', + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/no-process-exit': 'off', curly: 2, 'prettier/prettier': 0, - 'unicorn/prevent-abbreviations': 'error', }, }; diff --git a/cli/.npmignore b/cli/.npmignore index 42809f8e8..fab798db6 100644 --- a/cli/.npmignore +++ b/cli/.npmignore @@ -1,11 +1,15 @@ **/*.spec.js +coverage/** +src/** upload/** .editorconfig .eslintignore -.eslintrc.js +.eslintrc.cjs +.gitignore .prettierignore .prettierrc +Dockerfile package-lock.json -testSetup.js tsconfig.json -tsconfig.build.json +vite.config.ts +vitest.config.ts diff --git a/cli/package-lock.json b/cli/package-lock.json index 82e920bd9..69be80132 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,7 +1,7 @@ { "name": "@immich/cli", "version": "2.1.0", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { @@ -37,6 +37,7 @@ "prettier-plugin-organize-imports": "^3.2.4", "typescript": "^5.3.3", "vite": "^5.0.12", + "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.2.2", "yaml": "^2.3.1" }, @@ -57,104 +58,6 @@ "typescript": "^5.3.3" } }, - "../server": { - "name": "immich", - "version": "1.94.1", - "extraneous": true, - "license": "GNU Affero General Public License version 3", - "dependencies": { - "@babel/runtime": "^7.22.11", - "@immich/cli": "^2.0.7", - "@nestjs/bullmq": "^10.0.1", - "@nestjs/common": "^10.2.2", - "@nestjs/config": "^3.0.0", - "@nestjs/core": "^10.2.2", - "@nestjs/platform-express": "^10.2.2", - "@nestjs/platform-socket.io": "^10.2.2", - "@nestjs/schedule": "^4.0.0", - "@nestjs/swagger": "^7.1.8", - "@nestjs/typeorm": "^10.0.0", - "@nestjs/websockets": "^10.2.2", - "@socket.io/postgres-adapter": "^0.3.1", - "@types/picomatch": "^2.3.3", - "archiver": "^6.0.0", - "async-lock": "^1.4.0", - "bcrypt": "^5.1.1", - "bullmq": "^4.8.0", - "chokidar": "^3.5.3", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.0", - "cookie-parser": "^1.4.6", - "exiftool-vendored": "~24.4.0", - "exiftool-vendored.pl": "12.73", - "fluent-ffmpeg": "^2.1.2", - "geo-tz": "^8.0.0", - "glob": "^10.3.3", - "handlebars": "^4.7.8", - "i18n-iso-countries": "^7.6.0", - "ioredis": "^5.3.2", - "joi": "^17.10.0", - "lodash": "^4.17.21", - "luxon": "^3.4.2", - "nest-commander": "^3.11.1", - "node-addon-api": "^7.0.0", - "openid-client": "^5.4.3", - "pg": "^8.11.3", - "picomatch": "^4.0.0", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1", - "sanitize-filename": "^1.6.3", - "sharp": "^0.33.0", - "thumbhash": "^0.1.1", - "typeorm": "^0.3.17", - "ua-parser-js": "^1.0.35" - }, - "devDependencies": { - "@nestjs/cli": "^10.1.16", - "@nestjs/schematics": "^10.0.2", - "@nestjs/testing": "^10.2.2", - "@testcontainers/postgresql": "^10.2.1", - "@types/archiver": "^6.0.0", - "@types/async-lock": "^1.4.2", - "@types/bcrypt": "^5.0.0", - "@types/cookie-parser": "^1.4.3", - "@types/express": "^4.17.17", - "@types/fluent-ffmpeg": "^2.1.21", - "@types/imagemin": "^8.0.1", - "@types/jest": "29.5.12", - "@types/jest-when": "^3.5.2", - "@types/lodash": "^4.14.197", - "@types/mock-fs": "^4.13.1", - "@types/multer": "^1.4.7", - "@types/node": "^20.5.7", - "@types/sharp": "^0.31.1", - "@types/supertest": "^6.0.0", - "@types/ua-parser-js": "^0.7.36", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", - "dotenv": "^16.3.1", - "eslint": "^8.56.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^51.0.0", - "jest": "^29.6.4", - "jest-when": "^3.6.0", - "mock-fs": "^5.2.0", - "prettier": "^3.0.2", - "prettier-plugin-organize-imports": "^3.2.3", - "rimraf": "^5.0.1", - "source-map-support": "^0.5.21", - "sql-formatter": "^15.0.0", - "supertest": "^6.3.3", - "testcontainers": "^10.2.1", - "ts-jest": "^29.1.1", - "ts-loader": "^9.4.4", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.3.3", - "utimes": "^5.2.1" - } - }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -165,13 +68,13 @@ } }, "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -365,9 +268,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", + "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -377,9 +280,9 @@ } }, "node_modules/@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.23.4", @@ -780,9 +683,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.9.1.tgz", - "integrity": "sha512-Y27x+MBLjXa+0JWDhykM3+JE+il3kHKAEqabfEWq3SDhZjLYb6/BHL/JKFnH3fe207JaXkyDo685Oc2Glt6ifA==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -811,6 +714,28 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", @@ -834,6 +759,28 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -946,32 +893,32 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" @@ -984,9 +931,9 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.19", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", - "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1039,9 +986,9 @@ } }, "node_modules/@pkgr/core": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.0.tgz", - "integrity": "sha512-Zwq5OCzuwJC2jwqmpEQt7Ds1DTi6BWSwoGkbb1n9pO3hzb35BoJELx7c0T23iDkBGkh2e7tvOtjF3tr3OaQHDQ==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", "dev": true, "engines": { "node": "^12.20.0 || ^14.18.0 || >=16.0.0" @@ -1051,9 +998,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", - "integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", + "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", "cpu": [ "arm" ], @@ -1064,9 +1011,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz", - "integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", + "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", "cpu": [ "arm64" ], @@ -1077,9 +1024,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz", - "integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", + "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", "cpu": [ "arm64" ], @@ -1090,9 +1037,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz", - "integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", + "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", "cpu": [ "x64" ], @@ -1103,9 +1050,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz", - "integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", + "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", "cpu": [ "arm" ], @@ -1116,9 +1063,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz", - "integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", + "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", "cpu": [ "arm64" ], @@ -1129,9 +1076,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz", - "integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", + "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", "cpu": [ "arm64" ], @@ -1142,9 +1089,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz", - "integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", + "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", "cpu": [ "riscv64" ], @@ -1155,9 +1102,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz", - "integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", + "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", "cpu": [ "x64" ], @@ -1168,9 +1115,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz", - "integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", + "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", "cpu": [ "x64" ], @@ -1181,9 +1128,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz", - "integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", + "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", "cpu": [ "arm64" ], @@ -1194,9 +1141,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz", - "integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", + "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", "cpu": [ "ia32" ], @@ -1207,9 +1154,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz", - "integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", + "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", "cpu": [ "x64" ], @@ -1247,9 +1194,9 @@ "dev": true }, "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true }, "node_modules/@types/json-schema": { @@ -1259,9 +1206,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.14.202", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", - "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", "dev": true }, "node_modules/@types/lodash-es": { @@ -1283,18 +1230,18 @@ } }, "node_modules/@types/node": { - "version": "20.11.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz", - "integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==", + "version": "20.11.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.27.tgz", + "integrity": "sha512-qyUZfMnCg1KEz57r7pzFtSGt49f6RPkPBis3Vo4PbS7roQEDn22hiHzl/Lo1q4i4hDEgBJmBF/NTNg2XR0HbFg==", "dev": true, "dependencies": { "undici-types": "~5.26.4" } }, "node_modules/@types/normalize-package-data": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.2.tgz", - "integrity": "sha512-lqa4UEhhv/2sjjIQgjX8B+RBjj47eo0mzGasklVJ78UKGQY1r0VpB9XHDaZZO9qzEFDdy4MrXLuEaSmPrPSe/A==", + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, "node_modules/@types/semver": { @@ -1304,16 +1251,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.1.tgz", - "integrity": "sha512-zioDz623d0RHNhvx0eesUmGfIjzrk18nSBC8xewepKXbBvN/7c1qImV7Hg8TI1URTxKax7/zxfxj3Uph8Chcuw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.2.0.tgz", + "integrity": "sha512-mdekAHOqS9UjlmyF/LSs6AIEvfceV749GFxoBAjwAv0nkevfKHWQFDMcBZWUiIC5ft6ePWivXoS36aKQ0Cy3sw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/type-utils": "7.1.1", - "@typescript-eslint/utils": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/type-utils": "7.2.0", + "@typescript-eslint/utils": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -1339,15 +1286,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.1.tgz", - "integrity": "sha512-ZWUFyL0z04R1nAEgr9e79YtV5LbafdOtN7yapNbn1ansMyaegl2D4bL7vHoJ4HPSc4CaLwuCVas8CVuneKzplQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", + "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/typescript-estree": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", "debug": "^4.3.4" }, "engines": { @@ -1367,13 +1314,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz", - "integrity": "sha512-cirZpA8bJMRb4WZ+rO6+mnOJrGFDd38WoXCEI57+CYBqta8Yc8aJym2i7vyqLL1vVYljgw0X27axkUXz32T8TA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", + "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1" + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1384,13 +1331,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.1.tgz", - "integrity": "sha512-5r4RKze6XHEEhlZnJtR3GYeCh1IueUHdbrukV2KSlLXaTjuSfeVF8mZUVPLovidCuZfbVjfhi4c0DNSa/Rdg5g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.2.0.tgz", + "integrity": "sha512-xHi51adBHo9O9330J8GQYQwrKBqbIPJGZZVQTHHmy200hvkLZFWJIFtAG/7IYTWUyun6DE6w5InDReePJYJlJA==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.1.1", - "@typescript-eslint/utils": "7.1.1", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/utils": "7.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -1411,9 +1358,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.1.tgz", - "integrity": "sha512-KhewzrlRMrgeKm1U9bh2z5aoL4s7K3tK5DwHDn8MHv0yQfWFz/0ZR6trrIHHa5CsF83j/GgHqzdbzCXJ3crx0Q==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", + "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1424,13 +1371,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.1.tgz", - "integrity": "sha512-9ZOncVSfr+sMXVxxca2OJOPagRwT0u/UHikM2Rd6L/aB+kL/QAuTnsv6MeXtjzCJYb8PzrXarypSGIPx3Jemxw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", + "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1451,42 +1398,18 @@ } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", - "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.2.0.tgz", + "integrity": "sha512-YfHpnMAGb1Eekpm3XRK8hcMwGLGsnT6L+7b2XyRv6ouDuJU1tZir1GS2i0+VXRatMwSI1/UfcyPe53ADkU+IuA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/typescript-estree": "7.1.1", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", "semver": "^7.5.4" }, "engines": { @@ -1501,12 +1424,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.1.tgz", - "integrity": "sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", + "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/types": "7.2.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -1747,13 +1670,12 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { @@ -1769,9 +1691,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "dev": true, "funding": [ { @@ -1788,8 +1710,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" }, @@ -1840,9 +1762,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001572", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001572.tgz", - "integrity": "sha512-1Pbh5FLmn5y4+QhNyJE9j3/7dK44dGB83/ZMjv/qJk86TvDbjk0LosiZo0i0WB0Vx607qMX9jYrn1VLHCkN4rw==", + "version": "1.0.30001597", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz", + "integrity": "sha512-7LjJvmQU6Sj7bL0j5b5WY/3n7utXUJvAe1lxhsHDbLmwX9mdL86Yjtr+5SRCyf8qME4M7pU2hswj0FpyBVCv9w==", "dev": true, "funding": [ { @@ -1993,12 +1915,12 @@ "dev": true }, "node_modules/core-js-compat": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.0.tgz", - "integrity": "sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw==", + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.0.tgz", + "integrity": "sha512-iV9Pd/PsgjNWBXeq8XRtWVSgz2tKAfhfvBs7qxYty+RlRd+OCksaWmOnc4JKrTc1cToXL1N0s3l/vwlxPtdElw==", "dev": true, "dependencies": { - "browserslist": "^4.22.2" + "browserslist": "^4.22.3" }, "funding": { "type": "opencollective", @@ -2094,9 +2016,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.616", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz", - "integrity": "sha512-1n7zWYh8eS0L9Uy+GskE0lkBUNK83cXTVJI0pU3mGprFsbfSdAc15VTFbo+A+Bq4pwstmL30AVcEU3Fo463lNg==", + "version": "1.4.705", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.705.tgz", + "integrity": "sha512-LKqhpwJCLhYId2VVwEzFXWrqQI5n5zBppz1W9ehhTlfYU8CUUW6kClbN8LHF/v7flMgRdETS772nqywJ+ckVAw==", "dev": true }, "node_modules/emoji-regex": { @@ -2153,9 +2075,9 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true, "engines": { "node": ">=6" @@ -2331,6 +2253,28 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -2475,9 +2419,9 @@ "dev": true }, "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, "dependencies": { "reusify": "^1.0.4" @@ -2524,23 +2468,23 @@ } }, "node_modules/flat-cache": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.0.tgz", - "integrity": "sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "dependencies": { - "flatted": "^3.2.7", + "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" }, "engines": { - "node": ">=12.0.0" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, "node_modules/foreground-child": { @@ -2580,10 +2524,13 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/get-func-name": { "version": "2.0.2", @@ -2640,30 +2587,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -2699,24 +2622,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2726,6 +2643,18 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -2748,9 +2677,9 @@ } }, "node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", "dev": true, "engines": { "node": ">= 4" @@ -2828,12 +2757,12 @@ } }, "node_modules/is-core-module": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", - "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dev": true, "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2943,9 +2872,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", "dev": true, "dependencies": { "html-escaper": "^2.0.0", @@ -3034,9 +2963,9 @@ "dev": true }, "node_modules/keyv": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", - "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "dependencies": { "json-buffer": "3.0.1" @@ -3122,9 +3051,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.8", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", + "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -3209,15 +3138,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minipass": { @@ -3230,9 +3162,9 @@ } }, "node_modules/mlly": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.5.0.tgz", - "integrity": "sha512-NPVQvAY1xr1QoVeG0cy8yUYC7FQcOx6evl/RjT1wL5FvzPnzOysoqB/jmx/DhssT2dYa8nxECLAaFI/+gVLhDQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.6.1.tgz", + "integrity": "sha512-vLgaHvaeunuOXHSmEbZ9izxPx3USsk8KCQ8iC+aTlp5sKRSoZvwhHh5L9VbKSaVC6sJDqbyohIS76E2VmHIPAA==", "dev": true, "dependencies": { "acorn": "^8.11.3", @@ -3833,9 +3765,9 @@ } }, "node_modules/resolve": { - "version": "1.22.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz", - "integrity": "sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dev": true, "dependencies": { "is-core-module": "^2.13.0", @@ -3883,6 +3815,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/rimraf/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3903,10 +3845,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rollup": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz", - "integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", + "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -3919,19 +3873,19 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.9.6", - "@rollup/rollup-android-arm64": "4.9.6", - "@rollup/rollup-darwin-arm64": "4.9.6", - "@rollup/rollup-darwin-x64": "4.9.6", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.6", - "@rollup/rollup-linux-arm64-gnu": "4.9.6", - "@rollup/rollup-linux-arm64-musl": "4.9.6", - "@rollup/rollup-linux-riscv64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-musl": "4.9.6", - "@rollup/rollup-win32-arm64-msvc": "4.9.6", - "@rollup/rollup-win32-ia32-msvc": "4.9.6", - "@rollup/rollup-win32-x64-msvc": "4.9.6", + "@rollup/rollup-android-arm-eabi": "4.13.0", + "@rollup/rollup-android-arm64": "4.13.0", + "@rollup/rollup-darwin-arm64": "4.13.0", + "@rollup/rollup-darwin-x64": "4.13.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", + "@rollup/rollup-linux-arm64-gnu": "4.13.0", + "@rollup/rollup-linux-arm64-musl": "4.13.0", + "@rollup/rollup-linux-riscv64-gnu": "4.13.0", + "@rollup/rollup-linux-x64-gnu": "4.13.0", + "@rollup/rollup-linux-x64-musl": "4.13.0", + "@rollup/rollup-win32-arm64-msvc": "4.13.0", + "@rollup/rollup-win32-ia32-msvc": "4.13.0", + "@rollup/rollup-win32-x64-msvc": "4.13.0", "fsevents": "~2.3.2" } }, @@ -3959,9 +3913,9 @@ } }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -3985,12 +3939,6 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4068,9 +4016,9 @@ } }, "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true }, "node_modules/spdx-expression-parse": { @@ -4084,9 +4032,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.15.tgz", - "integrity": "sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ==", + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", + "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", "dev": true }, "node_modules/stackback": { @@ -4263,6 +4211,16 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/test-exclude/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -4283,6 +4241,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4335,17 +4305,37 @@ } }, "node_modules/ts-api-utils": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", - "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", "dev": true, "engines": { - "node": ">=16.13.0" + "node": ">=16" }, "peerDependencies": { "typescript": ">=4.2.0" } }, + "node_modules/tsconfck": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.0.3.tgz", + "integrity": "sha512-4t0noZX9t6GcPTfBAbIbbIU4pfpCwh0ueq3S4O/5qXI1VwK1outmxhe9dOiEWqMz3MW2LKgDTpqWV+37IWuVbA==", + "dev": true, + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", @@ -4399,9 +4389,9 @@ } }, "node_modules/ufo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", - "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.4.0.tgz", + "integrity": "sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==", "dev": true }, "node_modules/undici-types": { @@ -4474,9 +4464,9 @@ } }, "node_modules/vite": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz", - "integrity": "sha512-BdN1xh0Of/oQafhU+FvopafUp6WaYenLU/NFoL5WyJL++GxkNfieKzBhM24H3HVsPQrlAqB7iJYTHabzaRed5Q==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.6.tgz", + "integrity": "sha512-yYIAZs9nVfRJ/AiOLCA91zzhjsHUgMjB+EigzFb6W2XTLO8JixBCKCjvhKZaye+NKYHCrkv3Oh50dH9EdLU2RA==", "dev": true, "dependencies": { "esbuild": "^0.19.3", @@ -4550,6 +4540,25 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-tsconfig-paths": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", + "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz", @@ -4749,6 +4758,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/yaml": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", @@ -4773,3125 +4788,5 @@ "url": "https://github.com/sponsors/sindresorhus" } } - }, - "dependencies": { - "@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true - }, - "@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", - "dev": true, - "requires": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", - "dev": true, - "requires": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", - "dev": true - }, - "@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "dev": true - }, - "@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", - "dev": true - }, - "@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", - "dev": true, - "requires": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - } - }, - "@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, - "@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", - "dev": true, - "optional": true - }, - "@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", - "dev": true, - "optional": true - }, - "@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", - "dev": true, - "optional": true - }, - "@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", - "dev": true, - "optional": true - }, - "@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", - "dev": true, - "optional": true - }, - "@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", - "dev": true, - "optional": true - }, - "@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "dev": true, - "optional": true - }, - "@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", - "dev": true, - "optional": true - }, - "@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^3.3.0" - } - }, - "@eslint-community/regexpp": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.9.1.tgz", - "integrity": "sha512-Y27x+MBLjXa+0JWDhykM3+JE+il3kHKAEqabfEWq3SDhZjLYb6/BHL/JKFnH3fe207JaXkyDo685Oc2Glt6ifA==", - "dev": true - }, - "@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - } - }, - "@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", - "dev": true - }, - "@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - } - }, - "@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true - }, - "@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true - }, - "@immich/sdk": { - "version": "file:../open-api/typescript-sdk", - "requires": { - "@oazapfts/runtime": "^1.0.2", - "@types/node": "^20.11.0", - "typescript": "^5.3.3" - } - }, - "@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "requires": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true - }, - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "requires": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - } - }, - "strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "requires": { - "ansi-regex": "^6.0.1" - } - } - } - }, - "@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true - }, - "@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "requires": { - "@sinclair/typebox": "^0.27.8" - } - }, - "@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, - "requires": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", - "dev": true - }, - "@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true - }, - "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true - }, - "@jridgewell/trace-mapping": { - "version": "0.3.19", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", - "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", - "dev": true, - "requires": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true - }, - "@pkgr/core": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.0.tgz", - "integrity": "sha512-Zwq5OCzuwJC2jwqmpEQt7Ds1DTi6BWSwoGkbb1n9pO3hzb35BoJELx7c0T23iDkBGkh2e7tvOtjF3tr3OaQHDQ==", - "dev": true - }, - "@rollup/rollup-android-arm-eabi": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", - "integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-android-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz", - "integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-darwin-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz", - "integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-darwin-x64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz", - "integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz", - "integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz", - "integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz", - "integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz", - "integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-x64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz", - "integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-x64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz", - "integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz", - "integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz", - "integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-x64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz", - "integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==", - "dev": true, - "optional": true - }, - "@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true - }, - "@types/byte-size": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@types/byte-size/-/byte-size-8.1.2.tgz", - "integrity": "sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA==", - "dev": true - }, - "@types/cli-progress": { - "version": "3.11.5", - "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.5.tgz", - "integrity": "sha512-D4PbNRbviKyppS5ivBGyFO29POlySLmA2HyUFE4p5QGazAMM3CwkKWcvTl8gvElSuxRh6FPKL8XmidX873ou4g==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true - }, - "@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", - "dev": true - }, - "@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "@types/lodash": { - "version": "4.14.202", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", - "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", - "dev": true - }, - "@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "dev": true, - "requires": { - "@types/lodash": "*" - } - }, - "@types/mock-fs": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz", - "integrity": "sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/node": { - "version": "20.11.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz", - "integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==", - "dev": true, - "requires": { - "undici-types": "~5.26.4" - } - }, - "@types/normalize-package-data": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.2.tgz", - "integrity": "sha512-lqa4UEhhv/2sjjIQgjX8B+RBjj47eo0mzGasklVJ78UKGQY1r0VpB9XHDaZZO9qzEFDdy4MrXLuEaSmPrPSe/A==", - "dev": true - }, - "@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "dev": true - }, - "@typescript-eslint/eslint-plugin": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.1.tgz", - "integrity": "sha512-zioDz623d0RHNhvx0eesUmGfIjzrk18nSBC8xewepKXbBvN/7c1qImV7Hg8TI1URTxKax7/zxfxj3Uph8Chcuw==", - "dev": true, - "requires": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/type-utils": "7.1.1", - "@typescript-eslint/utils": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", - "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - } - }, - "@typescript-eslint/parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.1.tgz", - "integrity": "sha512-ZWUFyL0z04R1nAEgr9e79YtV5LbafdOtN7yapNbn1ansMyaegl2D4bL7vHoJ4HPSc4CaLwuCVas8CVuneKzplQ==", - "dev": true, - "requires": { - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/typescript-estree": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1", - "debug": "^4.3.4" - } - }, - "@typescript-eslint/scope-manager": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz", - "integrity": "sha512-cirZpA8bJMRb4WZ+rO6+mnOJrGFDd38WoXCEI57+CYBqta8Yc8aJym2i7vyqLL1vVYljgw0X27axkUXz32T8TA==", - "dev": true, - "requires": { - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1" - } - }, - "@typescript-eslint/type-utils": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.1.tgz", - "integrity": "sha512-5r4RKze6XHEEhlZnJtR3GYeCh1IueUHdbrukV2KSlLXaTjuSfeVF8mZUVPLovidCuZfbVjfhi4c0DNSa/Rdg5g==", - "dev": true, - "requires": { - "@typescript-eslint/typescript-estree": "7.1.1", - "@typescript-eslint/utils": "7.1.1", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - } - }, - "@typescript-eslint/types": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.1.tgz", - "integrity": "sha512-KhewzrlRMrgeKm1U9bh2z5aoL4s7K3tK5DwHDn8MHv0yQfWFz/0ZR6trrIHHa5CsF83j/GgHqzdbzCXJ3crx0Q==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.1.tgz", - "integrity": "sha512-9ZOncVSfr+sMXVxxca2OJOPagRwT0u/UHikM2Rd6L/aB+kL/QAuTnsv6MeXtjzCJYb8PzrXarypSGIPx3Jemxw==", - "dev": true, - "requires": { - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "@typescript-eslint/utils": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", - "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", - "dev": true, - "requires": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/typescript-estree": "7.1.1", - "semver": "^7.5.4" - } - }, - "@typescript-eslint/visitor-keys": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.1.tgz", - "integrity": "sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==", - "dev": true, - "requires": { - "@typescript-eslint/types": "7.1.1", - "eslint-visitor-keys": "^3.4.1" - } - }, - "@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "@vitest/coverage-v8": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.1.tgz", - "integrity": "sha512-UuBnkSJUNE9rdHjDCPyJ4fYuMkoMtnghes1XohYa4At0MS3OQSAo97FrbwSLRshYsXThMZy1+ybD/byK5llyIg==", - "dev": true, - "requires": { - "@ampproject/remapping": "^2.2.1", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.4", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.1.6", - "magic-string": "^0.30.5", - "magicast": "^0.3.3", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "test-exclude": "^6.0.0", - "v8-to-istanbul": "^9.2.0" - } - }, - "@vitest/expect": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz", - "integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==", - "dev": true, - "requires": { - "@vitest/spy": "1.3.1", - "@vitest/utils": "1.3.1", - "chai": "^4.3.10" - } - }, - "@vitest/runner": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz", - "integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==", - "dev": true, - "requires": { - "@vitest/utils": "1.3.1", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" - }, - "dependencies": { - "p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", - "dev": true, - "requires": { - "yocto-queue": "^1.0.0" - } - }, - "yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", - "dev": true - } - } - }, - "@vitest/snapshot": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz", - "integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==", - "dev": true, - "requires": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" - } - }, - "@vitest/spy": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz", - "integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==", - "dev": true, - "requires": { - "tinyspy": "^2.2.0" - } - }, - "@vitest/utils": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz", - "integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==", - "dev": true, - "requires": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" - } - }, - "acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "dev": true - }, - "acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} - }, - "acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", - "dev": true - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, - "assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "requires": { - "fill-range": "^7.0.1" - } - }, - "browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", - "dev": true, - "requires": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" - } - }, - "builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", - "dev": true - }, - "byte-size": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/byte-size/-/byte-size-8.1.1.tgz", - "integrity": "sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg==", - "dev": true - }, - "cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "caniuse-lite": { - "version": "1.0.30001572", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001572.tgz", - "integrity": "sha512-1Pbh5FLmn5y4+QhNyJE9j3/7dK44dGB83/ZMjv/qJk86TvDbjk0LosiZo0i0WB0Vx607qMX9jYrn1VLHCkN4rw==", - "dev": true - }, - "chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", - "dev": true, - "requires": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.0.8" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "requires": { - "get-func-name": "^2.0.2" - } - }, - "ci-info": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", - "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", - "dev": true - }, - "clean-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", - "integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==", - "dev": true, - "requires": { - "escape-string-regexp": "^1.0.5" - }, - "dependencies": { - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true - } - } - }, - "cli-progress": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", - "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", - "dev": true, - "requires": { - "string-width": "^4.2.3" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "commander": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz", - "integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "core-js-compat": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.0.tgz", - "integrity": "sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw==", - "dev": true, - "requires": { - "browserslist": "^4.22.2" - } - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", - "dev": true, - "requires": { - "type-detect": "^4.0.0" - } - }, - "deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true - }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "requires": { - "path-type": "^4.0.0" - } - }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "electron-to-chromium": { - "version": "1.4.616", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz", - "integrity": "sha512-1n7zWYh8eS0L9Uy+GskE0lkBUNK83cXTVJI0pU3mGprFsbfSdAc15VTFbo+A+Bq4pwstmL30AVcEU3Fo463lNg==", - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", - "dev": true, - "requires": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" - } - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", - "dev": true, - "requires": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - } - }, - "eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", - "dev": true, - "requires": {} - }, - "eslint-plugin-prettier": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", - "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", - "dev": true, - "requires": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.8.6" - } - }, - "eslint-plugin-unicorn": { - "version": "51.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz", - "integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.22.20", - "@eslint-community/eslint-utils": "^4.4.0", - "@eslint/eslintrc": "^2.1.4", - "ci-info": "^4.0.0", - "clean-regexp": "^1.0.0", - "core-js-compat": "^3.34.0", - "esquery": "^1.5.0", - "indent-string": "^4.0.0", - "is-builtin-module": "^3.2.1", - "jsesc": "^3.0.2", - "pluralize": "^8.0.0", - "read-pkg-up": "^7.0.1", - "regexp-tree": "^0.1.27", - "regjsparser": "^0.10.0", - "semver": "^7.5.4", - "strip-indent": "^3.0.0" - } - }, - "eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true - }, - "espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "requires": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - } - }, - "esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - } - }, - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "requires": { - "@types/estree": "^1.0.0" - } - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - } - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true - }, - "fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "dependencies": { - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - } - } - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "requires": { - "flat-cache": "^3.0.4" - } - }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "flat-cache": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.0.tgz", - "integrity": "sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==", - "dev": true, - "requires": { - "flatted": "^3.2.7", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - } - }, - "flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", - "dev": true - }, - "foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true - }, - "get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true - }, - "glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dev": true, - "requires": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "requires": { - "is-glob": "^4.0.3" - } - }, - "globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - } - }, - "graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true - }, - "html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true - }, - "ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", - "dev": true - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true - }, - "indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "is-builtin-module": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", - "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", - "dev": true, - "requires": { - "builtin-modules": "^3.3.0" - } - }, - "is-core-module": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", - "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true - }, - "is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true - }, - "istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "requires": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - } - }, - "istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - } - }, - "istanbul-reports": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", - "dev": true, - "requires": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - } - }, - "jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, - "requires": { - "@isaacs/cliui": "^8.0.2", - "@pkgjs/parseargs": "^0.11.0" - } - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true - }, - "json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true - }, - "keyv": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", - "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", - "dev": true, - "requires": { - "json-buffer": "3.0.1" - } - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "local-pkg": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", - "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", - "dev": true, - "requires": { - "mlly": "^1.4.2", - "pkg-types": "^1.0.3" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, - "lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "requires": { - "get-func-name": "^2.0.1" - } - }, - "lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true - }, - "magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", - "dev": true, - "requires": { - "@jridgewell/sourcemap-codec": "^1.4.15" - } - }, - "magicast": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.3.tgz", - "integrity": "sha512-ZbrP1Qxnpoes8sz47AM0z08U+jW6TyRgZzcWy3Ma3vDhJttwMwAFDMMQFobwdBxByBD46JYmxRzeF7w2+wJEuw==", - "dev": true, - "requires": { - "@babel/parser": "^7.23.6", - "@babel/types": "^7.23.6", - "source-map-js": "^1.0.2" - } - }, - "make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "requires": { - "semver": "^7.5.3" - } - }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true - }, - "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "requires": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - } - }, - "mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true - }, - "min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true - }, - "mlly": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.5.0.tgz", - "integrity": "sha512-NPVQvAY1xr1QoVeG0cy8yUYC7FQcOx6evl/RjT1wL5FvzPnzOysoqB/jmx/DhssT2dYa8nxECLAaFI/+gVLhDQ==", - "dev": true, - "requires": { - "acorn": "^8.11.3", - "pathe": "^1.1.2", - "pkg-types": "^1.0.3", - "ufo": "^1.3.2" - } - }, - "mock-fs": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", - "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", - "dev": true - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true - }, - "normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "requires": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - }, - "dependencies": { - "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true - } - } - }, - "npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "requires": { - "path-key": "^4.0.0" - }, - "dependencies": { - "path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true - } - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "requires": { - "mimic-fn": "^4.0.0" - } - }, - "optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "requires": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", - "dev": true, - "requires": { - "lru-cache": "^9.1.1 || ^10.0.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - } - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true - }, - "pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true - }, - "pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, - "pkg-types": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", - "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==", - "dev": true, - "requires": { - "jsonc-parser": "^3.2.0", - "mlly": "^1.2.0", - "pathe": "^1.1.0" - } - }, - "pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true - }, - "postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", - "dev": true, - "requires": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - }, - "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", - "dev": true - }, - "prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "requires": { - "fast-diff": "^1.1.2" - } - }, - "prettier-plugin-organize-imports": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.4.tgz", - "integrity": "sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog==", - "dev": true, - "requires": {} - }, - "pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "requires": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true - } - } - }, - "punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, - "read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", - "dev": true, - "requires": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "dependencies": { - "type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "dev": true - } - } - }, - "read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", - "dev": true, - "requires": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true - } - } - }, - "regexp-tree": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", - "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", - "dev": true - }, - "regjsparser": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.10.0.tgz", - "integrity": "sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==", - "dev": true, - "requires": { - "jsesc": "~0.5.0" - }, - "dependencies": { - "jsesc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", - "dev": true - } - } - }, - "resolve": { - "version": "1.22.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz", - "integrity": "sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==", - "dev": true, - "requires": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - }, - "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "rollup": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz", - "integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==", - "dev": true, - "requires": { - "@rollup/rollup-android-arm-eabi": "4.9.6", - "@rollup/rollup-android-arm64": "4.9.6", - "@rollup/rollup-darwin-arm64": "4.9.6", - "@rollup/rollup-darwin-x64": "4.9.6", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.6", - "@rollup/rollup-linux-arm64-gnu": "4.9.6", - "@rollup/rollup-linux-arm64-musl": "4.9.6", - "@rollup/rollup-linux-riscv64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-musl": "4.9.6", - "@rollup/rollup-win32-arm64-msvc": "4.9.6", - "@rollup/rollup-win32-ia32-msvc": "4.9.6", - "@rollup/rollup-win32-x64-msvc": "4.9.6", - "@types/estree": "1.0.5", - "fsevents": "~2.3.2" - } - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - }, - "dependencies": { - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - } - } - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true - }, - "signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true - }, - "spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", - "dev": true - }, - "spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.15.tgz", - "integrity": "sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ==", - "dev": true - }, - "stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true - }, - "std-env": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", - "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", - "dev": true - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "string-width-cjs": { - "version": "npm:string-width@4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strip-ansi-cjs": { - "version": "npm:strip-ansi@6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true - }, - "strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "requires": { - "min-indent": "^1.0.0" - } - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "strip-literal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.0.0.tgz", - "integrity": "sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==", - "dev": true, - "requires": { - "js-tokens": "^8.0.2" - }, - "dependencies": { - "js-tokens": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz", - "integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==", - "dev": true - } - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, - "synckit": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", - "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==", - "dev": true, - "requires": { - "@pkgr/core": "^0.1.0", - "tslib": "^2.6.2" - } - }, - "test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "requires": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "tinybench": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz", - "integrity": "sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==", - "dev": true - }, - "tinypool": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.2.tgz", - "integrity": "sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==", - "dev": true - }, - "tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", - "dev": true - }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "requires": { - "is-number": "^7.0.0" - } - }, - "ts-api-utils": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", - "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", - "dev": true, - "requires": {} - }, - "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true - }, - "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - }, - "typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", - "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", - "dev": true - }, - "ufo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", - "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==", - "dev": true - }, - "undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, - "update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", - "dev": true, - "requires": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "v8-to-istanbul": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", - "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - } - }, - "validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "vite": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz", - "integrity": "sha512-BdN1xh0Of/oQafhU+FvopafUp6WaYenLU/NFoL5WyJL++GxkNfieKzBhM24H3HVsPQrlAqB7iJYTHabzaRed5Q==", - "dev": true, - "requires": { - "esbuild": "^0.19.3", - "fsevents": "~2.3.3", - "postcss": "^8.4.35", - "rollup": "^4.2.0" - } - }, - "vite-node": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz", - "integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==", - "dev": true, - "requires": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" - } - }, - "vitest": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz", - "integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==", - "dev": true, - "requires": { - "@vitest/expect": "1.3.1", - "@vitest/runner": "1.3.1", - "@vitest/snapshot": "1.3.1", - "@vitest/spy": "1.3.1", - "@vitest/utils": "1.3.1", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.2", - "vite": "^5.0.0", - "vite-node": "1.3.1", - "why-is-node-running": "^2.2.2" - } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "why-is-node-running": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", - "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", - "dev": true, - "requires": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - } - }, - "wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "requires": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true - }, - "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true - }, - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "requires": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - } - }, - "strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "requires": { - "ansi-regex": "^6.0.1" - } - } - } - }, - "wrap-ansi-cjs": { - "version": "npm:wrap-ansi@7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "yaml": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", - "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", - "dev": true - }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true - } } } diff --git a/cli/package.json b/cli/package.json index b76c24f94..45c60569e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -35,6 +35,7 @@ "prettier-plugin-organize-imports": "^3.2.4", "typescript": "^5.3.3", "vite": "^5.0.12", + "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.2.2", "yaml": "^2.3.1" }, diff --git a/cli/src/commands/upload.command.ts b/cli/src/commands/asset.ts similarity index 90% rename from cli/src/commands/upload.command.ts rename to cli/src/commands/asset.ts index 250fd79c6..b6c159c9b 100644 --- a/cli/src/commands/upload.command.ts +++ b/cli/src/commands/asset.ts @@ -1,4 +1,12 @@ -import { AssetBulkUploadCheckResult } from '@immich/sdk'; +import { + AssetBulkUploadCheckResult, + addAssetsToAlbum, + checkBulkUpload, + createAlbum, + defaults, + getAllAlbums, + getSupportedMediaTypes, +} from '@immich/sdk'; import byteSize from 'byte-size'; import cliProgress from 'cli-progress'; import { chunk, zip } from 'lodash-es'; @@ -7,9 +15,8 @@ import fs, { createReadStream } from 'node:fs'; import { access, constants, stat, unlink } from 'node:fs/promises'; import os from 'node:os'; import { basename } from 'node:path'; -import { ImmichApi } from 'src/services/api.service'; -import { CrawlService } from '../services/crawl.service'; -import { BaseCommand } from './base-command'; +import { CrawlService } from 'src/services/crawl.service'; +import { BaseOptions, authenticate } from 'src/utils'; const zipDefined = zip as (a: T[], b: U[]) => [T, U][]; @@ -106,7 +113,7 @@ class Asset { } } -export class UploadOptionsDto { +class UploadOptionsDto { recursive? = false; exclusionPatterns?: string[] = []; dryRun? = false; @@ -118,11 +125,13 @@ export class UploadOptionsDto { concurrency? = 4; } -export class UploadCommand extends BaseCommand { - api!: ImmichApi; +export const upload = (paths: string[], baseOptions: BaseOptions, uploadOptions: UploadOptionsDto) => + new UploadCommand().run(paths, baseOptions, uploadOptions); - public async run(paths: string[], options: UploadOptionsDto): Promise { - this.api = await this.connect(); +// TODO refactor this +class UploadCommand { + public async run(paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto): Promise { + await authenticate(baseOptions); console.log('Crawling for assets...'); const files = await this.getFiles(paths, options); @@ -264,7 +273,7 @@ export class UploadCommand extends BaseCommand { } public async getAlbums(): Promise> { - const existingAlbums = await this.api.getAllAlbums(); + const existingAlbums = await getAllAlbums({}); const albumMapping = new Map(); for (const album of existingAlbums) { @@ -313,7 +322,7 @@ export class UploadCommand extends BaseCommand { try { for (const albumNames of chunk(newAlbums, options.concurrency)) { const newAlbumIds = await Promise.all( - albumNames.map((albumName: string) => this.api.createAlbum({ albumName }).then((r) => r.id)), + albumNames.map((albumName: string) => createAlbum({ createAlbumDto: { albumName } }).then((r) => r.id)), ); for (const [albumName, albumId] of zipDefined(albumNames, newAlbumIds)) { @@ -348,7 +357,7 @@ export class UploadCommand extends BaseCommand { try { for (const [albumId, assets] of albumToAssets.entries()) { for (const assetBatch of chunk(assets, Math.min(1000 * (options.concurrency ?? 4), 65_000))) { - await this.api.addAssetsToAlbum(albumId, { ids: assetBatch }); + await addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetBatch } }); albumUpdateProgress.increment(assetBatch.length); } } @@ -404,17 +413,18 @@ export class UploadCommand extends BaseCommand { const assetBulkUploadCheckDto = { assets: zipDefined(assetsToCheck, checksums).map(([asset, checksum]) => ({ id: asset.path, checksum })), }; - const checkResponse = await this.api.checkBulkUpload(assetBulkUploadCheckDto); + const checkResponse = await checkBulkUpload({ assetBulkUploadCheckDto }); return checkResponse.results; } private async uploadAssets(assets: Asset[]): Promise { const fileRequests = await Promise.all(assets.map((asset) => asset.getUploadFormData())); - return Promise.all(fileRequests.map((request) => this.uploadAsset(request).then((response) => response.id))); + const results = await Promise.all(fileRequests.map((request) => this.uploadAsset(request))); + return results.map((response) => response.id); } private async crawl(paths: string[], options: UploadOptionsDto): Promise { - const formatResponse = await this.api.getSupportedMediaTypes(); + const formatResponse = await getSupportedMediaTypes(); const crawlService = new CrawlService(formatResponse.image, formatResponse.video); return crawlService.crawl({ @@ -426,14 +436,12 @@ export class UploadCommand extends BaseCommand { } private async uploadAsset(data: FormData): Promise<{ id: string }> { - const url = this.api.instanceUrl + '/asset/upload'; + const { baseUrl, headers } = defaults; - const response = await fetch(url, { + const response = await fetch(`${baseUrl}/asset/upload`, { method: 'post', redirect: 'error', - headers: { - 'x-api-key': this.api.apiKey, - }, + headers: headers as Record, body: data, }); if (response.status !== 200 && response.status !== 201) { diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts new file mode 100644 index 000000000..05f3d7953 --- /dev/null +++ b/cli/src/commands/auth.ts @@ -0,0 +1,48 @@ +import { getMyUserInfo } from '@immich/sdk'; +import { existsSync } from 'node:fs'; +import { mkdir, unlink } from 'node:fs/promises'; +import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils'; + +export const login = async (instanceUrl: string, apiKey: string, options: BaseOptions) => { + console.log(`Logging in to ${instanceUrl}`); + + const { configDirectory: configDir } = options; + + await connect(instanceUrl, apiKey); + + const [error, userInfo] = await withError(getMyUserInfo()); + if (error) { + logError(error, 'Failed to load user info'); + process.exit(1); + } + + console.log(`Logged in as ${userInfo.email}`); + + if (!existsSync(configDir)) { + // Create config folder if it doesn't exist + const created = await mkdir(configDir, { recursive: true }); + if (!created) { + console.log(`Failed to create config folder: ${configDir}`); + return; + } + } + + await writeAuthFile(configDir, { instanceUrl, apiKey }); + + console.log(`Wrote auth info to ${getAuthFilePath(configDir)}`); +}; + +export const logout = async (options: BaseOptions) => { + console.log('Logging out...'); + + const { configDirectory: configDir } = options; + + const authFile = getAuthFilePath(configDir); + + if (existsSync(authFile)) { + await unlink(authFile); + console.log(`Removed auth file: ${authFile}`); + } + + console.log('Successfully logged out'); +}; diff --git a/cli/src/commands/base-command.ts b/cli/src/commands/base-command.ts deleted file mode 100644 index 2ecb3fef2..000000000 --- a/cli/src/commands/base-command.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ServerVersionResponseDto, UserResponseDto } from '@immich/sdk'; -import { ImmichApi } from 'src/services/api.service'; -import { SessionService } from '../services/session.service'; - -export abstract class BaseCommand { - protected sessionService!: SessionService; - protected user!: UserResponseDto; - protected serverVersion!: ServerVersionResponseDto; - - constructor(options: { configDirectory?: string }) { - if (!options.configDirectory) { - throw new Error('Config directory is required'); - } - this.sessionService = new SessionService(options.configDirectory); - } - - public async connect(): Promise { - return await this.sessionService.connect(); - } -} diff --git a/cli/src/commands/login.command.ts b/cli/src/commands/login.command.ts deleted file mode 100644 index 863c28701..000000000 --- a/cli/src/commands/login.command.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseCommand } from './base-command'; - -export class LoginCommand extends BaseCommand { - public async run(instanceUrl: string, apiKey: string): Promise { - await this.sessionService.login(instanceUrl, apiKey); - } -} diff --git a/cli/src/commands/logout.command.ts b/cli/src/commands/logout.command.ts deleted file mode 100644 index 736f77424..000000000 --- a/cli/src/commands/logout.command.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseCommand } from './base-command'; - -export class LogoutCommand extends BaseCommand { - public static readonly description = 'Logout and remove persisted credentials'; - public async run(): Promise { - await this.sessionService.logout(); - } -} diff --git a/cli/src/commands/server-info.command.ts b/cli/src/commands/server-info.command.ts deleted file mode 100644 index c6029b130..000000000 --- a/cli/src/commands/server-info.command.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { BaseCommand } from './base-command'; - -export class ServerInfoCommand extends BaseCommand { - public async run() { - const api = await this.connect(); - const versionInfo = await api.getServerVersion(); - const mediaTypes = await api.getSupportedMediaTypes(); - const statistics = await api.getAssetStatistics(); - - console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`); - console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`); - console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`); - console.log( - `Statistics:\n Images: ${statistics.images}\n Videos: ${statistics.videos}\n Total: ${statistics.total}`, - ); - } -} diff --git a/cli/src/commands/server-info.ts b/cli/src/commands/server-info.ts new file mode 100644 index 000000000..a7de804df --- /dev/null +++ b/cli/src/commands/server-info.ts @@ -0,0 +1,15 @@ +import { getAssetStatistics, getServerVersion, getSupportedMediaTypes } from '@immich/sdk'; +import { BaseOptions, authenticate } from 'src/utils'; + +export const serverInfo = async (options: BaseOptions) => { + await authenticate(options); + + const versionInfo = await getServerVersion(); + const mediaTypes = await getSupportedMediaTypes(); + const stats = await getAssetStatistics({}); + + console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`); + console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`); + console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`); + console.log(`Statistics:\n Images: ${stats.images}\n Videos: ${stats.videos}\n Total: ${stats.total}`); +}; diff --git a/cli/src/index.ts b/cli/src/index.ts index e9485190a..bf7e13f44 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -2,11 +2,10 @@ import { Command, Option } from 'commander'; import os from 'node:os'; import path from 'node:path'; +import { upload } from 'src/commands/asset'; +import { login, logout } from 'src/commands/auth'; +import { serverInfo } from 'src/commands/server-info'; import { version } from '../package.json'; -import { LoginCommand } from './commands/login.command'; -import { LogoutCommand } from './commands/logout.command'; -import { ServerInfoCommand } from './commands/server-info.command'; -import { UploadCommand } from './commands/upload.command'; const defaultConfigDirectory = path.join(os.homedir(), '.config/immich/'); @@ -18,14 +17,34 @@ const program = new Command() new Option('-d, --config-directory ', 'Configuration directory where auth.yml will be stored') .env('IMMICH_CONFIG_DIR') .default(defaultConfigDirectory), - ); + ) + .addOption(new Option('-u, --url [url]', 'Immich server URL').env('IMMICH_INSTANCE_URL')) + .addOption(new Option('-k, --key [apiKey]', 'Immich API key').env('IMMICH_API_KEY')); + +program + .command('login') + .alias('login-key') + .description('Login using an API key') + .argument('url', 'Immich server URL') + .argument('key', 'Immich API key') + .action((url, key) => login(url, key, program.opts())); + +program + .command('logout') + .description('Remove stored credentials') + .action(() => logout(program.opts())); + +program + .command('server-info') + .description('Display server information') + .action(() => serverInfo(program.opts())); program .command('upload') .description('Upload assets') - .usage('[options] [paths...]') + .usage('[paths...] [options]') .addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false)) - .addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS')) + .addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default([])) .addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) .addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false)) .addOption( @@ -50,32 +69,6 @@ program ) .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS')) .argument('[paths...]', 'One or more paths to assets to be uploaded') - .action(async (paths, options) => { - options.exclusionPatterns = options.ignore; - await new UploadCommand(program.opts()).run(paths, options); - }); - -program - .command('server-info') - .description('Display server information') - .action(async () => { - await new ServerInfoCommand(program.opts()).run(); - }); - -program - .command('login-key') - .description('Login using an API key') - .argument('url') - .argument('key') - .action(async (url, key) => { - await new LoginCommand(program.opts()).run(url, key); - }); - -program - .command('logout') - .description('Remove stored credentials') - .action(async () => { - await new LogoutCommand(program.opts()).run(); - }); + .action((paths, options) => upload(paths, program.opts(), options)); program.parse(process.argv); diff --git a/cli/src/services/api.service.ts b/cli/src/services/api.service.ts deleted file mode 100644 index 089eda120..000000000 --- a/cli/src/services/api.service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - ApiKeyCreateDto, - AssetBulkUploadCheckDto, - BulkIdsDto, - CreateAlbumDto, - CreateAssetDto, - LoginCredentialDto, - SignUpDto, - addAssetsToAlbum, - checkBulkUpload, - createAlbum, - createApiKey, - getAllAlbums, - getAllAssets, - getAssetStatistics, - getMyUserInfo, - getServerVersion, - getSupportedMediaTypes, - login, - pingServer, - signUpAdmin, - uploadFile, -} from '@immich/sdk'; - -/** - * Wraps the underlying API to abstract away the options and make API calls mockable for testing. - */ -export class ImmichApi { - private readonly options; - - constructor( - public instanceUrl: string, - public apiKey: string, - ) { - this.options = { - baseUrl: instanceUrl, - headers: { - 'x-api-key': apiKey, - }, - }; - } - - setApiKey(apiKey: string) { - this.apiKey = apiKey; - if (!this.options.headers) { - throw new Error('missing headers'); - } - this.options.headers['x-api-key'] = apiKey; - } - - addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto) { - return addAssetsToAlbum({ id, bulkIdsDto }, this.options); - } - - checkBulkUpload(assetBulkUploadCheckDto: AssetBulkUploadCheckDto) { - return checkBulkUpload({ assetBulkUploadCheckDto }, this.options); - } - - createAlbum(createAlbumDto: CreateAlbumDto) { - return createAlbum({ createAlbumDto }, this.options); - } - - createApiKey(apiKeyCreateDto: ApiKeyCreateDto, options: { headers: { Authorization: string } }) { - return createApiKey({ apiKeyCreateDto }, { ...this.options, ...options }); - } - - getAllAlbums() { - return getAllAlbums({}, this.options); - } - - getAllAssets() { - return getAllAssets({}, this.options); - } - - getAssetStatistics() { - return getAssetStatistics({}, this.options); - } - - getMyUserInfo() { - return getMyUserInfo(this.options); - } - - getServerVersion() { - return getServerVersion(this.options); - } - - getSupportedMediaTypes() { - return getSupportedMediaTypes(this.options); - } - - login(loginCredentialDto: LoginCredentialDto) { - return login({ loginCredentialDto }, this.options); - } - - pingServer() { - return pingServer(this.options); - } - - signUpAdmin(signUpDto: SignUpDto) { - return signUpAdmin({ signUpDto }, this.options); - } - - uploadFile(createAssetDto: CreateAssetDto) { - return uploadFile({ createAssetDto }, this.options); - } -} diff --git a/cli/src/services/session.service.spec.ts b/cli/src/services/session.service.spec.ts deleted file mode 100644 index c217ab4e6..000000000 --- a/cli/src/services/session.service.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import yaml from 'yaml'; -import { SessionService } from './session.service'; - -const TEST_CONFIG_DIR = '/tmp/immich/'; -const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml'); -const TEST_IMMICH_INSTANCE_URL = 'https://test/api'; -const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg'; - -const spyOnConsole = () => vi.spyOn(console, 'log').mockImplementation(() => {}); - -const createTestAuthFile = async (contents: string) => { - if (!fs.existsSync(TEST_CONFIG_DIR)) { - // Create config folder if it doesn't exist - const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true }); - if (!created) { - throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`); - } - } - - fs.writeFileSync(TEST_AUTH_FILE, contents); -}; - -const readTestAuthFile = async (): Promise => { - return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8'); -}; - -const deleteAuthFile = () => { - try { - fs.unlinkSync(TEST_AUTH_FILE); - } catch (error: any) { - if (error.code !== 'ENOENT') { - throw error; - } - } -}; - -const mocks = vi.hoisted(() => { - return { - getMyUserInfo: vi.fn(() => Promise.resolve({ email: 'admin@example.com' })), - pingServer: vi.fn(() => Promise.resolve({ res: 'pong' })), - }; -}); - -vi.mock('./api.service', async (importOriginal) => { - const module = await importOriginal(); - // @ts-expect-error this is only a partial implementation of the return value - module.ImmichApi.prototype.getMyUserInfo = mocks.getMyUserInfo; - module.ImmichApi.prototype.pingServer = mocks.pingServer; - return module; -}); - -describe('SessionService', () => { - let sessionService: SessionService; - - beforeEach(() => { - deleteAuthFile(); - sessionService = new SessionService(TEST_CONFIG_DIR); - }); - - afterEach(() => { - deleteAuthFile(); - }); - - it('should connect to immich', async () => { - await createTestAuthFile( - JSON.stringify({ - apiKey: TEST_IMMICH_API_KEY, - instanceUrl: TEST_IMMICH_INSTANCE_URL, - }), - ); - - await sessionService.connect(); - expect(mocks.pingServer).toHaveBeenCalledTimes(1); - }); - - it('should error if no auth file exists', async () => { - await sessionService.connect().catch((error) => { - expect(error.message).toEqual('No auth file exist. Please login first'); - }); - }); - - it('should error if auth file is missing instance URl', async () => { - await createTestAuthFile( - JSON.stringify({ - apiKey: TEST_IMMICH_API_KEY, - }), - ); - await sessionService.connect().catch((error) => { - expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`); - }); - }); - - it('should error if auth file is missing api key', async () => { - await createTestAuthFile( - JSON.stringify({ - instanceUrl: TEST_IMMICH_INSTANCE_URL, - }), - ); - - await expect(sessionService.connect()).rejects.toThrow(`API key missing in auth config file ${TEST_AUTH_FILE}`); - }); - - it('should create auth file when logged in', async () => { - await sessionService.login(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY); - - const data: string = await readTestAuthFile(); - const authConfig = yaml.parse(data); - expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL); - expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY); - }); - - it('should delete auth file when logging out', async () => { - const consoleSpy = spyOnConsole(); - - await createTestAuthFile( - JSON.stringify({ - apiKey: TEST_IMMICH_API_KEY, - instanceUrl: TEST_IMMICH_INSTANCE_URL, - }), - ); - await sessionService.logout(); - - await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => { - expect(error.message).toContain('ENOENT'); - }); - - expect(consoleSpy.mock.calls).toEqual([ - ['Logging out...'], - [`Removed auth file ${TEST_AUTH_FILE}`], - ['Successfully logged out'], - ]); - }); -}); diff --git a/cli/src/services/session.service.ts b/cli/src/services/session.service.ts deleted file mode 100644 index 0235b30a4..000000000 --- a/cli/src/services/session.service.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { existsSync } from 'node:fs'; -import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import yaml from 'yaml'; -import { ImmichApi } from './api.service'; - -class LoginError extends Error { - constructor(message: string) { - super(message); - - this.name = this.constructor.name; - - Error.captureStackTrace(this, this.constructor); - } -} - -export class SessionService { - private get authPath() { - return path.join(this.configDirectory, '/auth.yml'); - } - - constructor(private configDirectory: string) {} - - async connect(): Promise { - let instanceUrl = process.env.IMMICH_INSTANCE_URL; - let apiKey = process.env.IMMICH_API_KEY; - - if (!instanceUrl || !apiKey) { - await access(this.authPath, constants.F_OK).catch((error) => { - if (error.code === 'ENOENT') { - throw new LoginError('No auth file exist. Please login first'); - } - }); - - const data: string = await readFile(this.authPath, 'utf8'); - const parsedConfig = yaml.parse(data); - - instanceUrl = parsedConfig.instanceUrl; - apiKey = parsedConfig.apiKey; - - if (!instanceUrl) { - throw new LoginError(`Instance URL missing in auth config file ${this.authPath}`); - } - - if (!apiKey) { - throw new LoginError(`API key missing in auth config file ${this.authPath}`); - } - } - - instanceUrl = await this.resolveApiEndpoint(instanceUrl); - - const api = new ImmichApi(instanceUrl, apiKey); - - const pingResponse = await api.pingServer().catch((error) => { - throw new Error(`Failed to connect to server ${instanceUrl}: ${error.message}`, error); - }); - - if (pingResponse.res !== 'pong') { - throw new Error(`Could not parse response. Is Immich listening on ${instanceUrl}?`); - } - - return api; - } - - async login(instanceUrl: string, apiKey: string): Promise { - console.log(`Logging in to ${instanceUrl}`); - - instanceUrl = await this.resolveApiEndpoint(instanceUrl); - - const api = new ImmichApi(instanceUrl, apiKey); - - // Check if server and api key are valid - const userInfo = await api.getMyUserInfo().catch((error) => { - throw new LoginError(`Failed to connect to server ${instanceUrl}: ${error.message}`); - }); - - console.log(`Logged in as ${userInfo.email}`); - - if (!existsSync(this.configDirectory)) { - // Create config folder if it doesn't exist - const created = await mkdir(this.configDirectory, { recursive: true }); - if (!created) { - throw new Error(`Failed to create config folder ${this.configDirectory}`); - } - } - - await writeFile(this.authPath, yaml.stringify({ instanceUrl, apiKey }), { mode: 0o600 }); - - console.log(`Wrote auth info to ${this.authPath}`); - - return api; - } - - async logout(): Promise { - console.log('Logging out...'); - - if (existsSync(this.authPath)) { - await unlink(this.authPath); - console.log('Removed auth file ' + this.authPath); - } - - console.log('Successfully logged out'); - } - - private async resolveApiEndpoint(instanceUrl: string): Promise { - const wellKnownUrl = new URL('.well-known/immich', instanceUrl); - try { - const wellKnown = await fetch(wellKnownUrl).then((response) => response.json()); - const endpoint = new URL(wellKnown.api.endpoint, instanceUrl).toString(); - if (endpoint !== instanceUrl) { - console.debug(`Discovered API at ${endpoint}`); - } - return endpoint; - } catch { - return instanceUrl; - } - } -} diff --git a/cli/src/utils.ts b/cli/src/utils.ts new file mode 100644 index 000000000..f99a0e66a --- /dev/null +++ b/cli/src/utils.ts @@ -0,0 +1,89 @@ +import { defaults, getMyUserInfo, isHttpError } from '@immich/sdk'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import yaml from 'yaml'; + +export interface BaseOptions { + configDirectory: string; + apiKey?: string; + instanceUrl?: string; +} + +export interface AuthDto { + instanceUrl: string; + apiKey: string; +} + +export const authenticate = async (options: BaseOptions): Promise => { + const { configDirectory: configDir, instanceUrl, apiKey } = options; + + // provided in command + if (instanceUrl && apiKey) { + await connect(instanceUrl, apiKey); + return; + } + + // fallback to file + const config = await readAuthFile(configDir); + await connect(config.instanceUrl, config.apiKey); +}; + +export const connect = async (instanceUrl: string, apiKey: string): Promise => { + const wellKnownUrl = new URL('.well-known/immich', instanceUrl); + try { + const wellKnown = await fetch(wellKnownUrl).then((response) => response.json()); + const endpoint = new URL(wellKnown.api.endpoint, instanceUrl).toString(); + if (endpoint !== instanceUrl) { + console.debug(`Discovered API at ${endpoint}`); + } + instanceUrl = endpoint; + } catch { + // noop + } + + defaults.baseUrl = instanceUrl; + defaults.headers = { 'x-api-key': apiKey }; + + const [error] = await withError(getMyUserInfo()); + if (isHttpError(error)) { + logError(error, 'Failed to connect to server'); + process.exit(1); + } +}; + +export const logError = (error: unknown, message: string) => { + if (isHttpError(error)) { + console.error(`${message}: ${error.status}`); + console.error(JSON.stringify(error.data, undefined, 2)); + } else { + console.error(`${message} - ${error}`); + } +}; + +export const getAuthFilePath = (dir: string) => join(dir, 'auth.yml'); + +export const readAuthFile = async (dir: string) => { + try { + const data = await readFile(getAuthFilePath(dir)); + // TODO add class-transform/validation + return yaml.parse(data.toString()) as AuthDto; + } catch (error: Error | any) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + console.log('No auth file exists. Please login first.'); + process.exit(1); + } + throw error; + } +}; + +export const writeAuthFile = async (dir: string, auth: AuthDto) => + writeFile(getAuthFilePath(dir), yaml.stringify(auth), { mode: 0o600 }); + +export const withError = async (promise: Promise): Promise<[Error, undefined] | [undefined, T]> => { + try { + const result = await promise; + return [undefined, result]; + } catch (error: Error | any) { + return [error, undefined]; + } +}; diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 3742f4c19..fcd01e01c 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -15,19 +15,7 @@ "incremental": true, "skipLibCheck": true, "esModuleInterop": true, - "rootDirs": ["src", "../server/src"], "baseUrl": "./", - "paths": { - "@test": ["../server/test"], - "@test/*": ["../server/test/*"], - "@test-utils": ["../server/src/test-utils/utils"], - "@app/immich": ["../server/src/immich"], - "@app/immich/*": ["../server/src/immich/*"], - "@app/infra": ["../server/src/infra"], - "@app/infra/*": ["../server/src/infra/*"], - "@app/domain": ["../server/src/domain"], - "@app/domain/*": ["../server/src/domain/*"] - }, "types": ["vitest/globals"] }, "exclude": ["dist", "node_modules"] diff --git a/cli/vite.config.ts b/cli/vite.config.ts index 89ee3a3d3..f5dd7c8e1 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ build: { @@ -14,4 +15,5 @@ export default defineConfig({ // bundle everything except for Node built-ins noExternal: /^(?!node:).*$/, }, + plugins: [tsconfigPaths()], }); diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 4d418a0c2..77effc15f 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -78,7 +78,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus + image: prom/prometheus@sha256:bc1794e85c9e00293351b967efa267ce6af1c824ac875a9d0c7ac84700a8b53e volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus diff --git a/docs/docs/administration/img/customize-delete-user.png b/docs/docs/administration/img/customize-delete-user.png new file mode 100644 index 000000000..e76212cd5 Binary files /dev/null and b/docs/docs/administration/img/customize-delete-user.png differ diff --git a/docs/docs/administration/img/delete-user.webp b/docs/docs/administration/img/delete-user.webp new file mode 100644 index 000000000..9d03e5a94 Binary files /dev/null and b/docs/docs/administration/img/delete-user.webp differ diff --git a/docs/docs/administration/img/immediately-remove-user.png b/docs/docs/administration/img/immediately-remove-user.png new file mode 100644 index 000000000..8f558bf71 Binary files /dev/null and b/docs/docs/administration/img/immediately-remove-user.png differ diff --git a/docs/docs/administration/img/user-management-update.png b/docs/docs/administration/img/user-management-update.png index 428359e57..f494750ed 100644 Binary files a/docs/docs/administration/img/user-management-update.png and b/docs/docs/administration/img/user-management-update.png differ diff --git a/docs/docs/administration/img/user-quota-size.png b/docs/docs/administration/img/user-quota-size.png new file mode 100644 index 000000000..bddaea716 Binary files /dev/null and b/docs/docs/administration/img/user-quota-size.png differ diff --git a/docs/docs/administration/img/user-storage-label.png b/docs/docs/administration/img/user-storage-label.png new file mode 100644 index 000000000..686793c64 Binary files /dev/null and b/docs/docs/administration/img/user-storage-label.png differ diff --git a/docs/docs/administration/user-management.mdx b/docs/docs/administration/user-management.mdx index 0b32b1ff8..47021fd7c 100644 --- a/docs/docs/administration/user-management.mdx +++ b/docs/docs/administration/user-management.mdx @@ -13,12 +13,57 @@ Immich supports multiple users, each with their own library. -## Delete a User +## Set Storage Quota For User -If you need to remove a user from Immich, head to "Administration", where users can be scheduled for deletion. The user account will immediately become disabled and their library and all associated data will be removed after 7 days. +Admin can specify the storage quota for the user as the instance's admin; once the limit is reached, the user won't be able to upload to the instance anymore. + +In order to select a storage quota, click on the pencil icon and enter the storage quota in GiB. You can choose an unlimited quota using the value 0 (default). + +:::tip +The system administrator can see the usage quota percentage of all users in Server Stats page. +::: + +:::info +External libraries don't take up space from the storage quota. +::: + + + +## Set Storage Label For User + +The admin can add a custom label for each user, so instead of `upload/{userId}/your-template` it will be `upload/{custom_user_label}/your-template`. +To apply a storage template, go to the Administration page -> click on the pencil button next to the user. +:::note +To apply the Storage Label to previously uploaded assets, run the Storage Migration Job. +::: + + ## Password Reset -To reset a user's password, click the pencil icon to edit a user, then click "Reset Password". The user's password will be reset to "password" and they have to change it next time the sign in. +To reset a user's password, click the pencil icon to edit a user, then click "Reset Password". The user's password will be reset to random password and they have to change it next time the sign in. -![Reset Password](./img/user-management-update.png) + + +## Delete a User + +If you need to remove a user from Immich, head to "Administration", where users can be scheduled for deletion. The user account will immediately become disabled and their library and all associated data will be removed after 7 days by default. + + + +### Delete Delay + +You can customize the time of the deletion of the users from the Administration -> Settings -> User Settings. +:::info user deletion job +The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution. +::: + + + +### Immediately Remove User + +You can choose to delete a user immediately by checking the box +`Queue user and assets for immediate deletion` in the deletion process, this will immediately remove the user and all assets. +This cannot be undone and the files cannot be recovered. + + diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index a915e20e2..9fc1b20d2 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -36,7 +36,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N | `NODE_ENV` | Environment (production, development) | `production` | server, microservices, machine learning, web | | `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices | | `IMMICH_MEDIA_LOCATION` | Media Location | `./upload` | server, microservices | -| `IMMICH_CONFIG_FILE` | Path to config file | | server | +| `IMMICH_CONFIG_FILE` | Path to config file | | server, microservices | | `IMMICH_WEB_ROOT` | Path of root index.html | `/usr/src/app/www` | server | | `IMMICH_REVERSE_GEOCODING_ROOT` | Path of reverse geocoding dump directory | `/usr/src/resources` | microservices | diff --git a/docs/docs/overview/img/feature-panel.png b/docs/docs/overview/img/feature-panel.png index cab58441c..8c39fe0d4 100644 Binary files a/docs/docs/overview/img/feature-panel.png and b/docs/docs/overview/img/feature-panel.png differ diff --git a/docs/docs/overview/logo.md b/docs/docs/overview/logo.md deleted file mode 100644 index e30140f4f..000000000 --- a/docs/docs/overview/logo.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -sidebar_position: 4 ---- - -# Logo - -Why the colorful flower, you ask? - -I really like the Japanese culture, especially the books, history, and food. The current logo is a spin-off of [the Oda clan's symbol](https://en.wikipedia.org/wiki/Oda_clan). - -![Oda_emblem](https://user-images.githubusercontent.com/27055614/182044504-a5ed33a8-5640-42de-b359-18fdbee9fb90.svg) - -One of my favorite books is [Taikō](https://www.goodreads.com/book/show/336228.Taiko), it is a story about a prominent figure in the history of Japan, [Toyotomi Hideyoshi](https://www.britannica.com/biography/Toyotomi-Hideyoshi). He came from nothing, and through his resilience and wonderful mind, he has become one of the most powerful rulers in Japan's history. I enjoy his personality and the way he moved through life. - -The color is an adaptation of **_App-Which-Must-Not-Be-Named_**'s color scheme, with an extra color (pink) to complete the flower's fifth petal. The petal layers are the same color scheme as the main layer rotating back and forth to "bring the flower to life." - -![image](https://user-images.githubusercontent.com/27055614/182044984-2ee6d1ed-c4a7-4331-8a4b-64fcde77fe1f.png) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 053e89fa4..afe9f0eec 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -88,11 +88,10 @@ const config = { }, }, navbar: { - title: 'IMMICH', logo: { - alt: 'Immich University Logo', - src: 'img/color-logo.png', - srcDark: 'img/logo.png', + alt: 'Immich Logo', + src: 'img/immich-logo-inline-light.png', + srcDark: 'img/immich-logo-inline-dark.png', }, items: [ { diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 3fc2e6b87..388095ebb 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -1,23 +1,24 @@ import React from 'react'; import Link from '@docusaurus/Link'; import Layout from '@theme/Layout'; - +import { useColorMode } from '@docusaurus/theme-common'; function HomepageHeader() { + const { isDarkTheme } = useColorMode(); + return (
-
- Immich logo -

- Immich -

-
-

- Self-hosted backup solution - for photos and videos - on mobile device +

+ Immich logo +
+

+ Self-hosted photo and + video management solution

-
- - screenshots - + screenshots
diff --git a/docs/static/img/favicon.ico b/docs/static/img/favicon.ico index aff1d30ff..6082fc3bd 100644 Binary files a/docs/static/img/favicon.ico and b/docs/static/img/favicon.ico differ diff --git a/docs/static/img/favicon.png b/docs/static/img/favicon.png index ba1b8409f..4e642631a 100644 Binary files a/docs/static/img/favicon.png and b/docs/static/img/favicon.png differ diff --git a/docs/static/img/immich-logo-inline-dark.png b/docs/static/img/immich-logo-inline-dark.png new file mode 100644 index 000000000..cc6cb23b6 Binary files /dev/null and b/docs/static/img/immich-logo-inline-dark.png differ diff --git a/docs/static/img/immich-logo-inline-light.png b/docs/static/img/immich-logo-inline-light.png new file mode 100644 index 000000000..b910b3790 Binary files /dev/null and b/docs/static/img/immich-logo-inline-light.png differ diff --git a/docs/static/img/immich-logo-stacked-dark.svg b/docs/static/img/immich-logo-stacked-dark.svg new file mode 100644 index 000000000..7f8381869 --- /dev/null +++ b/docs/static/img/immich-logo-stacked-dark.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/img/immich-logo-stacked-light.svg b/docs/static/img/immich-logo-stacked-light.svg new file mode 100644 index 000000000..8c4505d97 --- /dev/null +++ b/docs/static/img/immich-logo-stacked-light.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/img/immich-logo.svg b/docs/static/img/immich-logo.svg index e7edba069..376fa6f3e 100644 --- a/docs/static/img/immich-logo.svg +++ b/docs/static/img/immich-logo.svg @@ -1,98 +1,29 @@ - - + + - - - - - - - - - - - - - - - - + + + + + + + diff --git a/docs/static/img/immich-screenshots.png b/docs/static/img/immich-screenshots.png index c8569528d..6123279f2 100644 Binary files a/docs/static/img/immich-screenshots.png and b/docs/static/img/immich-screenshots.png differ diff --git a/docs/static/img/logo.png b/docs/static/img/logo.png deleted file mode 100644 index ba1b8409f..000000000 Binary files a/docs/static/img/logo.png and /dev/null differ diff --git a/e2e/package-lock.json b/e2e/package-lock.json index f94958113..74f4b51b6 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -70,6 +70,7 @@ "prettier-plugin-organize-imports": "^3.2.4", "typescript": "^5.3.3", "vite": "^5.0.12", + "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.2.2", "yaml": "^2.3.1" }, @@ -1276,16 +1277,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.1.tgz", - "integrity": "sha512-zioDz623d0RHNhvx0eesUmGfIjzrk18nSBC8xewepKXbBvN/7c1qImV7Hg8TI1URTxKax7/zxfxj3Uph8Chcuw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.2.0.tgz", + "integrity": "sha512-mdekAHOqS9UjlmyF/LSs6AIEvfceV749GFxoBAjwAv0nkevfKHWQFDMcBZWUiIC5ft6ePWivXoS36aKQ0Cy3sw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/type-utils": "7.1.1", - "@typescript-eslint/utils": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/type-utils": "7.2.0", + "@typescript-eslint/utils": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -1311,15 +1312,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.1.tgz", - "integrity": "sha512-ZWUFyL0z04R1nAEgr9e79YtV5LbafdOtN7yapNbn1ansMyaegl2D4bL7vHoJ4HPSc4CaLwuCVas8CVuneKzplQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", + "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/typescript-estree": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", "debug": "^4.3.4" }, "engines": { @@ -1339,13 +1340,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz", - "integrity": "sha512-cirZpA8bJMRb4WZ+rO6+mnOJrGFDd38WoXCEI57+CYBqta8Yc8aJym2i7vyqLL1vVYljgw0X27axkUXz32T8TA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", + "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1" + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1356,13 +1357,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.1.tgz", - "integrity": "sha512-5r4RKze6XHEEhlZnJtR3GYeCh1IueUHdbrukV2KSlLXaTjuSfeVF8mZUVPLovidCuZfbVjfhi4c0DNSa/Rdg5g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.2.0.tgz", + "integrity": "sha512-xHi51adBHo9O9330J8GQYQwrKBqbIPJGZZVQTHHmy200hvkLZFWJIFtAG/7IYTWUyun6DE6w5InDReePJYJlJA==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.1.1", - "@typescript-eslint/utils": "7.1.1", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/utils": "7.2.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -1383,9 +1384,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.1.tgz", - "integrity": "sha512-KhewzrlRMrgeKm1U9bh2z5aoL4s7K3tK5DwHDn8MHv0yQfWFz/0ZR6trrIHHa5CsF83j/GgHqzdbzCXJ3crx0Q==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", + "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1396,13 +1397,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.1.tgz", - "integrity": "sha512-9ZOncVSfr+sMXVxxca2OJOPagRwT0u/UHikM2Rd6L/aB+kL/QAuTnsv6MeXtjzCJYb8PzrXarypSGIPx3Jemxw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", + "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/visitor-keys": "7.1.1", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1448,17 +1449,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", - "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.2.0.tgz", + "integrity": "sha512-YfHpnMAGb1Eekpm3XRK8hcMwGLGsnT6L+7b2XyRv6ouDuJU1tZir1GS2i0+VXRatMwSI1/UfcyPe53ADkU+IuA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.1.1", - "@typescript-eslint/types": "7.1.1", - "@typescript-eslint/typescript-estree": "7.1.1", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", "semver": "^7.5.4" }, "engines": { @@ -1473,12 +1474,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.1.tgz", - "integrity": "sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", + "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/types": "7.2.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -2493,26 +2494,26 @@ } }, "node_modules/exiftool-vendored": { - "version": "24.5.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz", - "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==", + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.6.0.tgz", + "integrity": "sha512-jGjsoeYmR9VUrlZn0j1wcxMVi5y8C7A4FAa4vm3/l7ThT8d0f+jRcBqtdjaf+P5Ds/F4OgUq+ee/fRVhLy2DrA==", "dev": true, "dependencies": { - "@photostructure/tz-lookup": "^9.0.1", + "@photostructure/tz-lookup": "^9.0.2", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", "he": "^1.2.0", "luxon": "^3.4.4" }, "optionalDependencies": { - "exiftool-vendored.exe": "12.76.0", - "exiftool-vendored.pl": "12.76.0" + "exiftool-vendored.exe": "12.78.0", + "exiftool-vendored.pl": "12.78.0" } }, "node_modules/exiftool-vendored.exe": { - "version": "12.76.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz", - "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==", + "version": "12.78.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.78.0.tgz", + "integrity": "sha512-eMN7L67sb89xi8sN7INPg19uwa1KibG2oOyGcfOvB47h+1hzmGgivVu/SZIMeOToVIbLRwUl+AFwLYSTNXsJEg==", "dev": true, "optional": true, "os": [ @@ -2520,9 +2521,9 @@ ] }, "node_modules/exiftool-vendored.pl": { - "version": "12.76.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz", - "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==", + "version": "12.78.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.78.0.tgz", + "integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg==", "dev": true, "optional": true, "os": [ diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 2310b4718..de320ee95 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -1,6 +1,7 @@ import { AlbumResponseDto, AssetFileUploadResponseDto, + AssetOrder, LoginResponseDto, SharedLinkType, deleteUser, @@ -353,6 +354,7 @@ describe('/album', () => { assetCount: 0, owner: expect.objectContaining({ email: user1.userEmail }), isActivityEnabled: true, + order: AssetOrder.Desc, }); }); }); diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index e8f9a46bb..d9ac1eddb 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1,20 +1,48 @@ -import { LibraryResponseDto, LibraryType, LoginResponseDto, getAllLibraries } from '@immich/sdk'; +import { + LibraryResponseDto, + LibraryType, + LoginResponseDto, + ScanLibraryDto, + getAllLibraries, + scanLibrary, +} from '@immich/sdk'; +import { existsSync, rmdirSync } from 'node:fs'; +import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { app, asBearerAuth, testAssetDirInternal, utils } from 'src/utils'; +import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) => + scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) }); describe('/library', () => { let admin: LoginResponseDto; let user: LoginResponseDto; let library: LibraryResponseDto; + let websocket: Socket; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); user = await utils.userSetup(admin.accessToken, userDto.user1); library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); + websocket = await utils.connectWebsocket(admin.accessToken); + }); + + afterAll(() => { + utils.disconnectWebsocket(websocket); + }); + + beforeEach(() => { + utils.resetEvents(); + const tempDir = `${testAssetDir}/temp`; + if (existsSync(tempDir)) { + rmdirSync(tempDir, { recursive: true }); + } + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`); + utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`); }); describe('GET /library', () => { @@ -376,6 +404,36 @@ describe('/library', () => { ]), ); }); + + it('should delete an external library with assets', async () => { + const library = await utils.createLibrary(admin.accessToken, { + type: LibraryType.External, + importPaths: [`${testAssetDirInternal}/temp`], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + + const { status, body } = await request(app) + .delete(`/library/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(204); + expect(body).toEqual({}); + + const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) }); + expect(libraries).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: library.id, + }), + ]), + ); + + // ensure no files get deleted + expect(existsSync(`${testAssetDir}/temp/directoryA/assetA.png`)).toBe(true); + expect(existsSync(`${testAssetDir}/temp/directoryB/assetB.png`)).toBe(true); + }); }); describe('GET /library/:id/statistics', () => { @@ -394,6 +452,89 @@ describe('/library', () => { expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); + + it('should not scan an upload library', async () => { + const library = await utils.createLibrary(admin.accessToken, { + type: LibraryType.Upload, + }); + + const { status, body } = await request(app) + .post(`/library/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Can only refresh external libraries')); + }); + + it('should scan external library', async () => { + const library = await utils.createLibrary(admin.accessToken, { + type: LibraryType.External, + importPaths: [`${testAssetDirInternal}/temp/directoryA`], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 }); + + const { assets } = await utils.metadataSearch(admin.accessToken, { + originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, + }); + expect(assets.count).toBe(1); + }); + + it('should scan external library with exclusion pattern', async () => { + const library = await utils.createLibrary(admin.accessToken, { + type: LibraryType.External, + importPaths: [`${testAssetDirInternal}/temp`], + exclusionPatterns: ['**/directoryA'], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 }); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(assets.count).toBe(1); + expect(assets.items[0].originalPath.includes('directoryB')); + }); + + it('should scan multiple import paths', async () => { + const library = await utils.createLibrary(admin.accessToken, { + type: LibraryType.External, + importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(assets.count).toBe(2); + expect(assets.items.find((asset) => asset.originalPath.includes('directoryA'))).toBeDefined(); + expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined(); + }); + + it('should pick up new files', async () => { + const library = await utils.createLibrary(admin.accessToken, { + type: LibraryType.External, + importPaths: [`${testAssetDirInternal}/temp`], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(assets.count).toBe(2); + + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 }); + + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.count).toBe(3); + }); }); describe('POST /library/:id/removeOffline', () => { diff --git a/e2e/src/cli/specs/login.e2e-spec.ts b/e2e/src/cli/specs/login.e2e-spec.ts index 61702769c..42877221f 100644 --- a/e2e/src/cli/specs/login.e2e-spec.ts +++ b/e2e/src/cli/specs/login.e2e-spec.ts @@ -21,7 +21,9 @@ describe(`immich login-key`, () => { it('should require a valid key', async () => { const { stderr, exitCode } = await immichCli(['login-key', app, 'immich-is-so-cool']); - expect(stderr).toContain('Failed to connect to server http://127.0.0.1:2283/api: Error: 401'); + expect(stderr).toContain('Failed to connect to server'); + expect(stderr).toContain('Invalid API key'); + expect(stderr).toContain('401'); expect(exitCode).toBe(1); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index dde9ed22c..8ca7fba60 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -5,6 +5,7 @@ import { CreateAssetDto, CreateLibraryDto, CreateUserDto, + MetadataSearchDto, PersonCreateDto, SharedLinkCreateDto, ValidateLibraryDto, @@ -16,8 +17,10 @@ import { createUser, defaults, deleteAssets, + getAllAssets, getAssetInfo, login, + searchMetadata, setAdminOnboarding, signUpAdmin, validate, @@ -25,9 +28,9 @@ import { import { BrowserContext } from '@playwright/test'; import { exec, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { existsSync } from 'node:fs'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import path from 'node:path'; +import path, { dirname } from 'node:path'; import { promisify } from 'node:util'; import pg from 'pg'; import { io, type Socket } from 'socket.io-client'; @@ -37,7 +40,7 @@ import request from 'supertest'; type CliResponse = { stdout: string; stderr: string; exitCode: number | null }; type EventType = 'assetUpload' | 'assetDelete' | 'userDelete'; -type WaitOptions = { event: EventType; id: string; timeout?: number }; +type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number }; type AdminSetupOptions = { onboarding?: boolean }; type AssetData = { bytes?: Buffer; filename: string }; @@ -83,16 +86,30 @@ const events: Record> = { userDelete: new Set(), }; -const callbacks: Record void> = {}; +const idCallbacks: Record void> = {}; +const countCallbacks: Record void }> = {}; const execPromise = promisify(exec); const onEvent = ({ event, id }: { event: EventType; id: string }) => { - events[event].add(id); - const callback = callbacks[id]; - if (callback) { - callback(); - delete callbacks[id]; + // console.log(`Received event: ${event} [id=${id}]`); + const set = events[event]; + set.add(id); + + const idCallback = idCallbacks[id]; + if (idCallback) { + idCallback(); + delete idCallbacks[id]; + } + + const item = countCallbacks[event]; + if (item) { + const { count, callback: countCallback } = item; + + if (set.size >= count) { + countCallback(); + delete countCallbacks[event]; + } } }; @@ -184,20 +201,43 @@ export const utils = { } }, - waitForWebsocketEvent: async ({ event, id, timeout: ms }: WaitOptions): Promise => { - console.log(`Waiting for ${event} [${id}]`); + resetEvents: () => { + for (const set of Object.values(events)) { + set.clear(); + } + }, + + waitForWebsocketEvent: async ({ event, id, total: count, timeout: ms }: WaitOptions): Promise => { + if (!id && !count) { + throw new Error('id or count must be provided for waitForWebsocketEvent'); + } + + const type = id ? `id=${id}` : `count=${count}`; + console.log(`Waiting for ${event} [${type}]`); const set = events[event]; - if (set.has(id)) { + if ((id && set.has(id)) || (count && set.size >= count)) { return; } return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000); - callbacks[id] = () => { - clearTimeout(timeout); - resolve(); - }; + if (id) { + idCallbacks[id] = () => { + clearTimeout(timeout); + resolve(); + }; + } + + if (count) { + countCallbacks[event] = { + count, + callback: () => { + clearTimeout(timeout); + resolve(); + }, + }; + } }); }, @@ -263,8 +303,31 @@ export const utils = { return body as AssetFileUploadResponseDto; }, + createImageFile: (path: string) => { + if (!existsSync(dirname(path))) { + mkdirSync(dirname(path), { recursive: true }); + } + if (!existsSync(path)) { + writeFileSync(path, makeRandomImage()); + } + }, + + removeImageFile: (path: string) => { + if (!existsSync(path)) { + return; + } + + rmSync(path); + }, + getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), + getAllAssets: (accessToken: string) => getAllAssets({}, { headers: asBearerAuth(accessToken) }), + + metadataSearch: async (accessToken: string, dto: MetadataSearchDto) => { + return searchMetadata({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) }); + }, + deleteAssets: (accessToken: string, ids: string[]) => deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }), diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 561d2af41..2df540f09 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,8 +1,8 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:8e697181d24bd77cc4251fdd37e4cdd6d725c5de2ed63b9bc8db77357400c5e2 as builder-cpu +FROM python:3.11-bookworm@sha256:991e20a11120277e977cadbc104e7a9b196a68a346597879821b19034285a403 as builder-cpu -FROM openvino/ubuntu22_runtime:2023.1.0@sha256:002842a9005ba01543b7169ff6f14ecbec82287f09c4d1dd37717f0a8e8754a7 as builder-openvino +FROM openvino/ubuntu22_runtime:2023.3.0@sha256:176646df619032ea6c10faf842867119c393e7497b7f88b5e307e932a0fd5aa8 as builder-openvino USER root RUN apt-get update && apt-get install -y --no-install-recommends python3-dev @@ -21,10 +21,12 @@ FROM builder-${DEVICE} as builder ARG DEVICE ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=true \ - VIRTUAL_ENV="/opt/venv" \ - PATH="/opt/venv/bin:${PATH}" + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=true \ + VIRTUAL_ENV="/opt/venv" \ + PATH="/opt/venv/bin:${PATH}" + +RUN apt-get update && apt-get install -y --no-install-recommends g++ RUN pip install --upgrade pip && pip install poetry RUN poetry config installer.max-workers 10 && \ @@ -34,9 +36,9 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:ce81dc539f0aedc9114cae640f8352fad83d37461c24a3615b01f081d0c0583a as prod-cpu +FROM python:3.11-slim-bookworm@sha256:a2eb07f336e4f194358382611b4fea136c632b40baa6314cb27a366deeaf0144 as prod-cpu -FROM openvino/ubuntu22_runtime:2023.1.0@sha256:002842a9005ba01543b7169ff6f14ecbec82287f09c4d1dd37717f0a8e8754a7 as prod-openvino +FROM openvino/ubuntu22_runtime:2023.3.0@sha256:176646df619032ea6c10faf842867119c393e7497b7f88b5e307e932a0fd5aa8 as prod-openvino USER root FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04@sha256:2d913b09e6be8387e1a10976933642c73c840c0b735f0bf3c28d97fc9bc422e0 as prod-cuda @@ -56,14 +58,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends ocl-icd-libopen mkdir /opt/armnn COPY --from=builder-armnn \ - /opt/armnn/libarmnn.so.?? \ - /opt/armnn/libarmnnOnnxParser.so.?? \ - /opt/armnn/libarmnnDeserializer.so.?? \ - /opt/armnn/libarmnnTfLiteParser.so.?? \ - /opt/armnn/libprotobuf.so.?.??.?.? \ - /opt/ann/libann.s[o] \ - /opt/ann/build.sh \ - /opt/armnn/ + /opt/armnn/libarmnn.so.?? \ + /opt/armnn/libarmnnOnnxParser.so.?? \ + /opt/armnn/libarmnnDeserializer.so.?? \ + /opt/armnn/libarmnnTfLiteParser.so.?? \ + /opt/armnn/libprotobuf.so.?.??.?.? \ + /opt/ann/libann.s[o] \ + /opt/ann/build.sh \ + /opt/armnn/ FROM prod-${DEVICE} as prod @@ -73,11 +75,12 @@ RUN apt-get update && \ WORKDIR /usr/src/app ENV NODE_ENV=production \ - TRANSFORMERS_CACHE=/cache \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PATH="/opt/venv/bin:$PATH" \ - PYTHONPATH=/usr/src + TRANSFORMERS_CACHE=/cache \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" \ + PYTHONPATH=/usr/src \ + DEVICE=${DEVICE} # prevent core dumps RUN echo "hard core 0" >> /etc/security/limits.conf && \ diff --git a/machine-learning/app/models/base.py b/machine-learning/app/models/base.py index ad48624b4..16adc159a 100644 --- a/machine-learning/app/models/base.py +++ b/machine-learning/app/models/base.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from abc import ABC, abstractmethod from pathlib import Path from shutil import rmtree @@ -115,17 +114,12 @@ class InferenceModel(ABC): case ".armnn": session = AnnSession(model_path) case ".onnx": - cwd = os.getcwd() - try: - os.chdir(model_path.parent) - session = ort.InferenceSession( - model_path.as_posix(), - sess_options=self.sess_options, - providers=self.providers, - provider_options=self.provider_options, - ) - finally: - os.chdir(cwd) + session = ort.InferenceSession( + model_path.as_posix(), + sess_options=self.sess_options, + providers=self.providers, + provider_options=self.provider_options, + ) case _: raise ValueError(f"Unsupported model file type: {model_path.suffix}") return session @@ -192,7 +186,7 @@ class InferenceModel(ABC): case "CPUExecutionProvider" | "CUDAExecutionProvider": option = {"arena_extend_strategy": "kSameAsRequested"} case "OpenVINOExecutionProvider": - option = {"device_type": "GPU_FP32"} + option = {"device_type": "GPU_FP32", "cache_dir": (self.cache_dir / "openvino").as_posix()} case _: option = {} options.append(option) diff --git a/machine-learning/app/test_main.py b/machine-learning/app/test_main.py index 72cd020ff..22038a654 100644 --- a/machine-learning/app/test_main.py +++ b/machine-learning/app/test_main.py @@ -88,7 +88,7 @@ class TestBase: encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"]) assert encoder.provider_options == [ - {"device_type": "GPU_FP32"}, + {"device_type": "GPU_FP32", "cache_dir": (encoder.cache_dir / "openvino").as_posix()}, {"arena_extend_strategy": "kSameAsRequested"}, ] @@ -262,7 +262,6 @@ class TestBase: mock_ann = mocker.patch("app.models.base.AnnSession") mock_ort = mocker.patch("app.models.base.ort.InferenceSession") - mocker.patch("app.models.base.os.chdir") encoder = OpenCLIPEncoder("ViT-B-32__openai") encoder._make_session(mock_armnn_path) @@ -285,26 +284,6 @@ class TestBase: mock_ann.assert_not_called() mock_ort.assert_not_called() - def test_make_session_changes_cwd(self, mocker: MockerFixture) -> None: - mock_model_path = mocker.Mock() - mock_model_path.is_file.return_value = True - mock_model_path.suffix = ".onnx" - mock_model_path.parent = "model_parent" - mock_model_path.with_suffix.return_value = mock_model_path - mock_ort = mocker.patch("app.models.base.ort.InferenceSession") - mock_chdir = mocker.patch("app.models.base.os.chdir") - - encoder = OpenCLIPEncoder("ViT-B-32__openai") - encoder._make_session(mock_model_path) - - mock_chdir.assert_has_calls( - [ - mock.call(mock_model_path.parent), - mock.call(os.getcwd()), - ] - ) - mock_ort.assert_called_once() - def test_download(self, mocker: MockerFixture) -> None: mock_snapshot_download = mocker.patch("app.models.base.snapshot_download") diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 955a64f8e..7a0c66bdc 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiocache" @@ -1828,38 +1828,38 @@ files = [ [[package]] name = "mypy" -version = "1.8.0" +version = "1.9.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, - {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, - {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, - {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, - {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, - {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, - {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, - {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, - {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, - {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, - {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, - {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, - {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, - {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, - {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, - {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, - {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, - {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, - {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, + {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, + {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, + {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, + {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, + {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, + {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, + {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, + {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, + {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, + {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, + {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, + {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, + {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, + {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, + {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, + {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, + {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, ] [package.dependencies] @@ -2064,21 +2064,22 @@ reference = "cuda12" [[package]] name = "onnxruntime-openvino" -version = "1.15.0" +version = "1.17.1" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime_openvino-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9bfe245312e897f219dfef619c0d98f4797ffb008ad55aa41aedb32b522f72"}, - {file = "onnxruntime_openvino-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:a31cd9c9848dc196803d74ea46152fe0f3dd876bc5769eff7e3776fef4c654de"}, - {file = "onnxruntime_openvino-1.15.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c9bc1614f9d267d62023287035d204d9840ac0057d1c7a770a27acdd1642662"}, - {file = "onnxruntime_openvino-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5808b7b876e5f6a083228bd43fc1028096cb9b485f466bf980d8f72d8424d"}, + {file = "onnxruntime_openvino-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ed693011b472f9a617b2d5c4785d5fa1e1b77f7cb2b02e47b899534ec6c6396"}, + {file = "onnxruntime_openvino-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:5152b5e56e83e022ced2986700d68dd8ba7b1466761725ce774f679c5710ab87"}, + {file = "onnxruntime_openvino-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ce3b1aa06d6b8b732d314d217028ec4735de5806215c44d3bdbcad03b9260d5"}, + {file = "onnxruntime_openvino-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:21133a701bb07ea19e01f48b8c23beee575f2e879f49173843f275d7c91a625a"}, + {file = "onnxruntime_openvino-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76824dac3c392ad4b812f29c18be2055ab3bba2e3c111e44baae847b33d5b081"}, ] [package.dependencies] coloredlogs = "*" flatbuffers = "*" -numpy = ">=1.21.6" +numpy = ">=1.25.2" packaging = "*" protobuf = "*" sympy = "*" @@ -2289,13 +2290,13 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co [[package]] name = "pluggy" -version = "1.3.0" +version = "1.4.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, ] [package.extras] @@ -2472,13 +2473,13 @@ files = [ [[package]] name = "pytest" -version = "8.0.2" +version = "8.1.1" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"}, - {file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"}, + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, ] [package.dependencies] @@ -2486,21 +2487,21 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.3.0,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +pluggy = ">=1.4,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.23.5" +version = "0.23.5.post1" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.23.5.tar.gz", hash = "sha256:3a048872a9c4ba14c3e90cc1aa20cbc2def7d01c7c8db3777ec281ba9c057675"}, - {file = "pytest_asyncio-0.23.5-py3-none-any.whl", hash = "sha256:4e7093259ba018d58ede7d5315131d21923a60f8a6e9ee266ce1589685c89eac"}, + {file = "pytest-asyncio-0.23.5.post1.tar.gz", hash = "sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813"}, + {file = "pytest_asyncio-0.23.5.post1-py3-none-any.whl", hash = "sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e"}, ] [package.dependencies] @@ -3288,13 +3289,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.27.1" +version = "0.28.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"}, - {file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"}, + {file = "uvicorn-0.28.0-py3-none-any.whl", hash = "sha256:6623abbbe6176204a4226e67607b4d52cc60ff62cda0ff177613645cefa2ece1"}, + {file = "uvicorn-0.28.0.tar.gz", hash = "sha256:cab4473b5d1eaeb5a0f6375ac4bc85007ffc75c3cc1768816d9e5d589857b067"}, ] [package.dependencies] @@ -3626,4 +3627,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.12" -content-hash = "c947090d326e81179054b7ce4dded311df8b7ca5a56680d5e9459cf8ca18df1a" +content-hash = "1b014276ec94f9389459a70d31f0d96d1dd5a138bcc988900865e5f07a72bc62" diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 327f4fd35..c0e549af5 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -51,7 +51,7 @@ onnxruntime-gpu = {version = "^1.17.0", source = "cuda12"} optional = true [tool.poetry.group.openvino.dependencies] -onnxruntime-openvino = ">=1.15.0,<1.16.0" +onnxruntime-openvino = "^1.17.1" [tool.poetry.group.armnn] optional = true diff --git a/machine-learning/start.sh b/machine-learning/start.sh index d4a971f0c..7a5cb919a 100755 --- a/machine-learning/start.sh +++ b/machine-learning/start.sh @@ -1,8 +1,11 @@ #!/usr/bin/env sh lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2" -export LD_PRELOAD="$lib_path" -export LD_BIND_NOW=1 +# mimalloc seems to increase memory usage dramatically with openvino, need to investigate +if ! [ "$DEVICE" = "openvino" ]; then + export LD_PRELOAD="$lib_path" + export LD_BIND_NOW=1 +fi : "${MACHINE_LEARNING_HOST:=[::]}" : "${MACHINE_LEARNING_PORT:=3003}" diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index a57d56c21..4a50ef5a3 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -58,6 +58,7 @@ + @@ -75,4 +76,4 @@ - \ No newline at end of file + diff --git a/mobile/lib/modules/activities/widgets/activity_tile.dart b/mobile/lib/modules/activities/widgets/activity_tile.dart index cb434d22d..77dcb9892 100644 --- a/mobile/lib/modules/activities/widgets/activity_tile.dart +++ b/mobile/lib/modules/activities/widgets/activity_tile.dart @@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/modules/activities/models/activity.model.dart'; -import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; @@ -106,9 +106,8 @@ class _ActivityAssetThumbnail extends StatelessWidget { decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), image: DecorationImage( - image: ImmichRemoteImageProvider( + image: ImmichRemoteThumbnailProvider( assetId: assetId, - isThumbnail: true, ), fit: BoxFit.cover, ), diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index 996e25d0f..b1a8d4c54 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -211,8 +211,8 @@ class AlbumViewerAppbar extends HookConsumerWidget return SafeArea( child: Padding( padding: const EdgeInsets.only(top: 24.0), - child: Column( - mainAxisSize: MainAxisSize.min, + child: ListView( + shrinkWrap: true, children: [ ...buildBottomSheetActions(), if (onAddPhotos != null) ...commonActions, diff --git a/mobile/lib/modules/album/views/album_options_part.dart b/mobile/lib/modules/album/views/album_options_part.dart index 832db7cb8..2f831e430 100644 --- a/mobile/lib/modules/album/views/album_options_part.dart +++ b/mobile/lib/modules/album/views/album_options_part.dart @@ -142,6 +142,7 @@ class AlbumOptionsPage extends HookConsumerWidget { buildSharedUsersList() { return ListView.builder( + primary: false, shrinkWrap: true, itemCount: sharedUsers.value.length, itemBuilder: (context, index) { @@ -188,9 +189,7 @@ class AlbumOptionsPage extends HookConsumerWidget { centerTitle: true, title: Text("translated_text_options".tr()), ), - body: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, + body: ListView( children: [ if (isOwner && album.shared) SwitchListTile.adaptive( diff --git a/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart b/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart index cb17e6638..0a883791a 100644 --- a/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart +++ b/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart @@ -63,8 +63,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget { ), ); } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return ListView( children: [ Wrap( children: [...usersChip], @@ -81,6 +80,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget { ), ), ListView.builder( + primary: false, shrinkWrap: true, itemBuilder: ((context, index) { return ListTile( diff --git a/mobile/lib/modules/album/views/select_user_for_sharing_page.dart b/mobile/lib/modules/album/views/select_user_for_sharing_page.dart index 61550e04f..763f53c65 100644 --- a/mobile/lib/modules/album/views/select_user_for_sharing_page.dart +++ b/mobile/lib/modules/album/views/select_user_for_sharing_page.dart @@ -90,8 +90,7 @@ class SelectUserForSharingPage extends HookConsumerWidget { ), ); } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return ListView( children: [ Wrap( children: [...usersChip], @@ -108,6 +107,7 @@ class SelectUserForSharingPage extends HookConsumerWidget { ).tr(), ), ListView.builder( + primary: false, shrinkWrap: true, itemBuilder: ((context, index) { return ListTile( diff --git a/mobile/lib/modules/asset_viewer/image_providers/cache/image_loader.dart b/mobile/lib/modules/asset_viewer/image_providers/cache/image_loader.dart new file mode 100644 index 000000000..e5204acde --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/cache/image_loader.dart @@ -0,0 +1,58 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart'; +import 'package:immich_mobile/shared/models/store.dart'; + +/// Loads the codec from the URI and sends the events to the [chunkEvents] stream +/// +/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart) +/// for this wonderful implementation of their image loader +class ImageLoader { + static Future loadImageFromCache( + String uri, { + required ImageCacheManager cache, + required ImageDecoderCallback decode, + StreamController? chunkEvents, + int? height, + int? width, + }) async { + final headers = { + 'x-immich-user-token': Store.get(StoreKey.accessToken), + }; + + final stream = cache.getImageFile( + uri, + withProgress: true, + headers: headers, + maxHeight: height, + maxWidth: width, + ); + + await for (final result in stream) { + if (result is DownloadProgress) { + // We are downloading the file, so update the [chunkEvents] + chunkEvents?.add( + ImageChunkEvent( + cumulativeBytesLoaded: result.downloaded, + expectedTotalBytes: result.totalSize, + ), + ); + } + + if (result is FileInfo) { + // We have the file + final file = result.file; + final bytes = await file.readAsBytes(); + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + final decoded = await decode(buffer); + return decoded; + } + } + + // If we get here, the image failed to load from the cache stream + throw ImageLoadingException('Could not load image from stream'); + } +} diff --git a/mobile/lib/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart b/mobile/lib/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart new file mode 100644 index 000000000..b0f242a99 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart @@ -0,0 +1,20 @@ +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +/// The cache manager for full size images [ImmichRemoteImageProvider] +class RemoteImageCacheManager extends CacheManager with ImageCacheManager { + static const key = 'remoteImageCacheKey'; + static final RemoteImageCacheManager _instance = RemoteImageCacheManager._(); + + factory RemoteImageCacheManager() { + return _instance; + } + + RemoteImageCacheManager._() + : super( + Config( + key, + maxNrOfCacheObjects: 500, + stalePeriod: const Duration(days: 30), + ), + ); +} diff --git a/mobile/lib/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart b/mobile/lib/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart new file mode 100644 index 000000000..8bd320b72 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart @@ -0,0 +1,21 @@ +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +/// The cache manager for thumbnail images [ImmichRemoteThumbnailProvider] +class ThumbnailImageCacheManager extends CacheManager with ImageCacheManager { + static const key = 'thumbnailImageCacheKey'; + static final ThumbnailImageCacheManager _instance = + ThumbnailImageCacheManager._(); + + factory ThumbnailImageCacheManager() { + return _instance; + } + + ThumbnailImageCacheManager._() + : super( + Config( + key, + maxNrOfCacheObjects: 5000, + stalePeriod: const Duration(days: 30), + ), + ); +} diff --git a/mobile/lib/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart b/mobile/lib/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart new file mode 100644 index 000000000..5e5ff7235 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart @@ -0,0 +1,5 @@ +/// An exception for the [ImageLoader] and the Immich image providers +class ImageLoadingException implements Exception { + final String message; + ImageLoadingException(this.message); +} diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart index d9fbd8048..095f15b82 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart @@ -1,8 +1,10 @@ import 'dart:async'; -import 'dart:io'; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/image_loader.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart'; import 'package:openapi/api.dart' as api; import 'package:flutter/foundation.dart'; @@ -12,24 +14,18 @@ import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -/// Our Image Provider HTTP client to make the request -final _httpClient = HttpClient() - ..autoUncompress = false - ..maxConnectionsPerHost = 10; - -/// The remote image provider +/// The remote image provider for full size remote images class ImmichRemoteImageProvider extends ImageProvider { /// The [Asset.remoteId] of the asset to fetch final String assetId; - // If this is a thumbnail, we stop at loading the - // smallest version of the remote image - final bool isThumbnail; + /// The image cache manager + final ImageCacheManager? cacheManager; ImmichRemoteImageProvider({ required this.assetId, - this.isThumbnail = false, + this.cacheManager, }); /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key @@ -46,9 +42,10 @@ class ImmichRemoteImageProvider ImmichRemoteImageProvider key, ImageDecoderCallback decode, ) { + final cache = cacheManager ?? RemoteImageCacheManager(); final chunkEvents = StreamController(); return MultiImageStreamCompleter( - codec: _codec(key, decode, chunkEvents), + codec: _codec(key, cache, decode, chunkEvents), scale: 1.0, chunkEvents: chunkEvents.stream, ); @@ -69,82 +66,61 @@ class ImmichRemoteImageProvider // Streams in each stage of the image as we ask for it Stream _codec( ImmichRemoteImageProvider key, + ImageCacheManager cache, ImageDecoderCallback decode, StreamController chunkEvents, ) async* { // Load a preview to the chunk events - if (_loadPreview || key.isThumbnail) { + if (_loadPreview) { final preview = getThumbnailUrlForRemoteId( key.assetId, type: api.ThumbnailFormat.WEBP, ); - yield await _loadFromUri( - Uri.parse(preview), - decode, - chunkEvents, + yield await ImageLoader.loadImageFromCache( + preview, + cache: cache, + decode: decode, + chunkEvents: chunkEvents, ); } - // Guard thumnbail rendering - if (key.isThumbnail) { - await chunkEvents.close(); - return; - } - // Load the higher resolution version of the image final url = getThumbnailUrlForRemoteId( key.assetId, type: api.ThumbnailFormat.JPEG, ); - final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); + final codec = await ImageLoader.loadImageFromCache( + url, + cache: cache, + decode: decode, + chunkEvents: chunkEvents, + ); yield codec; // Load the final remote image if (_useOriginal) { // Load the original image final url = getImageUrlFromId(key.assetId); - final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); + final codec = await ImageLoader.loadImageFromCache( + url, + cache: cache, + decode: decode, + chunkEvents: chunkEvents, + ); yield codec; } await chunkEvents.close(); } - // Loads the codec from the URI and sends the events to the [chunkEvents] stream - Future _loadFromUri( - Uri uri, - ImageDecoderCallback decode, - StreamController chunkEvents, - ) async { - final request = await _httpClient.getUrl(uri); - request.headers.add( - 'x-immich-user-token', - Store.get(StoreKey.accessToken), - ); - final response = await request.close(); - // Chunks of the completed image can be shown - final data = await consolidateHttpClientResponseBytes( - response, - onBytesReceived: (cumulative, total) { - chunkEvents.add( - ImageChunkEvent( - cumulativeBytesLoaded: cumulative, - expectedTotalBytes: total, - ), - ); - }, - ); - - // Decode the response - final buffer = await ui.ImmutableBuffer.fromUint8List(data); - return decode(buffer); - } - @override bool operator ==(Object other) { - if (other is! ImmichRemoteImageProvider) return false; if (identical(this, other)) return true; - return assetId == other.assetId && isThumbnail == other.isThumbnail; + if (other is ImmichRemoteImageProvider) { + return assetId == other.assetId; + } + + return false; } @override diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart index 92b85b347..84fdbd740 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart @@ -1,30 +1,34 @@ import 'dart:async'; -import 'dart:io'; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/image_loader.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart'; import 'package:openapi/api.dart' as api; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -/// Our HTTP client to make the request -final _httpClient = HttpClient() - ..autoUncompress = false - ..maxConnectionsPerHost = 100; - /// The remote image provider class ImmichRemoteThumbnailProvider extends ImageProvider { /// The [Asset.remoteId] of the asset to fetch final String assetId; + final int? height; + final int? width; + + /// The image cache manager + final ImageCacheManager? cacheManager; + ImmichRemoteThumbnailProvider({ required this.assetId, + this.height, + this.width, + this.cacheManager, }); /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key @@ -41,19 +45,18 @@ class ImmichRemoteThumbnailProvider ImmichRemoteThumbnailProvider key, ImageDecoderCallback decode, ) { - final chunkEvents = StreamController(); + final cache = cacheManager ?? ThumbnailImageCacheManager(); return MultiImageStreamCompleter( - codec: _codec(key, decode, chunkEvents), + codec: _codec(key, cache, decode), scale: 1.0, - chunkEvents: chunkEvents.stream, ); } // Streams in each stage of the image as we ask for it Stream _codec( ImmichRemoteThumbnailProvider key, + ImageCacheManager cache, ImageDecoderCallback decode, - StreamController chunkEvents, ) async* { // Load a preview to the chunk events final preview = getThumbnailUrlForRemoteId( @@ -61,50 +64,21 @@ class ImmichRemoteThumbnailProvider type: api.ThumbnailFormat.WEBP, ); - yield await _loadFromUri( - Uri.parse(preview), - decode, - chunkEvents, + yield await ImageLoader.loadImageFromCache( + preview, + cache: cache, + decode: decode, ); - - await chunkEvents.close(); - } - - // Loads the codec from the URI and sends the events to the [chunkEvents] stream - Future _loadFromUri( - Uri uri, - ImageDecoderCallback decode, - StreamController chunkEvents, - ) async { - final request = await _httpClient.getUrl(uri); - request.headers.add( - 'x-immich-user-token', - Store.get(StoreKey.accessToken), - ); - final response = await request.close(); - // Chunks of the completed image can be shown - final data = await consolidateHttpClientResponseBytes( - response, - onBytesReceived: (cumulative, total) { - chunkEvents.add( - ImageChunkEvent( - cumulativeBytesLoaded: cumulative, - expectedTotalBytes: total, - ), - ); - }, - ); - - // Decode the response - final buffer = await ui.ImmutableBuffer.fromUint8List(data); - return decode(buffer); } @override bool operator ==(Object other) { - if (other is! ImmichRemoteImageProvider) return false; if (identical(this, other)) return true; - return assetId == other.assetId; + if (other is ImmichRemoteThumbnailProvider) { + return assetId == other.assetId; + } + + return false; } @override diff --git a/mobile/lib/modules/backup/models/backup_state.model.dart b/mobile/lib/modules/backup/models/backup_state.model.dart index dd90251b8..3a9003731 100644 --- a/mobile/lib/modules/backup/models/backup_state.model.dart +++ b/mobile/lib/modules/backup/models/backup_state.model.dart @@ -21,6 +21,11 @@ class BackUpState { final BackUpProgressEnum backupProgress; final List allAssetsInDatabase; final double progressInPercentage; + final String progressInFileSize; + final double progressInFileSpeed; + final List progressInFileSpeeds; + final DateTime progressInFileSpeedUpdateTime; + final int progressInFileSpeedUpdateSentBytes; final double iCloudDownloadProgress; final CancellationToken cancelToken; final ServerDiskInfo serverInfo; @@ -48,6 +53,11 @@ class BackUpState { required this.backupProgress, required this.allAssetsInDatabase, required this.progressInPercentage, + required this.progressInFileSize, + required this.progressInFileSpeed, + required this.progressInFileSpeeds, + required this.progressInFileSpeedUpdateTime, + required this.progressInFileSpeedUpdateSentBytes, required this.iCloudDownloadProgress, required this.cancelToken, required this.serverInfo, @@ -68,6 +78,11 @@ class BackUpState { BackUpProgressEnum? backupProgress, List? allAssetsInDatabase, double? progressInPercentage, + String? progressInFileSize, + double? progressInFileSpeed, + List? progressInFileSpeeds, + DateTime? progressInFileSpeedUpdateTime, + int? progressInFileSpeedUpdateSentBytes, double? iCloudDownloadProgress, CancellationToken? cancelToken, ServerDiskInfo? serverInfo, @@ -87,6 +102,13 @@ class BackUpState { backupProgress: backupProgress ?? this.backupProgress, allAssetsInDatabase: allAssetsInDatabase ?? this.allAssetsInDatabase, progressInPercentage: progressInPercentage ?? this.progressInPercentage, + progressInFileSize: progressInFileSize ?? this.progressInFileSize, + progressInFileSpeed: progressInFileSpeed ?? this.progressInFileSpeed, + progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds, + progressInFileSpeedUpdateTime: + progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, + progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? + this.progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, cancelToken: cancelToken ?? this.cancelToken, @@ -109,7 +131,7 @@ class BackUpState { @override String toString() { - return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; + return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; } @override @@ -120,6 +142,12 @@ class BackUpState { return other.backupProgress == backupProgress && collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) && other.progressInPercentage == progressInPercentage && + other.progressInFileSize == progressInFileSize && + other.progressInFileSpeed == progressInFileSpeed && + collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) && + other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && + other.progressInFileSpeedUpdateSentBytes == + progressInFileSpeedUpdateSentBytes && other.iCloudDownloadProgress == iCloudDownloadProgress && other.cancelToken == cancelToken && other.serverInfo == serverInfo && @@ -144,6 +172,11 @@ class BackUpState { return backupProgress.hashCode ^ allAssetsInDatabase.hashCode ^ progressInPercentage.hashCode ^ + progressInFileSize.hashCode ^ + progressInFileSpeed.hashCode ^ + progressInFileSpeeds.hashCode ^ + progressInFileSpeedUpdateTime.hashCode ^ + progressInFileSpeedUpdateSentBytes.hashCode ^ iCloudDownloadProgress.hashCode ^ cancelToken.hashCode ^ serverInfo.hashCode ^ diff --git a/mobile/lib/modules/backup/models/current_upload_asset.model.dart b/mobile/lib/modules/backup/models/current_upload_asset.model.dart index ae75f68f8..9a761c9e4 100644 --- a/mobile/lib/modules/backup/models/current_upload_asset.model.dart +++ b/mobile/lib/modules/backup/models/current_upload_asset.model.dart @@ -6,6 +6,7 @@ class CurrentUploadAsset { final DateTime fileCreatedAt; final String fileName; final String fileType; + final int? fileSize; final bool? iCloudAsset; CurrentUploadAsset({ @@ -13,6 +14,7 @@ class CurrentUploadAsset { required this.fileCreatedAt, required this.fileName, required this.fileType, + this.fileSize, this.iCloudAsset, }); @@ -21,6 +23,7 @@ class CurrentUploadAsset { DateTime? fileCreatedAt, String? fileName, String? fileType, + int? fileSize, bool? iCloudAsset, }) { return CurrentUploadAsset( @@ -28,6 +31,7 @@ class CurrentUploadAsset { fileCreatedAt: fileCreatedAt ?? this.fileCreatedAt, fileName: fileName ?? this.fileName, fileType: fileType ?? this.fileType, + fileSize: fileSize ?? this.fileSize, iCloudAsset: iCloudAsset ?? this.iCloudAsset, ); } @@ -38,6 +42,7 @@ class CurrentUploadAsset { 'fileCreatedAt': fileCreatedAt.millisecondsSinceEpoch, 'fileName': fileName, 'fileType': fileType, + 'fileSize': fileSize, 'iCloudAsset': iCloudAsset, }; } @@ -49,6 +54,7 @@ class CurrentUploadAsset { DateTime.fromMillisecondsSinceEpoch(map['fileCreatedAt'] as int), fileName: map['fileName'] as String, fileType: map['fileType'] as String, + fileSize: map['fileSize'] as int, iCloudAsset: map['iCloudAsset'] != null ? map['iCloudAsset'] as bool : null, ); @@ -61,7 +67,7 @@ class CurrentUploadAsset { @override String toString() { - return 'CurrentUploadAsset(id: $id, fileCreatedAt: $fileCreatedAt, fileName: $fileName, fileType: $fileType, iCloudAsset: $iCloudAsset)'; + return 'CurrentUploadAsset(id: $id, fileCreatedAt: $fileCreatedAt, fileName: $fileName, fileType: $fileType, fileSize: $fileSize, iCloudAsset: $iCloudAsset)'; } @override @@ -72,6 +78,7 @@ class CurrentUploadAsset { other.fileCreatedAt == fileCreatedAt && other.fileName == fileName && other.fileType == fileType && + other.fileSize == fileSize && other.iCloudAsset == iCloudAsset; } @@ -81,6 +88,7 @@ class CurrentUploadAsset { fileCreatedAt.hashCode ^ fileName.hashCode ^ fileType.hashCode ^ + fileSize.hashCode ^ iCloudAsset.hashCode; } } diff --git a/mobile/lib/modules/backup/models/manual_upload_state.model.dart b/mobile/lib/modules/backup/models/manual_upload_state.model.dart index 1a83609c2..3b56672cf 100644 --- a/mobile/lib/modules/backup/models/manual_upload_state.model.dart +++ b/mobile/lib/modules/backup/models/manual_upload_state.model.dart @@ -1,4 +1,6 @@ import 'package:cancellation_token_http/http.dart'; +import 'package:collection/collection.dart'; + import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; class ManualUploadState { @@ -14,9 +16,19 @@ class ManualUploadState { final int totalAssetsToUpload; final int successfulUploads; final double progressInPercentage; + final String progressInFileSize; + final double progressInFileSpeed; + final List progressInFileSpeeds; + final DateTime progressInFileSpeedUpdateTime; + final int progressInFileSpeedUpdateSentBytes; const ManualUploadState({ required this.progressInPercentage, + required this.progressInFileSize, + required this.progressInFileSpeed, + required this.progressInFileSpeeds, + required this.progressInFileSpeedUpdateTime, + required this.progressInFileSpeedUpdateSentBytes, required this.cancelToken, required this.currentUploadAsset, required this.totalAssetsToUpload, @@ -27,6 +39,11 @@ class ManualUploadState { ManualUploadState copyWith({ double? progressInPercentage, + String? progressInFileSize, + double? progressInFileSpeed, + List? progressInFileSpeeds, + DateTime? progressInFileSpeedUpdateTime, + int? progressInFileSpeedUpdateSentBytes, CancellationToken? cancelToken, CurrentUploadAsset? currentUploadAsset, int? totalAssetsToUpload, @@ -36,6 +53,13 @@ class ManualUploadState { }) { return ManualUploadState( progressInPercentage: progressInPercentage ?? this.progressInPercentage, + progressInFileSize: progressInFileSize ?? this.progressInFileSize, + progressInFileSpeed: progressInFileSpeed ?? this.progressInFileSpeed, + progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds, + progressInFileSpeedUpdateTime: + progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, + progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? + this.progressInFileSpeedUpdateSentBytes, cancelToken: cancelToken ?? this.cancelToken, currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset, totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload, @@ -48,15 +72,22 @@ class ManualUploadState { @override String toString() { - return 'ManualUploadState(progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; + return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; } @override bool operator ==(Object other) { if (identical(this, other)) return true; + final collectionEquals = const DeepCollectionEquality().equals; return other is ManualUploadState && other.progressInPercentage == progressInPercentage && + other.progressInFileSize == progressInFileSize && + other.progressInFileSpeed == progressInFileSpeed && + collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) && + other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && + other.progressInFileSpeedUpdateSentBytes == + progressInFileSpeedUpdateSentBytes && other.cancelToken == cancelToken && other.currentUploadAsset == currentUploadAsset && other.totalAssetsToUpload == totalAssetsToUpload && @@ -68,6 +99,11 @@ class ManualUploadState { @override int get hashCode { return progressInPercentage.hashCode ^ + progressInFileSize.hashCode ^ + progressInFileSpeed.hashCode ^ + progressInFileSpeeds.hashCode ^ + progressInFileSpeedUpdateTime.hashCode ^ + progressInFileSpeedUpdateSentBytes.hashCode ^ cancelToken.hashCode ^ currentUploadAsset.hashCode ^ totalAssetsToUpload.hashCode ^ diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index 68c6bf9e6..a02ddf4e3 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart'; +import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -40,6 +41,11 @@ class BackupNotifier extends StateNotifier { backupProgress: BackUpProgressEnum.idle, allAssetsInDatabase: const [], progressInPercentage: 0, + progressInFileSize: "0 B / 0 B", + progressInFileSpeed: 0, + progressInFileSpeeds: const [], + progressInFileSpeedUpdateTime: DateTime.now(), + progressInFileSpeedUpdateSentBytes: 0, cancelToken: CancellationToken(), autoBackup: Store.get(StoreKey.autoBackup, false), backgroundBackup: Store.get(StoreKey.backgroundBackup, false), @@ -63,6 +69,7 @@ class BackupNotifier extends StateNotifier { fileCreatedAt: DateTime.parse('2020-10-04'), fileName: '...', fileType: '...', + fileSize: 0, iCloudAsset: false, ), iCloudDownloadProgress: 0.0, @@ -495,6 +502,10 @@ class BackupNotifier extends StateNotifier { state = state.copyWith( backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0, + progressInFileSize: "0 B / 0 B", + progressInFileSpeed: 0, + progressInFileSpeedUpdateTime: DateTime.now(), + progressInFileSpeedUpdateSentBytes: 0, ); } @@ -535,6 +546,10 @@ class BackupNotifier extends StateNotifier { .toSet(), backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0, + progressInFileSize: "0 B / 0 B", + progressInFileSpeed: 0, + progressInFileSpeedUpdateTime: DateTime.now(), + progressInFileSpeedUpdateSentBytes: 0, ); _updatePersistentAlbumsSelection(); } @@ -543,8 +558,36 @@ class BackupNotifier extends StateNotifier { } void _onUploadProgress(int sent, int total) { + double lastUploadSpeed = state.progressInFileSpeed; + List lastUploadSpeeds = state.progressInFileSpeeds.toList(); + DateTime lastUpdateTime = state.progressInFileSpeedUpdateTime; + int lastSentBytes = state.progressInFileSpeedUpdateSentBytes; + + final now = DateTime.now(); + final duration = now.difference(lastUpdateTime); + + // Keep the upload speed average span limited, to keep it somewhat relevant + if (lastUploadSpeeds.length > 10) { + lastUploadSpeeds.removeAt(0); + } + + if (duration.inSeconds > 0) { + lastUploadSpeeds.add( + ((sent - lastSentBytes) / duration.inSeconds).abs().roundToDouble(), + ); + + lastUploadSpeed = lastUploadSpeeds.average.abs().roundToDouble(); + lastUpdateTime = now; + lastSentBytes = sent; + } + state = state.copyWith( progressInPercentage: (sent.toDouble() / total.toDouble() * 100), + progressInFileSize: humanReadableFileBytesProgress(sent, total), + progressInFileSpeed: lastUploadSpeed, + progressInFileSpeeds: lastUploadSpeeds, + progressInFileSpeedUpdateTime: lastUpdateTime, + progressInFileSpeedUpdateSentBytes: lastSentBytes, ); } diff --git a/mobile/lib/modules/backup/providers/manual_upload.provider.dart b/mobile/lib/modules/backup/providers/manual_upload.provider.dart index bd7756adf..6d9ecbd20 100644 --- a/mobile/lib/modules/backup/providers/manual_upload.provider.dart +++ b/mobile/lib/modules/backup/providers/manual_upload.provider.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:cancellation_token_http/http.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -47,6 +48,11 @@ class ManualUploadNotifier extends StateNotifier { ) : super( ManualUploadState( progressInPercentage: 0, + progressInFileSize: "0 B / 0 B", + progressInFileSpeed: 0, + progressInFileSpeeds: const [], + progressInFileSpeedUpdateTime: DateTime.now(), + progressInFileSpeedUpdateSentBytes: 0, cancelToken: CancellationToken(), currentUploadAsset: CurrentUploadAsset( id: '...', @@ -123,9 +129,38 @@ class ManualUploadNotifier extends StateNotifier { } void _onProgress(int sent, int total) { + double lastUploadSpeed = state.progressInFileSpeed; + List lastUploadSpeeds = state.progressInFileSpeeds.toList(); + DateTime lastUpdateTime = state.progressInFileSpeedUpdateTime; + int lastSentBytes = state.progressInFileSpeedUpdateSentBytes; + + final now = DateTime.now(); + final duration = now.difference(lastUpdateTime); + + // Keep the upload speed average span limited, to keep it somewhat relevant + if (lastUploadSpeeds.length > 10) { + lastUploadSpeeds.removeAt(0); + } + + if (duration.inSeconds > 0) { + lastUploadSpeeds.add( + ((sent - lastSentBytes) / duration.inSeconds).abs().roundToDouble(), + ); + + lastUploadSpeed = lastUploadSpeeds.average.abs().roundToDouble(); + lastUpdateTime = now; + lastSentBytes = sent; + } + state = state.copyWith( progressInPercentage: (sent.toDouble() / total.toDouble() * 100), + progressInFileSize: humanReadableFileBytesProgress(sent, total), + progressInFileSpeed: lastUploadSpeed, + progressInFileSpeeds: lastUploadSpeeds, + progressInFileSpeedUpdateTime: lastUpdateTime, + progressInFileSpeedUpdateSentBytes: lastSentBytes, ); + if (state.showDetailedNotification) { final title = "backup_background_service_current_upload_notification" .tr(args: [state.currentUploadAsset.fileName]); @@ -184,6 +219,8 @@ class ManualUploadNotifier extends StateNotifier { state = state.copyWith( progressInPercentage: 0, + progressInFileSize: "0 B / 0 B", + progressInFileSpeed: 0, totalAssetsToUpload: allUploadAssets.length, successfulUploads: 0, currentAssetIndex: 0, @@ -291,7 +328,13 @@ class ManualUploadNotifier extends StateNotifier { if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); } - state = state.copyWith(progressInPercentage: 0); + state = state.copyWith( + progressInPercentage: 0, + progressInFileSize: "0 B / 0 B", + progressInFileSpeed: 0, + progressInFileSpeedUpdateTime: DateTime.now(), + progressInFileSpeedUpdateSentBytes: 0, + ); } Future uploadAssets( diff --git a/mobile/lib/modules/backup/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart index 48d6f71cf..d4277a822 100644 --- a/mobile/lib/modules/backup/services/backup.service.dart +++ b/mobile/lib/modules/backup/services/backup.service.dart @@ -316,6 +316,8 @@ class BackupService { req.files.add(assetRawUploadData); + var fileSize = file.lengthSync(); + if (entity.isLivePhoto) { if (livePhotoFile != null) { final livePhotoTitle = p.setExtension( @@ -330,6 +332,7 @@ class BackupService { filename: livePhotoTitle, ); req.files.add(livePhotoRawUploadData); + fileSize += livePhotoFile.lengthSync(); } else { _log.warning( "Failed to obtain motion part of the livePhoto - $originalFileName", @@ -345,6 +348,7 @@ class BackupService { : entity.createDateTime, fileName: originalFileName, fileType: _getAssetType(entity.type), + fileSize: fileSize, iCloudAsset: false, ), ); diff --git a/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart b/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart index 417fd3be5..35bee2f8d 100644 --- a/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart +++ b/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart @@ -26,10 +26,28 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { var uploadProgress = !isManualUpload ? ref.watch(backupProvider).progressInPercentage : ref.watch(manualUploadProvider).progressInPercentage; + var uploadFileProgress = !isManualUpload + ? ref.watch(backupProvider).progressInFileSize + : ref.watch(manualUploadProvider).progressInFileSize; + var uploadFileSpeed = !isManualUpload + ? ref.watch(backupProvider).progressInFileSpeed + : ref.watch(manualUploadProvider).progressInFileSpeed; var iCloudDownloadProgress = ref.watch(backupProvider).iCloudDownloadProgress; final isShowThumbnail = useState(false); + String formatUploadFileSpeed(double uploadFileSpeed) { + if (uploadFileSpeed < 1024) { + return '${uploadFileSpeed.toStringAsFixed(2)} B/s'; + } else if (uploadFileSpeed < 1024 * 1024) { + return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s'; + } else if (uploadFileSpeed < 1024 * 1024 * 1024) { + return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s'; + } else { + return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s'; + } + } + String getAssetCreationDate() { return DateFormat.yMMMMd().format( DateTime.parse( @@ -202,7 +220,26 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { ), Text( " ${uploadProgress.toStringAsFixed(0)}%", - style: const TextStyle(fontSize: 12), + style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"), + ), + ], + ), + ); + } + + buildUploadStats() { + return Padding( + padding: const EdgeInsets.only(top: 2.0, bottom: 2.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + uploadFileProgress, + style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"), + ), + Text( + formatUploadFileSpeed(uploadFileSpeed), + style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"), ), ], ), @@ -265,6 +302,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { children: [ if (Platform.isIOS) buildiCloudDownloadProgerssBar(), buildUploadProgressBar(), + buildUploadStats(), Padding( padding: const EdgeInsets.only(top: 8.0), child: buildAssetInfoTable(), diff --git a/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart b/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart index 288fdae33..b6d892f92 100644 --- a/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart +++ b/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart @@ -428,10 +428,8 @@ class SharedLinkEditPage extends HookConsumerWidget { leading: const CloseButton(), centerTitle: false, ), - resizeToAvoidBottomInset: false, body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: ListView( children: [ Padding( padding: const EdgeInsets.all(padding), @@ -487,7 +485,10 @@ class SharedLinkEditPage extends HookConsumerWidget { Align( alignment: Alignment.bottomRight, child: Padding( - padding: const EdgeInsets.only(right: padding + 10), + padding: const EdgeInsets.only( + right: padding + 10, + bottom: padding, + ), child: ElevatedButton( onPressed: existingLink != null ? handleEditLink : handleNewLink, @@ -508,6 +509,7 @@ class SharedLinkEditPage extends HookConsumerWidget { padding: const EdgeInsets.only( left: padding, right: padding, + bottom: padding, ), child: buildNewLinkField(), ), diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index f06f1726a..2e25a67b0 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -42,7 +42,6 @@ class ImmichImage extends StatelessWidget { if (asset == null) { return ImmichRemoteImageProvider( assetId: assetId!, - isThumbnail: false, ); } @@ -53,7 +52,6 @@ class ImmichImage extends StatelessWidget { } else { return ImmichRemoteImageProvider( assetId: asset.remoteId!, - isThumbnail: false, ); } } diff --git a/mobile/lib/shared/ui/immich_thumbnail.dart b/mobile/lib/shared/ui/immich_thumbnail.dart index 77827348d..3bfd16425 100644 --- a/mobile/lib/shared/ui/immich_thumbnail.dart +++ b/mobile/lib/shared/ui/immich_thumbnail.dart @@ -3,7 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; @@ -38,9 +38,8 @@ class ImmichThumbnail extends HookWidget { } if (asset == null) { - return ImmichRemoteImageProvider( + return ImmichRemoteThumbnailProvider( assetId: assetId!, - isThumbnail: true, ); } @@ -51,9 +50,10 @@ class ImmichThumbnail extends HookWidget { width: thumbnailSize, ); } else { - return ImmichRemoteImageProvider( + return ImmichRemoteThumbnailProvider( assetId: asset.remoteId!, - isThumbnail: true, + height: thumbnailSize, + width: thumbnailSize, ); } } diff --git a/mobile/lib/utils/backup_progress.dart b/mobile/lib/utils/backup_progress.dart index f24e8c6cf..38cdeacdb 100644 --- a/mobile/lib/utils/backup_progress.dart +++ b/mobile/lib/utils/backup_progress.dart @@ -10,6 +10,25 @@ String formatAssetBackupProgress(int uploadedAssets, int assetsToUpload) { return "$percent% ($uploadedAssets/$assetsToUpload)"; } +/// prints progress in useful (kilo/mega/giga)bytes +String humanReadableFileBytesProgress(int bytes, int bytesTotal) { + String unit = "KB"; + + if (bytesTotal >= 0x40000000) { + unit = "GB"; + bytes >>= 20; + bytesTotal >>= 20; + } else if (bytesTotal >= 0x100000) { + unit = "MB"; + bytes >>= 10; + bytesTotal >>= 10; + } else if (bytesTotal < 0x400) { + return "${(bytes).toStringAsFixed(2)} B / ${(bytesTotal).toStringAsFixed(2)} B"; + } + + return "${(bytes / 1024.0).toStringAsFixed(2)} $unit / ${(bytesTotal / 1024.0).toStringAsFixed(2)} $unit"; +} + /// prints percentage and absolute progress in useful (kilo/mega/giga)bytes String humanReadableBytesProgress(int bytes, int bytesTotal) { String unit = "KB"; // Kilobyte diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index bc00d30af..dd4a94e88 100644 --- a/mobile/openapi/doc/AlbumResponseDto.md +++ b/mobile/openapi/doc/AlbumResponseDto.md @@ -19,6 +19,7 @@ Name | Type | Description | Notes **id** | **String** | | **isActivityEnabled** | **bool** | | **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional] +**order** | [**AssetOrder**](AssetOrder.md) | | [optional] **owner** | [**UserResponseDto**](UserResponseDto.md) | | **ownerId** | **String** | | **shared** | **bool** | | diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index c65e6a605..1aaf195f3 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -834,7 +834,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **getTimeBucket** -> List getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, personId, userId, withPartners, withStacked) +> List getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked) @@ -864,13 +864,14 @@ final isArchived = true; // bool | final isFavorite = true; // bool | final isTrashed = true; // bool | final key = key_example; // String | +final order = ; // AssetOrder | final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final withPartners = true; // bool | final withStacked = true; // bool | try { - final result = api_instance.getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, personId, userId, withPartners, withStacked); + final result = api_instance.getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked); print(result); } catch (e) { print('Exception when calling AssetApi->getTimeBucket: $e\n'); @@ -888,6 +889,7 @@ Name | Type | Description | Notes **isFavorite** | **bool**| | [optional] **isTrashed** | **bool**| | [optional] **key** | **String**| | [optional] + **order** | [**AssetOrder**](.md)| | [optional] **personId** | **String**| | [optional] **userId** | **String**| | [optional] **withPartners** | **bool**| | [optional] @@ -909,7 +911,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **getTimeBuckets** -> List getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, personId, userId, withPartners, withStacked) +> List getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked) @@ -938,13 +940,14 @@ final isArchived = true; // bool | final isFavorite = true; // bool | final isTrashed = true; // bool | final key = key_example; // String | +final order = ; // AssetOrder | final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final withPartners = true; // bool | final withStacked = true; // bool | try { - final result = api_instance.getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, personId, userId, withPartners, withStacked); + final result = api_instance.getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked); print(result); } catch (e) { print('Exception when calling AssetApi->getTimeBuckets: $e\n'); @@ -961,6 +964,7 @@ Name | Type | Description | Notes **isFavorite** | **bool**| | [optional] **isTrashed** | **bool**| | [optional] **key** | **String**| | [optional] + **order** | [**AssetOrder**](.md)| | [optional] **personId** | **String**| | [optional] **userId** | **String**| | [optional] **withPartners** | **bool**| | [optional] diff --git a/mobile/openapi/doc/UpdateAlbumDto.md b/mobile/openapi/doc/UpdateAlbumDto.md index 4ded87d1b..89edf1c6e 100644 --- a/mobile/openapi/doc/UpdateAlbumDto.md +++ b/mobile/openapi/doc/UpdateAlbumDto.md @@ -12,6 +12,7 @@ Name | Type | Description | Notes **albumThumbnailAssetId** | **String** | | [optional] **description** | **String** | | [optional] **isActivityEnabled** | **bool** | | [optional] +**order** | [**AssetOrder**](AssetOrder.md) | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 786129b45..b0395bfcb 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -852,6 +852,8 @@ class AssetApi { /// /// * [String] key: /// + /// * [AssetOrder] order: + /// /// * [String] personId: /// /// * [String] userId: @@ -859,7 +861,7 @@ class AssetApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final path = r'/asset/time-bucket'; @@ -885,6 +887,9 @@ class AssetApi { if (key != null) { queryParams.addAll(_queryParams('', 'key', key)); } + if (order != null) { + queryParams.addAll(_queryParams('', 'order', order)); + } if (personId != null) { queryParams.addAll(_queryParams('', 'personId', personId)); } @@ -930,6 +935,8 @@ class AssetApi { /// /// * [String] key: /// + /// * [AssetOrder] order: + /// /// * [String] personId: /// /// * [String] userId: @@ -937,8 +944,8 @@ class AssetApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -970,6 +977,8 @@ class AssetApi { /// /// * [String] key: /// + /// * [AssetOrder] order: + /// /// * [String] personId: /// /// * [String] userId: @@ -977,7 +986,7 @@ class AssetApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final path = r'/asset/time-buckets'; @@ -1003,6 +1012,9 @@ class AssetApi { if (key != null) { queryParams.addAll(_queryParams('', 'key', key)); } + if (order != null) { + queryParams.addAll(_queryParams('', 'order', order)); + } if (personId != null) { queryParams.addAll(_queryParams('', 'personId', personId)); } @@ -1045,6 +1057,8 @@ class AssetApi { /// /// * [String] key: /// + /// * [AssetOrder] order: + /// /// * [String] personId: /// /// * [String] userId: @@ -1052,8 +1066,8 @@ class AssetApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index 43e24f87b..d76402855 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -24,6 +24,7 @@ class AlbumResponseDto { required this.id, required this.isActivityEnabled, this.lastModifiedAssetTimestamp, + this.order, required this.owner, required this.ownerId, required this.shared, @@ -66,6 +67,14 @@ class AlbumResponseDto { /// DateTime? lastModifiedAssetTimestamp; + /// + /// 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. + /// + AssetOrder? order; + UserResponseDto owner; String ownerId; @@ -97,6 +106,7 @@ class AlbumResponseDto { other.id == id && other.isActivityEnabled == isActivityEnabled && other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && + other.order == order && other.owner == owner && other.ownerId == ownerId && other.shared == shared && @@ -118,6 +128,7 @@ class AlbumResponseDto { (id.hashCode) + (isActivityEnabled.hashCode) + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + + (order == null ? 0 : order!.hashCode) + (owner.hashCode) + (ownerId.hashCode) + (shared.hashCode) + @@ -126,7 +137,7 @@ class AlbumResponseDto { (updatedAt.hashCode); @override - String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; + String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -152,6 +163,11 @@ class AlbumResponseDto { json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); } else { // json[r'lastModifiedAssetTimestamp'] = null; + } + if (this.order != null) { + json[r'order'] = this.order; + } else { + // json[r'order'] = null; } json[r'owner'] = this.owner; json[r'ownerId'] = this.ownerId; @@ -185,6 +201,7 @@ class AlbumResponseDto { id: mapValueOfType(json, r'id')!, isActivityEnabled: mapValueOfType(json, r'isActivityEnabled')!, lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''), + order: AssetOrder.fromJson(json[r'order']), owner: UserResponseDto.fromJson(json[r'owner'])!, ownerId: mapValueOfType(json, r'ownerId')!, shared: mapValueOfType(json, r'shared')!, diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index dfe245aaf..d9408cedf 100644 --- a/mobile/openapi/lib/model/update_album_dto.dart +++ b/mobile/openapi/lib/model/update_album_dto.dart @@ -17,6 +17,7 @@ class UpdateAlbumDto { this.albumThumbnailAssetId, this.description, this.isActivityEnabled, + this.order, }); /// @@ -51,12 +52,21 @@ class UpdateAlbumDto { /// bool? isActivityEnabled; + /// + /// 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. + /// + AssetOrder? order; + @override bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto && other.albumName == albumName && other.albumThumbnailAssetId == albumThumbnailAssetId && other.description == description && - other.isActivityEnabled == isActivityEnabled; + other.isActivityEnabled == isActivityEnabled && + other.order == order; @override int get hashCode => @@ -64,10 +74,11 @@ class UpdateAlbumDto { (albumName == null ? 0 : albumName!.hashCode) + (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + (description == null ? 0 : description!.hashCode) + - (isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode); + (isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode) + + (order == null ? 0 : order!.hashCode); @override - String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description, isActivityEnabled=$isActivityEnabled]'; + String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description, isActivityEnabled=$isActivityEnabled, order=$order]'; Map toJson() { final json = {}; @@ -91,6 +102,11 @@ class UpdateAlbumDto { } else { // json[r'isActivityEnabled'] = null; } + if (this.order != null) { + json[r'order'] = this.order; + } else { + // json[r'order'] = null; + } return json; } @@ -106,6 +122,7 @@ class UpdateAlbumDto { albumThumbnailAssetId: mapValueOfType(json, r'albumThumbnailAssetId'), description: mapValueOfType(json, r'description'), isActivityEnabled: mapValueOfType(json, r'isActivityEnabled'), + order: AssetOrder.fromJson(json[r'order']), ); } return null; diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index 933f77c19..5c79e5d2f 100644 --- a/mobile/openapi/test/album_response_dto_test.dart +++ b/mobile/openapi/test/album_response_dto_test.dart @@ -71,6 +71,11 @@ void main() { // TODO }); + // AssetOrder order + test('to test the property `order`', () async { + // TODO + }); + // UserResponseDto owner test('to test the property `owner`', () async { // TODO diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 846a5998c..d210d0e4d 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -95,12 +95,12 @@ void main() { // TODO }); - //Future> getTimeBucket(TimeBucketSize size, String timeBucket, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, String personId, String userId, bool withPartners, bool withStacked }) async + //Future> getTimeBucket(TimeBucketSize size, String timeBucket, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async test('test getTimeBucket', () async { // TODO }); - //Future> getTimeBuckets(TimeBucketSize size, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, String personId, String userId, bool withPartners, bool withStacked }) async + //Future> getTimeBuckets(TimeBucketSize size, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async test('test getTimeBuckets', () async { // TODO }); diff --git a/mobile/openapi/test/update_album_dto_test.dart b/mobile/openapi/test/update_album_dto_test.dart index 67ec80010..7f1591a52 100644 --- a/mobile/openapi/test/update_album_dto_test.dart +++ b/mobile/openapi/test/update_album_dto_test.dart @@ -36,6 +36,11 @@ void main() { // TODO }); + // AssetOrder order + test('to test the property `order`', () async { + // TODO + }); + }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2540baf77..15ada078c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1765,6 +1765,14 @@ "type": "string" } }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetOrder" + } + }, { "name": "personId", "required": false, @@ -1901,6 +1909,14 @@ "type": "string" } }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetOrder" + } + }, { "name": "personId", "required": false, @@ -6722,6 +6738,9 @@ "format": "date-time", "type": "string" }, + "order": { + "$ref": "#/components/schemas/AssetOrder" + }, "owner": { "$ref": "#/components/schemas/UserResponseDto" }, @@ -10335,6 +10354,9 @@ }, "isActivityEnabled": { "type": "boolean" + }, + "order": { + "$ref": "#/components/schemas/AssetOrder" } }, "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index acf540aff..6a660f4e1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -153,6 +153,7 @@ export type AlbumResponseDto = { id: string; isActivityEnabled: boolean; lastModifiedAssetTimestamp?: string; + order?: AssetOrder; owner: UserResponseDto; ownerId: string; shared: boolean; @@ -176,6 +177,7 @@ export type UpdateAlbumDto = { albumThumbnailAssetId?: string; description?: string; isActivityEnabled?: boolean; + order?: AssetOrder; }; export type BulkIdsDto = { ids: string[]; @@ -1453,12 +1455,13 @@ export function getAssetThumbnail({ format, id, key }: { ...opts })); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, personId, size, timeBucket, userId, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; isTrashed?: boolean; key?: string; + order?: AssetOrder; personId?: string; size: TimeBucketSize; timeBucket: string; @@ -1475,6 +1478,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, isFavorite, isTrashed, key, + order, personId, size, timeBucket, @@ -1485,12 +1489,13 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, ...opts })); } -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, personId, size, userId, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; isTrashed?: boolean; key?: string; + order?: AssetOrder; personId?: string; size: TimeBucketSize; userId?: string; @@ -1506,6 +1511,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key isFavorite, isTrashed, key, + order, personId, size, userId, @@ -2747,6 +2753,10 @@ export enum AssetTypeEnum { Audio = "AUDIO", Other = "OTHER" } +export enum AssetOrder { + Asc = "asc", + Desc = "desc" +} export enum Error { Duplicate = "duplicate", NoPermission = "no_permission", @@ -2774,10 +2784,6 @@ export enum TimeBucketSize { Day = "DAY", Month = "MONTH" } -export enum AssetOrder { - Asc = "asc", - Desc = "desc" -} export enum EntityType { Asset = "ASSET", Album = "ALBUM" diff --git a/server/e2e/jobs/specs/library.e2e-spec.ts b/server/e2e/jobs/specs/library.e2e-spec.ts index 6dca783c4..4ebb00c4d 100644 --- a/server/e2e/jobs/specs/library.e2e-spec.ts +++ b/server/e2e/jobs/specs/library.e2e-spec.ts @@ -34,117 +34,7 @@ describe(`${LibraryController.name} (e2e)`, () => { await restoreTempFolder(); }); - describe('DELETE /library/:id', () => { - it('should delete an external library with assets', async () => { - const library = await api.libraryApi.create(server, admin.accessToken, { - type: LibraryType.EXTERNAL, - importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], - }); - await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); - - const assets = await api.assetApi.getAllAssets(server, admin.accessToken); - expect(assets.length).toBeGreaterThan(2); - - const { status } = await request(server) - .delete(`/library/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(204); - - const libraries = await api.libraryApi.getAll(server, admin.accessToken); - expect(libraries).toHaveLength(1); - expect(libraries).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: library.id, - }), - ]), - ); - }); - }); - describe('POST /library/:id/scan', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/scan`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should scan external library with import paths', async () => { - const library = await api.libraryApi.create(server, admin.accessToken, { - type: LibraryType.EXTERNAL, - importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], - }); - await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); - - const assets = await api.assetApi.getAllAssets(server, admin.accessToken); - - expect(assets).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: AssetType.IMAGE, - originalFileName: 'el_torcal_rocks', - libraryId: library.id, - resized: true, - thumbhash: expect.any(String), - exifInfo: expect.objectContaining({ - exifImageWidth: 512, - exifImageHeight: 341, - latitude: null, - longitude: null, - }), - }), - expect.objectContaining({ - type: AssetType.IMAGE, - originalFileName: 'silver_fir', - libraryId: library.id, - resized: true, - thumbhash: expect.any(String), - exifInfo: expect.objectContaining({ - exifImageWidth: 511, - exifImageHeight: 323, - latitude: null, - longitude: null, - }), - }), - ]), - ); - }); - - it('should scan external library with exclusion pattern', async () => { - const library = await api.libraryApi.create(server, admin.accessToken, { - type: LibraryType.EXTERNAL, - importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], - exclusionPatterns: ['**/el_corcal*'], - }); - - await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); - - const assets = await api.assetApi.getAllAssets(server, admin.accessToken); - - expect(assets).toEqual( - expect.arrayContaining([ - expect.not.objectContaining({ - // Excluded by exclusion pattern - originalFileName: 'el_torcal_rocks', - }), - expect.objectContaining({ - type: AssetType.IMAGE, - originalFileName: 'silver_fir', - libraryId: library.id, - resized: true, - exifInfo: expect.objectContaining({ - exifImageWidth: 511, - exifImageHeight: 323, - latitude: null, - longitude: null, - }), - }), - ]), - ); - }); - it('should offline missing files', async () => { await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, { recursive: true, @@ -345,19 +235,6 @@ describe(`${LibraryController.name} (e2e)`, () => { ); }); }); - - it('should not scan an upload library', async () => { - const library = await api.libraryApi.create(server, admin.accessToken, { - type: LibraryType.UPLOAD, - }); - - const { status, body } = await request(server) - .post(`/library/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest('Can only refresh external libraries')); - }); }); describe('POST /library/:id/removeOffline', () => { diff --git a/server/package-lock.json b/server/package-lock.json index deab0b791..eecc75bb2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15,6 +15,7 @@ "@nestjs/common": "^10.2.2", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.2.2", + "@nestjs/event-emitter": "^2.0.4", "@nestjs/platform-express": "^10.2.2", "@nestjs/platform-socket.io": "^10.2.2", "@nestjs/schedule": "^4.0.0", @@ -34,8 +35,8 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", - "exiftool-vendored": "~24.5.0", - "exiftool-vendored.pl": "12.76", + "exiftool-vendored": "~24.6.0", + "exiftool-vendored.pl": "12.78", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", @@ -47,9 +48,9 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "luxon": "^3.4.2", + "mnemonist": "^0.39.8", "nest-commander": "^3.11.1", "nestjs-otel": "^5.1.5", - "node-addon-api": "^7.0.0", "openid-client": "^5.4.3", "pg": "^8.11.3", "picomatch": "^4.0.0", @@ -2640,6 +2641,18 @@ } } }, + "node_modules/@nestjs/event-emitter": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.0.4.tgz", + "integrity": "sha512-quMiw8yOwoSul0pp3mOonGz8EyXWHSBTqBy8B0TbYYgpnG1Ix2wGUnuTksLWaaBiiOTDhciaZ41Y5fJZsSJE1Q==", + "dependencies": { + "eventemitter2": "6.4.9" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", @@ -4016,9 +4029,9 @@ } }, "node_modules/@photostructure/tz-lookup": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.1.tgz", - "integrity": "sha512-inMfhc1QVKheq/PHF0v2vRPnPzTljxscuOKK95o3VlZA4T4w4DeYIpu7dC4W1EyjUhfZJlCBUudpmnFGNCqTog==" + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.2.tgz", + "integrity": "sha512-H8+tTt7ilJNkFyb+QgPnLEGUjQzGwiMb9n7lwRZNBgSKL3VZs9AkjI1E//FcwPjNafwAH932U92+xTqJiF3Bbw==" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -7637,6 +7650,11 @@ "node": ">=6" } }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -7675,34 +7693,34 @@ "dev": true }, "node_modules/exiftool-vendored": { - "version": "24.5.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz", - "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==", + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.6.0.tgz", + "integrity": "sha512-jGjsoeYmR9VUrlZn0j1wcxMVi5y8C7A4FAa4vm3/l7ThT8d0f+jRcBqtdjaf+P5Ds/F4OgUq+ee/fRVhLy2DrA==", "dependencies": { - "@photostructure/tz-lookup": "^9.0.1", + "@photostructure/tz-lookup": "^9.0.2", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", "he": "^1.2.0", "luxon": "^3.4.4" }, "optionalDependencies": { - "exiftool-vendored.exe": "12.76.0", - "exiftool-vendored.pl": "12.76.0" + "exiftool-vendored.exe": "12.78.0", + "exiftool-vendored.pl": "12.78.0" } }, "node_modules/exiftool-vendored.exe": { - "version": "12.76.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz", - "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==", + "version": "12.78.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.78.0.tgz", + "integrity": "sha512-eMN7L67sb89xi8sN7INPg19uwa1KibG2oOyGcfOvB47h+1hzmGgivVu/SZIMeOToVIbLRwUl+AFwLYSTNXsJEg==", "optional": true, "os": [ "win32" ] }, "node_modules/exiftool-vendored.pl": { - "version": "12.76.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz", - "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==", + "version": "12.78.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.78.0.tgz", + "integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg==", "os": [ "!win32" ] @@ -10426,6 +10444,14 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/mock-fs": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", @@ -10670,14 +10696,6 @@ "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" }, - "node_modules/node-addon-api": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz", - "integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==", - "engines": { - "node": "^16 || ^18 || >= 20" - } - }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -10819,6 +10837,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", + "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==" + }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -16196,6 +16219,14 @@ "uid": "2.0.2" } }, + "@nestjs/event-emitter": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.0.4.tgz", + "integrity": "sha512-quMiw8yOwoSul0pp3mOonGz8EyXWHSBTqBy8B0TbYYgpnG1Ix2wGUnuTksLWaaBiiOTDhciaZ41Y5fJZsSJE1Q==", + "requires": { + "eventemitter2": "6.4.9" + } + }, "@nestjs/mapped-types": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", @@ -17060,9 +17091,9 @@ } }, "@photostructure/tz-lookup": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.1.tgz", - "integrity": "sha512-inMfhc1QVKheq/PHF0v2vRPnPzTljxscuOKK95o3VlZA4T4w4DeYIpu7dC4W1EyjUhfZJlCBUudpmnFGNCqTog==" + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.2.tgz", + "integrity": "sha512-H8+tTt7ilJNkFyb+QgPnLEGUjQzGwiMb9n7lwRZNBgSKL3VZs9AkjI1E//FcwPjNafwAH932U92+xTqJiF3Bbw==" }, "@pkgjs/parseargs": { "version": "0.11.0", @@ -19905,6 +19936,11 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" }, + "eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==" + }, "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -19936,15 +19972,15 @@ } }, "exiftool-vendored": { - "version": "24.5.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz", - "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==", + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.6.0.tgz", + "integrity": "sha512-jGjsoeYmR9VUrlZn0j1wcxMVi5y8C7A4FAa4vm3/l7ThT8d0f+jRcBqtdjaf+P5Ds/F4OgUq+ee/fRVhLy2DrA==", "requires": { - "@photostructure/tz-lookup": "^9.0.1", + "@photostructure/tz-lookup": "^9.0.2", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", - "exiftool-vendored.exe": "12.76.0", - "exiftool-vendored.pl": "12.76.0", + "exiftool-vendored.exe": "12.78.0", + "exiftool-vendored.pl": "12.78.0", "he": "^1.2.0", "luxon": "^3.4.4" }, @@ -19957,15 +19993,15 @@ } }, "exiftool-vendored.exe": { - "version": "12.76.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz", - "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==", + "version": "12.78.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.78.0.tgz", + "integrity": "sha512-eMN7L67sb89xi8sN7INPg19uwa1KibG2oOyGcfOvB47h+1hzmGgivVu/SZIMeOToVIbLRwUl+AFwLYSTNXsJEg==", "optional": true }, "exiftool-vendored.pl": { - "version": "12.76.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz", - "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==" + "version": "12.78.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.78.0.tgz", + "integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg==" }, "exit": { "version": "0.1.2", @@ -22037,6 +22073,14 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, + "mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "requires": { + "obliterator": "^2.0.1" + } + }, "mock-fs": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", @@ -22241,11 +22285,6 @@ "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" }, - "node-addon-api": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz", - "integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==" - }, "node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -22349,6 +22388,11 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" }, + "obliterator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", + "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==" + }, "obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", diff --git a/server/package.json b/server/package.json index 0acd97837..c575571d0 100644 --- a/server/package.json +++ b/server/package.json @@ -39,6 +39,7 @@ "@nestjs/common": "^10.2.2", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.2.2", + "@nestjs/event-emitter": "^2.0.4", "@nestjs/platform-express": "^10.2.2", "@nestjs/platform-socket.io": "^10.2.2", "@nestjs/schedule": "^4.0.0", @@ -58,8 +59,8 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", - "exiftool-vendored": "~24.5.0", - "exiftool-vendored.pl": "12.76", + "exiftool-vendored": "~24.6.0", + "exiftool-vendored.pl": "12.78", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", @@ -71,9 +72,9 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "luxon": "^3.4.2", + "mnemonist": "^0.39.8", "nest-commander": "^3.11.1", "nestjs-otel": "^5.1.5", - "node-addon-api": "^7.0.0", "openid-client": "^5.4.3", "pg": "^8.11.3", "picomatch": "^4.0.0", diff --git a/server/src/domain/album/album-response.dto.ts b/server/src/domain/album/album-response.dto.ts index 168b38592..bcca1cd31 100644 --- a/server/src/domain/album/album-response.dto.ts +++ b/server/src/domain/album/album-response.dto.ts @@ -1,4 +1,5 @@ -import { AlbumEntity } from '@app/infra/entities'; +import { AlbumEntity, AssetOrder } from '@app/infra/entities'; +import { Optional } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger'; import { AssetResponseDto, mapAsset } from '../asset'; import { AuthDto } from '../auth/auth.dto'; @@ -23,6 +24,9 @@ export class AlbumResponseDto { startDate?: Date; endDate?: Date; isActivityEnabled!: boolean; + @Optional() + @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) + order?: AssetOrder; } export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { @@ -63,6 +67,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })), assetCount: entity.assets?.length || 0, isActivityEnabled: entity.isActivityEnabled, + order: entity.order, }; }; diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 9a7b940f7..dc3d510d4 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -148,6 +148,7 @@ export class AlbumService { description: dto.description, albumThumbnailAssetId: dto.albumThumbnailAssetId, isActivityEnabled: dto.isActivityEnabled, + order: dto.order, }); return mapAlbumWithoutAssets(updatedAlbum); diff --git a/server/src/domain/album/dto/album-update.dto.ts b/server/src/domain/album/dto/album-update.dto.ts index 1b6c754f0..4f88cefbb 100644 --- a/server/src/domain/album/dto/album-update.dto.ts +++ b/server/src/domain/album/dto/album-update.dto.ts @@ -1,4 +1,6 @@ -import { IsString } from 'class-validator'; +import { AssetOrder } from '@app/infra/entities'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsString } from 'class-validator'; import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util'; export class UpdateAlbumDto { @@ -15,4 +17,9 @@ export class UpdateAlbumDto { @ValidateBoolean({ optional: true }) isActivityEnabled?: boolean; + + @IsEnum(AssetOrder) + @Optional() + @ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' }) + order?: AssetOrder; } diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 0b8dea717..8ba5d93ba 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -113,6 +113,7 @@ const validImages = [ '.sr2', '.srf', '.srw', + '.svg', '.tiff', '.webp', '.x3f', diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index dd5408506..857f1648f 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -22,6 +22,7 @@ import { ISystemConfigRepository, IUserRepository, JobItem, + JobStatus, TimeBucketOptions, } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; @@ -384,7 +385,7 @@ export class AssetService { this.communicationRepository.send(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, ids); } - async handleAssetDeletionCheck() { + async handleAssetDeletionCheck(): Promise { const config = await this.configCore.getConfig(); const trashedDays = config.trash.enabled ? config.trash.days : 0; const trashedBefore = DateTime.now() @@ -400,10 +401,10 @@ export class AssetService { ); } - return true; + return JobStatus.SUCCESS; } - async handleAssetDeletion(job: IAssetDeletionJob) { + async handleAssetDeletion(job: IAssetDeletionJob): Promise { const { id, fromExternal } = job; const asset = await this.assetRepository.getById(id, { @@ -416,12 +417,12 @@ export class AssetService { }); if (!asset) { - return false; + return JobStatus.FAILED; } // Ignore requests that are not from external library job but is for an external asset if (!fromExternal && (!asset.library || asset.library.type === LibraryType.EXTERNAL)) { - return false; + return JobStatus.SKIPPED; } // Replace the parent of the stack children with a new asset @@ -456,7 +457,7 @@ export class AssetService { await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } }); } - return true; + return JobStatus.SUCCESS; } async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise { diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index 8b5c675d8..2abe31d0a 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -18,11 +18,6 @@ export class DeviceIdDto { deviceId!: string; } -export enum AssetOrder { - ASC = 'asc', - DESC = 'desc', -} - const hasGPS = (o: { latitude: undefined; longitude: undefined }) => o.latitude !== undefined || o.longitude !== undefined; const ValidateGPS = () => ValidateIf(hasGPS); diff --git a/server/src/domain/asset/dto/time-bucket.dto.ts b/server/src/domain/asset/dto/time-bucket.dto.ts index 597a5de35..7c5b9c212 100644 --- a/server/src/domain/asset/dto/time-bucket.dto.ts +++ b/server/src/domain/asset/dto/time-bucket.dto.ts @@ -1,6 +1,7 @@ +import { AssetOrder } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { ValidateBoolean, ValidateUUID } from '../../domain.util'; +import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util'; import { TimeBucketSize } from '../../repositories'; export class TimeBucketDto { @@ -32,6 +33,11 @@ export class TimeBucketDto { @ValidateBoolean({ optional: true }) withPartners?: boolean; + + @IsEnum(AssetOrder) + @Optional() + @ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' }) + order?: AssetOrder; } export class TimeBucketAssetDto extends TimeBucketDto { diff --git a/server/src/domain/audit/audit.service.spec.ts b/server/src/domain/audit/audit.service.spec.ts index 861e0edc1..82c6cc699 100644 --- a/server/src/domain/audit/audit.service.spec.ts +++ b/server/src/domain/audit/audit.service.spec.ts @@ -18,6 +18,7 @@ import { IPersonRepository, IStorageRepository, IUserRepository, + JobStatus, } from '../repositories'; import { AuditService } from './audit.service'; @@ -48,8 +49,8 @@ describe(AuditService.name, () => { describe('handleCleanup', () => { it('should delete old audit entries', async () => { - await expect(sut.handleCleanup()).resolves.toBe(true); - expect(auditMock.removeBefore).toBeCalledWith(expect.any(Date)); + await expect(sut.handleCleanup()).resolves.toBe(JobStatus.SUCCESS); + expect(auditMock.removeBefore).toHaveBeenCalledWith(expect.any(Date)); }); }); diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts index a7c003fad..91ebd78ee 100644 --- a/server/src/domain/audit/audit.service.ts +++ b/server/src/domain/audit/audit.service.ts @@ -16,6 +16,7 @@ import { IPersonRepository, IStorageRepository, IUserRepository, + JobStatus, } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; import { @@ -44,9 +45,9 @@ export class AuditService { this.access = AccessCore.create(accessRepository); } - async handleCleanup(): Promise { + async handleCleanup(): Promise { await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); - return true; + return JobStatus.SUCCESS; } async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise { diff --git a/server/src/domain/domain.constant.spec.ts b/server/src/domain/domain.constant.spec.ts index 154128a1c..70944328e 100644 --- a/server/src/domain/domain.constant.spec.ts +++ b/server/src/domain/domain.constant.spec.ts @@ -41,6 +41,7 @@ describe('mimeTypes', () => { { mimetype: 'image/sr2', extension: '.sr2' }, { mimetype: 'image/srf', extension: '.srf' }, { mimetype: 'image/srw', extension: '.srw' }, + { mimetype: 'image/svg', extension: '.svg' }, { mimetype: 'image/tiff', extension: '.tif' }, { mimetype: 'image/tiff', extension: '.tiff' }, { mimetype: 'image/webp', extension: '.webp' }, diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index b723474dd..56b455855 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -133,13 +133,14 @@ const image: Record = { '.sr2': ['image/sr2', 'image/x-sony-sr2'], '.srf': ['image/srf', 'image/x-sony-srf'], '.srw': ['image/srw', 'image/x-samsung-srw'], + '.svg': ['image/svg'], '.tif': ['image/tiff'], '.tiff': ['image/tiff'], '.webp': ['image/webp'], '.x3f': ['image/x3f', 'image/x-sigma-x3f'], }; -const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.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)), ); diff --git a/server/src/domain/domain.module.ts b/server/src/domain/domain.module.ts index 37faa09c9..c3e62edb5 100644 --- a/server/src/domain/domain.module.ts +++ b/server/src/domain/domain.module.ts @@ -26,9 +26,9 @@ import { TrashService } from './trash'; import { UserService } from './user'; const providers: Provider[] = [ + APIKeyService, ActivityService, AlbumService, - APIKeyService, AssetService, AuditService, AuthService, @@ -39,8 +39,8 @@ const providers: Provider[] = [ LibraryService, MediaService, MetadataService, - PersonService, PartnerService, + PersonService, SearchService, ServerInfoService, SharedLinkService, diff --git a/server/src/domain/download/download.service.spec.ts b/server/src/domain/download/download.service.spec.ts index fb9ae9567..09161d8f6 100644 --- a/server/src/domain/download/download.service.spec.ts +++ b/server/src/domain/download/download.service.spec.ts @@ -164,7 +164,7 @@ describe(DownloadService.name, () => { const assetIds = ['asset-1', 'asset-2']; await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse); - expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2']); + expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2'], { exifInfo: true }); }); it('should return a list of archives (albumId)', async () => { @@ -228,10 +228,10 @@ describe(DownloadService.name, () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); when(assetMock.getByIds) - .calledWith([assetStub.livePhotoStillAsset.id]) + .calledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }) .mockResolvedValue([assetStub.livePhotoStillAsset]); when(assetMock.getByIds) - .calledWith([assetStub.livePhotoMotionAsset.id]) + .calledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }) .mockResolvedValue([assetStub.livePhotoMotionAsset]); await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ diff --git a/server/src/domain/download/download.service.ts b/server/src/domain/download/download.service.ts index fcad2b6e7..1b4a19185 100644 --- a/server/src/domain/download/download.service.ts +++ b/server/src/domain/download/download.service.ts @@ -50,7 +50,7 @@ export class DownloadService { // motion part of live photos const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); if (motionIds.length > 0) { - assets.push(...(await this.assetRepository.getByIds(motionIds))); + assets.push(...(await this.assetRepository.getByIds(motionIds, { exifInfo: true }))); } for (const asset of assets) { @@ -114,7 +114,7 @@ export class DownloadService { if (dto.assetIds) { const assetIds = dto.assetIds; await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, assetIds); - const assets = await this.assetRepository.getByIds(assetIds); + const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true }); return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets })); } diff --git a/server/src/domain/job/job.service.spec.ts b/server/src/domain/job/job.service.spec.ts index 9fe38a2ff..c2133a623 100644 --- a/server/src/domain/job/job.service.spec.ts +++ b/server/src/domain/job/job.service.spec.ts @@ -16,13 +16,14 @@ import { ISystemConfigRepository, JobHandler, JobItem, + JobStatus, } from '../repositories'; import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core'; import { JobCommand, JobName, QueueName } from './job.constants'; import { JobService } from './job.service'; -const makeMockHandlers = (success: boolean) => { - const mock = jest.fn().mockResolvedValue(success); +const makeMockHandlers = (status: JobStatus) => { + const mock = jest.fn().mockResolvedValue(status); return Object.fromEntries(Object.values(JobName).map((jobName) => [jobName, mock])) as unknown as Record< JobName, JobHandler @@ -221,13 +222,13 @@ describe(JobService.name, () => { describe('init', () => { it('should register a handler for each queue', async () => { - await sut.init(makeMockHandlers(true)); + await sut.init(makeMockHandlers(JobStatus.SUCCESS)); expect(configMock.load).toHaveBeenCalled(); expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); }); it('should subscribe to config changes', async () => { - await sut.init(makeMockHandlers(false)); + await sut.init(makeMockHandlers(JobStatus.FAILED)); SystemConfigCore.create(newSystemConfigRepositoryMock(false)).config$.next({ job: { @@ -330,11 +331,9 @@ describe(JobService.name, () => { } else { assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); } - } else { - assetMock.getByIds.mockResolvedValue([]); } - await sut.init(makeMockHandlers(true)); + await sut.init(makeMockHandlers(JobStatus.SUCCESS)); await jobMock.addHandler.mock.calls[0][2](item); if (jobs.length > 1) { @@ -350,7 +349,7 @@ describe(JobService.name, () => { }); it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => { - await sut.init(makeMockHandlers(false)); + await sut.init(makeMockHandlers(JobStatus.FAILED)); await jobMock.addHandler.mock.calls[0][2](item); expect(jobMock.queueAll).not.toHaveBeenCalled(); diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index 5d5333f3a..e00636ad6 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -11,6 +11,7 @@ import { ISystemConfigRepository, JobHandler, JobItem, + JobStatus, QueueCleanType, } from '../repositories'; import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core'; @@ -155,8 +156,8 @@ export class JobService { try { const handler = jobHandlers[name]; - const success = await handler(data); - if (success) { + const status = await handler(data); + if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) { await this.onDone(item); } } catch (error: Error | any) { @@ -214,7 +215,7 @@ export class JobService { case JobName.METADATA_EXTRACTION: { if (item.data.source === 'sidecar-write') { - const [asset] = await this.assetRepository.getByIds([item.data.id]); + const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); if (asset) { this.communicationRepository.send(ClientEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset)); } @@ -272,7 +273,7 @@ export class JobService { break; } - const [asset] = await this.assetRepository.getByIds([item.data.id]); + const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); // Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients if (asset && asset.isVisible) { diff --git a/server/src/domain/library/library.dto.ts b/server/src/domain/library/library.dto.ts index b11bc9998..5e4bb4ec6 100644 --- a/server/src/domain/library/library.dto.ts +++ b/server/src/domain/library/library.dto.ts @@ -89,7 +89,7 @@ export class ValidateLibraryResponseDto { export class ValidateLibraryImportPathResponseDto { importPath!: string; - isValid?: boolean = false; + isValid: boolean = false; message?: string; } diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index 03042cf55..57bdf7373 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -18,6 +18,7 @@ import { userStub, } from '@test'; import { when } from 'jest-when'; +import { R_OK } from 'node:constants'; import { Stats } from 'node:fs'; import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job'; import { @@ -28,6 +29,7 @@ import { ILibraryRepository, IStorageRepository, ISystemConfigRepository, + JobStatus, StorageEventType, } from '../repositories'; import { SystemConfigCore } from '../system-config/system-config.core'; @@ -146,6 +148,26 @@ describe(LibraryService.name, () => { }); }); + describe('validateConfig', () => { + it('should allow a valid cron expression', () => { + expect(() => + sut.validateConfig({ + newConfig: { library: { scan: { cronExpression: '0 0 * * *' } } } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).not.toThrow(expect.stringContaining('Invalid cron expression')); + }); + + it('should fail for an invalid cron expression', () => { + expect(() => + sut.validateConfig({ + newConfig: { library: { scan: { cronExpression: 'foo' } } } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).toThrow(/Invalid cron expression.*/); + }); + }); + describe('handleQueueAssetRefresh', () => { it('should queue new assets', async () => { const mockLibraryJob: ILibraryRefreshJob = { @@ -155,7 +177,10 @@ describe(LibraryService.name, () => { }; libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); + // eslint-disable-next-line @typescript-eslint/require-await + storageMock.walk.mockImplementation(async function* generator() { + yield '/data/user1/photo.jpg'; + }); assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -181,7 +206,10 @@ describe(LibraryService.name, () => { }; libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); + // eslint-disable-next-line @typescript-eslint/require-await + storageMock.walk.mockImplementation(async function* generator() { + yield '/data/user1/photo.jpg'; + }); assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -208,7 +236,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false); + await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); }); it('should ignore import paths that do not exist', async () => { @@ -231,12 +259,11 @@ describe(LibraryService.name, () => { }; libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - storageMock.crawl.mockResolvedValue([]); assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); await sut.handleQueueAssetRefresh(mockLibraryJob); - expect(storageMock.crawl).toHaveBeenCalledWith({ + expect(storageMock.walk).toHaveBeenCalledWith({ pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], exclusionPatterns: [], }); @@ -250,7 +277,6 @@ describe(LibraryService.name, () => { }; libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.crawl.mockResolvedValue([]); assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, @@ -271,7 +297,10 @@ describe(LibraryService.name, () => { }; libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.crawl.mockResolvedValue([assetStub.offline.originalPath]); + // eslint-disable-next-line @typescript-eslint/require-await + storageMock.walk.mockImplementation(async function* generator() { + yield assetStub.offline.originalPath; + }); assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [assetStub.offline], hasNextPage: false, @@ -333,7 +362,7 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -381,7 +410,7 @@ describe(LibraryService.name, () => { assetMock.create.mockResolvedValue(assetStub.image); storageMock.checkFileExists.mockResolvedValue(true); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -428,7 +457,7 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.video); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -484,7 +513,7 @@ describe(LibraryService.name, () => { assetMock.create.mockResolvedValue(assetStub.image); libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(false); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); expect(assetMock.create.mock.calls).toEqual([]); }); @@ -505,7 +534,7 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); @@ -522,7 +551,7 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.METADATA_EXTRACTION, @@ -553,7 +582,7 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true }); expect(jobMock.queue).not.toHaveBeenCalled(); @@ -571,7 +600,7 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline); assetMock.create.mockResolvedValue(assetStub.offline); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false }); @@ -604,7 +633,7 @@ describe(LibraryService.name, () => { expect(assetMock.save).not.toHaveBeenCalled(); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); }); it('should refresh an existing asset if forced', async () => { @@ -618,7 +647,7 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { fileCreatedAt: new Date('2023-01-01'), @@ -646,7 +675,7 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create).toHaveBeenCalled(); const createdAsset = assetMock.create.mock.calls[0][0]; @@ -1069,7 +1098,7 @@ describe(LibraryService.name, () => { describe('handleQueueCleanup', () => { it('should queue cleanup jobs', async () => { libraryMock.getAllDeleted.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]); - await expect(sut.handleQueueCleanup()).resolves.toBe(true); + await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.uploadLibrary1.id } }, @@ -1356,7 +1385,7 @@ describe(LibraryService.name, () => { libraryMock.getAssetIds.mockResolvedValue([]); libraryMock.delete.mockImplementation(async () => {}); - await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(false); + await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.FAILED); }); it('should delete an empty library', async () => { @@ -1364,7 +1393,7 @@ describe(LibraryService.name, () => { libraryMock.getAssetIds.mockResolvedValue([]); libraryMock.delete.mockImplementation(async () => {}); - await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(true); + await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); }); it('should delete a library with assets', async () => { @@ -1374,7 +1403,7 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(assetStub.image1); - await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(true); + await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); }); }); @@ -1468,7 +1497,7 @@ describe(LibraryService.name, () => { it('should queue the refresh job', async () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.handleQueueAllScan({})).resolves.toBe(true); + await expect(sut.handleQueueAllScan({})).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1493,7 +1522,7 @@ describe(LibraryService.name, () => { it('should queue the force refresh job', async () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(true); + await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_QUEUE_CLEANUP, @@ -1518,7 +1547,7 @@ describe(LibraryService.name, () => { assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); assetMock.getById.mockResolvedValue(assetStub.image1); - await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(true); + await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ { @@ -1624,5 +1653,32 @@ describe(LibraryService.name, () => { }, ]); }); + + it('should detect when import path is in immich media folder', async () => { + storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats); + const validImport = libraryStub.hasImmichPaths.importPaths[1]; + when(storageMock.checkFileExists).calledWith(validImport, R_OK).mockResolvedValue(true); + + const result = await sut.validate(authStub.external1, libraryStub.hasImmichPaths.id, { + importPaths: libraryStub.hasImmichPaths.importPaths, + }); + + expect(result.importPaths).toEqual([ + { + importPath: libraryStub.hasImmichPaths.importPaths[0], + isValid: false, + message: 'Cannot use media upload folder for external libraries', + }, + { + importPath: validImport, + isValid: true, + }, + { + importPath: libraryStub.hasImmichPaths.importPaths[2], + isValid: false, + message: 'Cannot use media upload folder for external libraries', + }, + ]); + }); }); }); diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 25894c9b5..12d135fa3 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -1,6 +1,8 @@ -import { AssetType, LibraryType } from '@app/infra/entities'; +import { AssetType, LibraryEntity, LibraryType } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Trie } from 'mnemonist'; import { R_OK } from 'node:constants'; import { EventEmitter } from 'node:events'; import { Stats } from 'node:fs'; @@ -11,7 +13,6 @@ import { AuthDto } from '../auth'; import { mimeTypes } from '../domain.constant'; import { handlePromiseError, usePagination, validateCronExpression } from '../domain.util'; import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; - import { DatabaseLock, IAccessRepository, @@ -22,9 +23,13 @@ import { ILibraryRepository, IStorageRepository, ISystemConfigRepository, + InternalEvent, + InternalEventMap, + JobStatus, StorageEventType, WithProperty, } from '../repositories'; +import { StorageCore } from '../storage'; import { SystemConfigCore } from '../system-config'; import { CreateLibraryDto, @@ -39,6 +44,8 @@ import { mapLibrary, } from './library.dto'; +const LIBRARY_SCAN_BATCH_SIZE = 5000; + @Injectable() export class LibraryService extends EventEmitter { readonly logger = new ImmichLogger(LibraryService.name); @@ -61,12 +68,6 @@ export class LibraryService extends EventEmitter { super(); this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); - this.configCore.addValidator((config) => { - const { scan } = config.library; - if (!validateCronExpression(scan.cronExpression)) { - throw new Error(`Invalid cron expression ${scan.cronExpression}`); - } - }); } async init() { @@ -106,6 +107,14 @@ export class LibraryService extends EventEmitter { }); } + @OnEvent(InternalEvent.VALIDATE_CONFIG) + validateConfig({ newConfig }: InternalEventMap[InternalEvent.VALIDATE_CONFIG]) { + const { scan } = newConfig.library; + if (!validateCronExpression(scan.cronExpression)) { + throw new Error(`Invalid cron expression ${scan.cronExpression}`); + } + } + private async watch(id: string): Promise { if (!this.watchLibraries) { return false; @@ -239,13 +248,13 @@ export class LibraryService extends EventEmitter { return libraries.map((library) => mapLibrary(library)); } - async handleQueueCleanup(): Promise { + async handleQueueCleanup(): Promise { this.logger.debug('Cleaning up any pending library deletions'); const pendingDeletion = await this.repository.getAllDeleted(); await this.jobRepository.queueAll( pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), ); - return true; + return JobStatus.SUCCESS; } async create(auth: AuthDto, dto: CreateLibraryDto): Promise { @@ -298,26 +307,39 @@ export class LibraryService extends EventEmitter { } private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) { - await this.jobRepository.queueAll( - assetPaths.map((assetPath) => ({ - name: JobName.LIBRARY_SCAN_ASSET, - data: { - id: libraryId, - assetPath: path.normalize(assetPath), - ownerId, - force, - }, - })), - ); + this.logger.verbose(`Queuing refresh of ${assetPaths.length} asset(s)`); + + // We perform this in batches to save on memory when performing large refreshes (greater than 1M assets) + const batchSize = 5000; + for (let i = 0; i < assetPaths.length; i += batchSize) { + const batch = assetPaths.slice(i, i + batchSize); + await this.jobRepository.queueAll( + batch.map((assetPath) => ({ + name: JobName.LIBRARY_SCAN_ASSET, + data: { + id: libraryId, + assetPath: assetPath, + ownerId, + force, + }, + })), + ); + } + + this.logger.debug('Asset refresh queue completed'); } private async validateImportPath(importPath: string): Promise { const validation = new ValidateLibraryImportPathResponseDto(); validation.importPath = importPath; + if (StorageCore.isImmichPath(importPath)) { + validation.message = 'Cannot use media upload folder for external libraries'; + return validation; + } + try { const stat = await this.storageRepository.stat(importPath); - if (!stat.isDirectory()) { validation.message = 'Not a directory'; return validation; @@ -399,10 +421,10 @@ export class LibraryService extends EventEmitter { await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } }); } - async handleDeleteLibrary(job: IEntityJob): Promise { + async handleDeleteLibrary(job: IEntityJob): Promise { const library = await this.repository.get(job.id, true); if (!library) { - return false; + return JobStatus.FAILED; } // TODO use pagination @@ -416,10 +438,10 @@ export class LibraryService extends EventEmitter { this.logger.log(`Deleting library ${job.id}`); await this.repository.delete(job.id); } - return true; + return JobStatus.SUCCESS; } - async handleAssetRefresh(job: ILibraryFileJob) { + async handleAssetRefresh(job: ILibraryFileJob): Promise { const assetPath = path.normalize(job.assetPath); const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); @@ -434,7 +456,7 @@ export class LibraryService extends EventEmitter { this.logger.debug(`Marking asset as offline: ${assetPath}`); await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: true }); - return true; + return JobStatus.SUCCESS; } else { // File can't be accessed and does not already exist in db throw new BadRequestException('Cannot access file', { cause: error }); @@ -472,7 +494,7 @@ export class LibraryService extends EventEmitter { if (!doImport && !doRefresh) { // If we don't import, exit here - return true; + return JobStatus.SKIPPED; } let assetType: AssetType; @@ -498,7 +520,7 @@ export class LibraryService extends EventEmitter { const library = await this.repository.get(job.id, true); if (library?.deletedAt) { this.logger.error('Cannot import asset into deleted library'); - return false; + return JobStatus.FAILED; } const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); @@ -529,7 +551,7 @@ export class LibraryService extends EventEmitter { }); } else { // Not importing and not refreshing, do nothing - return true; + return JobStatus.SKIPPED; } this.logger.debug(`Queuing metadata extraction for: ${assetPath}`); @@ -540,7 +562,7 @@ export class LibraryService extends EventEmitter { await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: assetId } }); } - return true; + return JobStatus.SUCCESS; } async queueScan(auth: AuthDto, id: string, dto: ScanLibraryDto) { @@ -573,7 +595,7 @@ export class LibraryService extends EventEmitter { }); } - async handleQueueAllScan(job: IBaseJob): Promise { + async handleQueueAllScan(job: IBaseJob): Promise { this.logger.debug(`Refreshing all external libraries: force=${job.force}`); // Queue cleanup @@ -591,10 +613,10 @@ export class LibraryService extends EventEmitter { }, })), ); - return true; + return JobStatus.SUCCESS; } - async handleOfflineRemoval(job: IEntityJob): Promise { + async handleOfflineRemoval(job: IEntityJob): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id), ); @@ -606,26 +628,87 @@ export class LibraryService extends EventEmitter { ); } - return true; + return JobStatus.SUCCESS; } - // Check if a given path is in a user's external path. Both arguments are assumed to be normalized - private isInExternalPath(filePath: string, externalPath: string | null): boolean { - if (externalPath === null) { - return false; - } - return filePath.startsWith(externalPath); - } - - async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { + async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { const library = await this.repository.get(job.id); if (!library || library.type !== LibraryType.EXTERNAL) { this.logger.warn('Can only refresh external libraries'); - return false; + return JobStatus.FAILED; } - this.logger.verbose(`Refreshing library: ${job.id}`); + this.logger.log(`Refreshing library: ${job.id}`); + const crawledAssetPaths = await this.getPathTrie(library); + this.logger.debug(`Found ${crawledAssetPaths.size} asset(s) when crawling import paths ${library.importPaths}`); + + const assetIdsToMarkOffline = []; + const assetIdsToMarkOnline = []; + const pagination = usePagination(LIBRARY_SCAN_BATCH_SIZE, (pagination) => + this.assetRepository.getLibraryAssetPaths(pagination, library.id), + ); + + this.logger.verbose(`Crawled asset paths paginated`); + + const shouldScanAll = job.refreshAllFiles || job.refreshModifiedFiles; + for await (const page of pagination) { + for (const asset of page) { + const isOffline = !crawledAssetPaths.has(asset.originalPath); + if (isOffline && !asset.isOffline) { + assetIdsToMarkOffline.push(asset.id); + this.logger.verbose(`Added to mark-offline list: ${asset.originalPath}`); + } + + if (!isOffline && asset.isOffline) { + assetIdsToMarkOnline.push(asset.id); + this.logger.verbose(`Added to mark-online list: ${asset.originalPath}`); + } + + if (!shouldScanAll) { + crawledAssetPaths.delete(asset.originalPath); + } + } + } + + this.logger.verbose(`Crawled assets have been checked for online/offline status`); + + if (assetIdsToMarkOffline.length > 0) { + this.logger.debug(`Found ${assetIdsToMarkOffline.length} offline asset(s) previously marked as online`); + await this.assetRepository.updateAll(assetIdsToMarkOffline, { isOffline: true }); + } + + if (assetIdsToMarkOnline.length > 0) { + this.logger.debug(`Found ${assetIdsToMarkOnline.length} online asset(s) previously marked as offline`); + await this.assetRepository.updateAll(assetIdsToMarkOnline, { isOffline: false }); + } + + if (crawledAssetPaths.size > 0) { + if (!shouldScanAll) { + this.logger.debug(`Will import ${crawledAssetPaths.size} new asset(s)`); + } + + let batch = []; + for (const assetPath of crawledAssetPaths) { + batch.push(assetPath); + + if (batch.length >= LIBRARY_SCAN_BATCH_SIZE) { + await this.scanAssets(job.id, batch, library.ownerId, job.refreshAllFiles ?? false); + batch = []; + } + } + + if (batch.length > 0) { + await this.scanAssets(job.id, batch, library.ownerId, job.refreshAllFiles ?? false); + } + } + + await this.repository.update({ id: job.id, refreshedAt: new Date() }); + + return JobStatus.SUCCESS; + } + + private async getPathTrie(library: LibraryEntity): Promise> { const pathValidation = await Promise.all( library.importPaths.map(async (importPath) => await this.validateImportPath(importPath)), ); @@ -640,61 +723,17 @@ export class LibraryService extends EventEmitter { .filter((validation) => validation.isValid) .map((validation) => validation.importPath); - let rawPaths = await this.storageRepository.crawl({ + const generator = this.storageRepository.walk({ pathsToCrawl: validImportPaths, exclusionPatterns: library.exclusionPatterns, }); - const crawledAssetPaths = new Set(rawPaths); - const shouldScanAll = job.refreshAllFiles || job.refreshModifiedFiles; - let pathsToScan: string[] = shouldScanAll ? rawPaths : []; - rawPaths = []; - - this.logger.debug(`Found ${crawledAssetPaths.size} asset(s) when crawling import paths ${library.importPaths}`); - - const assetIdsToMarkOffline = []; - const assetIdsToMarkOnline = []; - const pagination = usePagination(5000, (pagination) => - this.assetRepository.getLibraryAssetPaths(pagination, library.id), - ); - - for await (const page of pagination) { - for (const asset of page) { - const isOffline = !crawledAssetPaths.has(asset.originalPath); - if (isOffline && !asset.isOffline) { - assetIdsToMarkOffline.push(asset.id); - } - - if (!isOffline && asset.isOffline) { - assetIdsToMarkOnline.push(asset.id); - } - - crawledAssetPaths.delete(asset.originalPath); - } + const trie = new Trie(); + for await (const filePath of generator) { + trie.add(filePath); } - if (assetIdsToMarkOffline.length > 0) { - this.logger.debug(`Found ${assetIdsToMarkOffline.length} offline asset(s) previously marked as online`); - await this.assetRepository.updateAll(assetIdsToMarkOffline, { isOffline: true }); - } - - if (assetIdsToMarkOnline.length > 0) { - this.logger.debug(`Found ${assetIdsToMarkOnline.length} online asset(s) previously marked as offline`); - await this.assetRepository.updateAll(assetIdsToMarkOnline, { isOffline: false }); - } - - if (!shouldScanAll) { - pathsToScan = [...crawledAssetPaths]; - this.logger.debug(`Will import ${pathsToScan.length} new asset(s)`); - } - - if (pathsToScan.length > 0) { - await this.scanAssets(job.id, pathsToScan, library.ownerId, job.refreshAllFiles ?? false); - } - - await this.repository.update({ id: job.id, refreshedAt: new Date() }); - - return true; + return trie; } private async findOrFail(id: string) { diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 8a6eae4cc..beea126bf 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -34,6 +34,7 @@ import { IPersonRepository, IStorageRepository, ISystemConfigRepository, + JobStatus, WithoutProperty, } from '../repositories'; import { MediaService } from './media.service'; @@ -1214,22 +1215,22 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).not.toHaveBeenCalled(); }); - it('should return false if hwaccel is enabled for an unsupported codec', async () => { + it('should fail if hwaccel is enabled for an unsupported codec', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([ { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, ]); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false); + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); }); - it('should return false if hwaccel option is invalid', async () => { + it('should fail if hwaccel option is invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: 'invalid' }]); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false); + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); }); @@ -1548,12 +1549,12 @@ describe(MediaService.name, () => { ); }); - it('should return false for qsv if no hw devices', async () => { + it('should fail for qsv if no hw devices', async () => { storageMock.readdir.mockResolvedValue([]); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }]); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false); + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); }); @@ -1777,12 +1778,12 @@ describe(MediaService.name, () => { ); }); - it('should return false for vaapi if no hw devices', async () => { + it('should fail for vaapi if no hw devices', async () => { storageMock.readdir.mockResolvedValue([]); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false); + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); }); diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 5c8e777ad..9d522d104 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -24,6 +24,7 @@ import { IStorageRepository, ISystemConfigRepository, JobItem, + JobStatus, VideoCodecHWConfig, VideoStreamInfo, WithoutProperty, @@ -70,7 +71,7 @@ export class MediaService { ); } - async handleQueueGenerateThumbnails({ force }: IBaseJob) { + async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force ? this.assetRepository.getAll(pagination) @@ -118,10 +119,10 @@ export class MediaService { await this.jobRepository.queueAll(jobs); - return true; + return JobStatus.SUCCESS; } - async handleQueueMigration() { + async handleQueueMigration(): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => this.assetRepository.getAll(pagination), ); @@ -148,31 +149,31 @@ export class MediaService { ); } - return true; + return JobStatus.SUCCESS; } - async handleAssetMigration({ id }: IEntityJob) { + async handleAssetMigration({ id }: IEntityJob): Promise { const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { - return false; + return JobStatus.FAILED; } await this.storageCore.moveAssetFile(asset, AssetPathType.JPEG_THUMBNAIL); await this.storageCore.moveAssetFile(asset, AssetPathType.WEBP_THUMBNAIL); await this.storageCore.moveAssetFile(asset, AssetPathType.ENCODED_VIDEO); - return true; + return JobStatus.SUCCESS; } - async handleGenerateJpegThumbnail({ id }: IEntityJob) { - const [asset] = await this.assetRepository.getByIds([id]); + async handleGenerateJpegThumbnail({ id }: IEntityJob): Promise { + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); if (!asset) { - return false; + return JobStatus.FAILED; } const resizePath = await this.generateThumbnail(asset, 'jpeg'); await this.assetRepository.save({ id: asset.id, resizePath }); - return true; + return JobStatus.SUCCESS; } private async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') { @@ -214,30 +215,30 @@ export class MediaService { return path; } - async handleGenerateWebpThumbnail({ id }: IEntityJob) { - const [asset] = await this.assetRepository.getByIds([id]); + async handleGenerateWebpThumbnail({ id }: IEntityJob): Promise { + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); if (!asset) { - return false; + return JobStatus.FAILED; } const webpPath = await this.generateThumbnail(asset, 'webp'); await this.assetRepository.save({ id: asset.id, webpPath }); - return true; + return JobStatus.SUCCESS; } - async handleGenerateThumbhashThumbnail({ id }: IEntityJob): Promise { + async handleGenerateThumbhashThumbnail({ id }: IEntityJob): Promise { const [asset] = await this.assetRepository.getByIds([id]); if (!asset?.resizePath) { - return false; + return JobStatus.FAILED; } const thumbhash = await this.mediaRepository.generateThumbhash(asset.resizePath); await this.assetRepository.save({ id: asset.id, thumbhash }); - return true; + return JobStatus.SUCCESS; } - async handleQueueVideoConversion(job: IBaseJob) { + async handleQueueVideoConversion(job: IBaseJob): Promise { const { force } = job; const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { @@ -252,13 +253,13 @@ export class MediaService { ); } - return true; + return JobStatus.SUCCESS; } - async handleVideoConversion({ id }: IEntityJob) { + async handleVideoConversion({ id }: IEntityJob): Promise { const [asset] = await this.assetRepository.getByIds([id]); if (!asset || asset.type !== AssetType.VIDEO) { - return false; + return JobStatus.FAILED; } const input = asset.originalPath; @@ -270,12 +271,12 @@ export class MediaService { const mainAudioStream = this.getMainStream(audioStreams); const containerExtension = format.formatName; if (!mainVideoStream || !containerExtension) { - return false; + return JobStatus.FAILED; } if (!mainVideoStream.height || !mainVideoStream.width) { this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`); - return false; + return JobStatus.FAILED; } const { ffmpeg: config } = await this.configCore.getConfig(); @@ -288,7 +289,7 @@ export class MediaService { await this.assetRepository.save({ id: asset.id, encodedVideoPath: null }); } - return true; + return JobStatus.SKIPPED; } let transcodeOptions; @@ -298,7 +299,7 @@ export class MediaService { ); } catch (error) { this.logger.error(`An error occurred while configuring transcoding options: ${error}`); - return false; + return JobStatus.FAILED; } this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`); @@ -322,7 +323,7 @@ export class MediaService { await this.assetRepository.save({ id: asset.id, encodedVideoPath: output }); - return true; + return JobStatus.SUCCESS; } private getMainStream(streams: T[]): T { diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index 36315cf72..c28c61f22 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -37,6 +37,7 @@ import { IStorageRepository, ISystemConfigRepository, ImmichTags, + JobStatus, WithoutProperty, } from '../repositories'; import { MetadataService, Orientation } from './metadata.service'; @@ -113,8 +114,8 @@ describe(MetadataService.name, () => { describe('handleLivePhotoLinking', () => { it('should handle an asset that could not be found', async () => { - await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(false); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled(); expect(albumMock.removeAsset).not.toHaveBeenCalled(); @@ -123,8 +124,8 @@ describe(MetadataService.name, () => { it('should handle an asset without exif info', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]); - await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(false); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled(); expect(albumMock.removeAsset).not.toHaveBeenCalled(); @@ -133,8 +134,8 @@ describe(MetadataService.name, () => { it('should handle livePhotoCID not set', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); - await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(true); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled(); expect(albumMock.removeAsset).not.toHaveBeenCalled(); @@ -148,8 +149,10 @@ describe(MetadataService.name, () => { }, ]); - await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(true); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); + await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe( + JobStatus.SKIPPED, + ); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ livePhotoCID: assetStub.livePhotoStillAsset.id, ownerId: assetStub.livePhotoMotionAsset.ownerId, @@ -169,8 +172,10 @@ describe(MetadataService.name, () => { ]); assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); - await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); + await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( + JobStatus.SUCCESS, + ); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ livePhotoCID: assetStub.livePhotoMotionAsset.id, ownerId: assetStub.livePhotoStillAsset.ownerId, @@ -194,7 +199,9 @@ describe(MetadataService.name, () => { ]); assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); - await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true); + await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( + JobStatus.SUCCESS, + ); expect(communicationMock.send).toHaveBeenCalledWith( ClientEvent.ASSET_HIDDEN, assetStub.livePhotoMotionAsset.ownerId, @@ -207,7 +214,7 @@ describe(MetadataService.name, () => { it('should queue metadata extraction for all assets without exif values', async () => { assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); - await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(true); + await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.getWithout).toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { @@ -220,7 +227,7 @@ describe(MetadataService.name, () => { it('should queue metadata extraction for all assets', async () => { assetMock.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); - await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(true); + await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.getAll).toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { @@ -237,7 +244,7 @@ describe(MetadataService.name, () => { }); it('should handle an asset that could not be found', async () => { - await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(false); + await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); expect(assetMock.upsertExif).not.toHaveBeenCalled(); @@ -630,19 +637,13 @@ describe(MetadataService.name, () => { describe('handleSidecarSync', () => { it('should do nothing if asset could not be found', async () => { assetMock.getByIds.mockResolvedValue([]); - await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(false); + await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); expect(assetMock.save).not.toHaveBeenCalled(); }); it('should do nothing if asset has no sidecar path', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(false); - expect(assetMock.save).not.toHaveBeenCalled(); - }); - - it('should do nothing if asset has no sidecar path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(false); + await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); expect(assetMock.save).not.toHaveBeenCalled(); }); @@ -650,7 +651,7 @@ describe(MetadataService.name, () => { assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); storageMock.checkFileExists.mockResolvedValue(true); - await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(true); + await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.sidecar.id, @@ -663,7 +664,7 @@ describe(MetadataService.name, () => { storageMock.checkFileExists.mockResolvedValueOnce(false); storageMock.checkFileExists.mockResolvedValueOnce(true); - await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(true); + await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.SUCCESS); expect(storageMock.checkFileExists).toHaveBeenNthCalledWith( 2, assetStub.sidecarWithoutExt.sidecarPath, @@ -680,7 +681,7 @@ describe(MetadataService.name, () => { storageMock.checkFileExists.mockResolvedValueOnce(true); storageMock.checkFileExists.mockResolvedValueOnce(true); - await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(true); + await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK); expect(storageMock.checkFileExists).toHaveBeenNthCalledWith( 2, @@ -697,7 +698,7 @@ describe(MetadataService.name, () => { assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); storageMock.checkFileExists.mockResolvedValue(false); - await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(true); + await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.sidecar.id, @@ -754,13 +755,13 @@ describe(MetadataService.name, () => { describe('handleSidecarWrite', () => { it('should skip assets that do not exist anymore', async () => { assetMock.getByIds.mockResolvedValue([]); - await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(false); + await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED); expect(metadataMock.writeTags).not.toHaveBeenCalled(); }); it('should skip jobs with not metadata', async () => { assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(true); + await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED); expect(metadataMock.writeTags).not.toHaveBeenCalled(); }); @@ -778,7 +779,7 @@ describe(MetadataService.name, () => { longitude: gps, dateTimeOriginal: date, }), - ).resolves.toBe(true); + ).resolves.toBe(JobStatus.SUCCESS); expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { ImageDescription: description, CreationDate: date, diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 39919f78f..5f0b28fc4 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -26,6 +26,7 @@ import { IStorageRepository, ISystemConfigRepository, ImmichTags, + JobStatus, WithoutProperty, } from '../repositories'; import { StorageCore } from '../storage'; @@ -151,15 +152,15 @@ export class MetadataService { await this.repository.teardown(); } - async handleLivePhotoLinking(job: IEntityJob) { + async handleLivePhotoLinking(job: IEntityJob): Promise { const { id } = job; - const [asset] = await this.assetRepository.getByIds([id]); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); if (!asset?.exifInfo) { - return false; + return JobStatus.FAILED; } if (!asset.exifInfo.livePhotoCID) { - return true; + return JobStatus.SKIPPED; } const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO; @@ -171,7 +172,7 @@ export class MetadataService { }); if (!match) { - return true; + return JobStatus.SKIPPED; } const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset]; @@ -183,10 +184,10 @@ export class MetadataService { // Notify clients to hide the linked live photo asset this.communicationRepository.send(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id); - return true; + return JobStatus.SUCCESS; } - async handleQueueMetadataExtraction(job: IBaseJob) { + async handleQueueMetadataExtraction(job: IBaseJob): Promise { const { force } = job; const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force @@ -200,13 +201,13 @@ export class MetadataService { ); } - return true; + return JobStatus.SUCCESS; } - async handleMetadataExtraction({ id }: IEntityJob) { + async handleMetadataExtraction({ id }: IEntityJob): Promise { const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { - return false; + return JobStatus.FAILED; } const { exifData, tags } = await this.exifData(asset); @@ -260,10 +261,10 @@ export class MetadataService { metadataExtractedAt: new Date(), }); - return true; + return JobStatus.SUCCESS; } - async handleQueueSidecar(job: IBaseJob) { + async handleQueueSidecar(job: IBaseJob): Promise { const { force } = job; const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force @@ -280,22 +281,22 @@ export class MetadataService { ); } - return true; + return JobStatus.SUCCESS; } - handleSidecarSync({ id }: IEntityJob) { + handleSidecarSync({ id }: IEntityJob): Promise { return this.processSidecar(id, true); } - handleSidecarDiscovery({ id }: IEntityJob) { + handleSidecarDiscovery({ id }: IEntityJob): Promise { return this.processSidecar(id, false); } - async handleSidecarWrite(job: ISidecarWriteJob) { + async handleSidecarWrite(job: ISidecarWriteJob): Promise { const { id, description, dateTimeOriginal, latitude, longitude } = job; const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { - return false; + return JobStatus.FAILED; } const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; @@ -310,7 +311,7 @@ export class MetadataService { ); if (Object.keys(exif).length === 0) { - return true; + return JobStatus.SKIPPED; } await this.repository.writeTags(sidecarPath, exif); @@ -319,7 +320,7 @@ export class MetadataService { await this.assetRepository.save({ id, sidecarPath }); } - return true; + return JobStatus.SUCCESS; } private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { @@ -552,19 +553,19 @@ export class MetadataService { return Duration.fromObject({ seconds: _seconds }).toFormat('hh:mm:ss.SSS'); } - private async processSidecar(id: string, isSync: boolean) { + private async processSidecar(id: string, isSync: boolean): Promise { const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { - return false; + return JobStatus.FAILED; } if (isSync && !asset.sidecarPath) { - return false; + return JobStatus.FAILED; } if (!isSync && (!asset.isVisible || asset.sidecarPath)) { - return false; + return JobStatus.FAILED; } // XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp @@ -587,11 +588,11 @@ export class MetadataService { if (sidecarPath) { await this.assetRepository.save({ id: asset.id, sidecarPath }); - return true; + return JobStatus.SUCCESS; } if (!isSync) { - return false; + return JobStatus.FAILED; } this.logger.debug( @@ -599,6 +600,6 @@ export class MetadataService { ); await this.assetRepository.save({ id: asset.id, sidecarPath: null }); - return true; + return JobStatus.SUCCESS; } } diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 191356d2c..08b5875a5 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -34,6 +34,7 @@ import { ISearchRepository, IStorageRepository, ISystemConfigRepository, + JobStatus, WithoutProperty, } from '../repositories'; import { PersonResponseDto, mapFaces, mapPerson } from './person.dto'; @@ -357,7 +358,7 @@ describe(PersonService.name, () => { describe('handlePersonMigration', () => { it('should not move person files', async () => { personMock.getById.mockResolvedValue(null); - await expect(sut.handlePersonMigration(personStub.noName)).resolves.toStrictEqual(false); + await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED); }); }); @@ -454,10 +455,10 @@ describe(PersonService.name, () => { }); describe('handleQueueDetectFaces', () => { - it('should return if machine learning is disabled', async () => { + it('should skip if machine learning is disabled', async () => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); - await expect(sut.handleQueueDetectFaces({})).resolves.toBe(true); + await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(configMock.load).toHaveBeenCalled(); @@ -530,19 +531,19 @@ describe(PersonService.name, () => { }); describe('handleQueueRecognizeFaces', () => { - it('should return if machine learning is disabled', async () => { + it('should skip if machine learning is disabled', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); - await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(true); + await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(configMock.load).toHaveBeenCalled(); }); - it('should return if recognition jobs are already queued', async () => { + it('should skip if recognition jobs are already queued', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 1, paused: 0, completed: 0, failed: 0, delayed: 0 }); - await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(true); + await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queueAll).not.toHaveBeenCalled(); }); @@ -612,10 +613,10 @@ describe(PersonService.name, () => { }); describe('handleDetectFaces', () => { - it('should return if machine learning is disabled', async () => { + it('should skip if machine learning is disabled', async () => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); - await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(true); + await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(configMock.load).toHaveBeenCalled(); }); @@ -701,31 +702,31 @@ describe(PersonService.name, () => { }); describe('handleRecognizeFaces', () => { - it('should return false if face does not exist', async () => { + it('should fail if face does not exist', async () => { personMock.getFaceByIdWithAssets.mockResolvedValue(null); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(false); + expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); expect(personMock.createFaces).not.toHaveBeenCalled(); }); - it('should return false if face does not have asset', async () => { + it('should fail if face does not have asset', async () => { const face = { ...faceStub.face1, asset: null } as AssetFaceEntity & { asset: null }; personMock.getFaceByIdWithAssets.mockResolvedValue(face); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(false); + expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); expect(personMock.createFaces).not.toHaveBeenCalled(); }); - it('should return true if face already has an assigned person', async () => { + it('should skip if face already has an assigned person', async () => { personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(true); + expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED); expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); @@ -852,10 +853,10 @@ describe(PersonService.name, () => { }); describe('handleGeneratePersonThumbnail', () => { - it('should return if machine learning is disabled', async () => { + it('should skip if machine learning is disabled', async () => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); - await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(true); + await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(configMock.load).toHaveBeenCalled(); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 235867314..1a2233f3c 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -24,6 +24,7 @@ import { IStorageRepository, ISystemConfigRepository, JobItem, + JobStatus, UpdateFacesData, WithoutProperty, } from '../repositories'; @@ -265,16 +266,16 @@ export class PersonService { } } - async handlePersonCleanup() { + async handlePersonCleanup(): Promise { const people = await this.repository.getAllWithoutFaces(); await this.delete(people); - return true; + return JobStatus.SUCCESS; } - async handleQueueDetectFaces({ force }: IBaseJob) { + async handleQueueDetectFaces({ force }: IBaseJob): Promise { const { machineLearning } = await this.configCore.getConfig(); if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { - return true; + return JobStatus.SKIPPED; } if (force) { @@ -294,13 +295,13 @@ export class PersonService { ); } - return true; + return JobStatus.SUCCESS; } - async handleDetectFaces({ id }: IEntityJob) { + async handleDetectFaces({ id }: IEntityJob): Promise { const { machineLearning } = await this.configCore.getConfig(); if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { - return true; + return JobStatus.SKIPPED; } const relations = { @@ -311,7 +312,7 @@ export class PersonService { }; const [asset] = await this.assetRepository.getByIds([id], relations); if (!asset || !asset.resizePath || asset.faces?.length > 0) { - return false; + return JobStatus.FAILED; } const faces = await this.machineLearningRepository.detectFaces( @@ -346,13 +347,13 @@ export class PersonService { facesRecognizedAt: new Date(), }); - return true; + return JobStatus.SUCCESS; } - async handleQueueRecognizeFaces({ force }: IBaseJob) { + async handleQueueRecognizeFaces({ force }: IBaseJob): Promise { const { machineLearning } = await this.configCore.getConfig(); if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { - return true; + return JobStatus.SKIPPED; } await this.jobRepository.waitForQueueCompletion(QueueName.THUMBNAIL_GENERATION, QueueName.FACE_DETECTION); @@ -364,7 +365,7 @@ export class PersonService { this.logger.debug( `Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`, ); - return true; + return JobStatus.SKIPPED; } const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => @@ -377,13 +378,13 @@ export class PersonService { ); } - return true; + return JobStatus.SUCCESS; } - async handleRecognizeFaces({ id, deferred }: IDeferrableJob) { + async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise { const { machineLearning } = await this.configCore.getConfig(); if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { - return true; + return JobStatus.SKIPPED; } const face = await this.repository.getFaceByIdWithAssets( @@ -393,12 +394,12 @@ export class PersonService { ); if (!face || !face.asset) { this.logger.warn(`Face ${id} not found`); - return false; + return JobStatus.FAILED; } if (face.personId) { this.logger.debug(`Face ${id} already has a person assigned`); - return true; + return JobStatus.SKIPPED; } const matches = await this.smartInfoRepository.searchFaces({ @@ -411,7 +412,7 @@ export class PersonService { // `matches` also includes the face itself if (machineLearning.facialRecognition.minFaces > 1 && matches.length <= 1) { this.logger.debug(`Face ${id} only matched the face itself, skipping`); - return true; + return JobStatus.SKIPPED; } this.logger.debug(`Face ${id} has ${matches.length} matches`); @@ -420,7 +421,7 @@ export class PersonService { if (!isCore && !deferred) { this.logger.debug(`Deferring non-core face ${id} for later processing`); await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } }); - return true; + return JobStatus.SKIPPED; } let personId = matches.find((match) => match.face.personId)?.face.personId; @@ -450,34 +451,34 @@ export class PersonService { await this.repository.reassignFaces({ faceIds: [id], newPersonId: personId }); } - return true; + return JobStatus.SUCCESS; } - async handlePersonMigration({ id }: IEntityJob) { + async handlePersonMigration({ id }: IEntityJob): Promise { const person = await this.repository.getById(id); if (!person) { - return false; + return JobStatus.FAILED; } await this.storageCore.movePersonFile(person, PersonPathType.FACE); - return true; + return JobStatus.SUCCESS; } - async handleGeneratePersonThumbnail(data: IEntityJob) { + async handleGeneratePersonThumbnail(data: IEntityJob): Promise { const { machineLearning, thumbnail } = await this.configCore.getConfig(); if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { - return true; + return JobStatus.SKIPPED; } const person = await this.repository.getById(data.id); if (!person?.faceAssetId) { - return false; + return JobStatus.FAILED; } const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId); if (face === null) { - return false; + return JobStatus.FAILED; } const { @@ -492,7 +493,7 @@ export class PersonService { const [asset] = await this.assetRepository.getByIds([assetId]); if (!asset?.resizePath) { - return false; + return JobStatus.FAILED; } this.logger.verbose(`Cropping face for person: ${person.id}`); const thumbnailPath = StorageCore.getPersonThumbnailPath(person); @@ -533,7 +534,7 @@ export class PersonService { await this.mediaRepository.resize(croppedOutput, thumbnailPath, thumbnailOptions); await this.repository.update({ id: person.id, thumbnailPath }); - return true; + return JobStatus.SUCCESS; } async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise { diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index b779c8b8c..8b14ce597 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -1,5 +1,5 @@ import { AssetSearchOptions, ReverseGeocodeResult, SearchExploreItem } from '@app/domain'; -import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities'; +import { AssetEntity, AssetJobStatusEntity, AssetOrder, AssetType, ExifEntity } from '@app/infra/entities'; import { FindOptionsRelations, FindOptionsSelect } from 'typeorm'; import { Paginated, PaginationOptions } from '../domain.util'; @@ -66,6 +66,7 @@ export interface AssetBuilderOptions { export interface TimeBucketOptions extends AssetBuilderOptions { size: TimeBucketSize; + order?: AssetOrder; } export interface TimeBucketItem { @@ -121,6 +122,7 @@ export interface IAssetRepository { relations?: FindOptionsRelations, select?: FindOptionsSelect, ): Promise; + getByIdsWithAllRelations(ids: string[]): Promise; getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise; getByChecksum(userId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index 65e322702..3efbbcb5e 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -1,4 +1,5 @@ import { AssetResponseDto, ReleaseNotification, ServerVersionResponseDto } from '@app/domain'; +import { SystemConfig } from '@app/infra/entities'; export const ICommunicationRepository = 'ICommunicationRepository'; @@ -21,6 +22,14 @@ export enum ServerEvent { CONFIG_UPDATE = 'config:update', } +export enum InternalEvent { + VALIDATE_CONFIG = 'validate_config', +} + +export interface InternalEventMap { + [InternalEvent.VALIDATE_CONFIG]: { newConfig: SystemConfig; oldConfig: SystemConfig }; +} + export interface ClientEventMap { [ClientEvent.UPLOAD_SUCCESS]: AssetResponseDto; [ClientEvent.USER_DELETE]: string; @@ -45,4 +54,6 @@ export interface ICommunicationRepository { on(event: 'connect', callback: OnConnectCallback): void; on(event: ServerEvent, callback: OnServerEventCallback): void; sendServerEvent(event: ServerEvent): void; + emit(event: E, data: InternalEventMap[E]): boolean; + emitAsync(event: E, data: InternalEventMap[E]): Promise; } diff --git a/server/src/domain/repositories/job.repository.ts b/server/src/domain/repositories/job.repository.ts index 232040f7a..3d31dd16b 100644 --- a/server/src/domain/repositories/job.repository.ts +++ b/server/src/domain/repositories/job.repository.ts @@ -94,7 +94,13 @@ export type JobItem = | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }; -export type JobHandler = (data: T) => boolean | Promise; +export enum JobStatus { + SUCCESS = 'success', + FAILED = 'failed', + SKIPPED = 'skipped', +} + +export type JobHandler = (data: T) => Promise; export type JobItemHandler = (item: JobItem) => Promise; export const IJobRepository = 'IJobRepository'; diff --git a/server/src/domain/repositories/storage.repository.ts b/server/src/domain/repositories/storage.repository.ts index f4f8cab7b..a052596c0 100644 --- a/server/src/domain/repositories/storage.repository.ts +++ b/server/src/domain/repositories/storage.repository.ts @@ -53,6 +53,7 @@ export interface IStorageRepository { readdir(folder: string): Promise; stat(filepath: string): Promise; crawl(crawlOptions: CrawlOptionsDto): Promise; + walk(crawlOptions: CrawlOptionsDto): AsyncGenerator; copyFile(source: string, target: string): Promise; rename(source: string, target: string): Promise; watch(paths: string[], options: WatchOptions, events: Partial): () => Promise; diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 9fa7d8e8b..1bc67266a 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -1,5 +1,4 @@ -import { AssetOrder } from '@app/domain/asset/dto/asset.dto'; -import { AssetType, GeodataPlacesEntity } from '@app/infra/entities'; +import { AssetOrder, AssetType, GeodataPlacesEntity } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index de1d63c9d..b6edf1ece 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -76,7 +76,7 @@ describe(SearchService.name, () => { fieldName: 'smartInfo.tags', items: [{ value: 'train', data: assetStub.imageFrom2015.id }], }); - assetMock.getByIds.mockResolvedValueOnce([assetStub.image, assetStub.imageFrom2015]); + assetMock.getByIdsWithAllRelations.mockResolvedValueOnce([assetStub.image, assetStub.imageFrom2015]); const expectedResponse = [ { fieldName: 'exifInfo.city', items: [{ value: 'Paris', data: mapAsset(assetStub.image) }] }, { fieldName: 'smartInfo.tags', items: [{ value: 'train', data: mapAsset(assetStub.imageFrom2015) }] }, diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 00c5e883e..56c4498bc 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -1,6 +1,6 @@ -import { AssetEntity } from '@app/infra/entities'; +import { AssetEntity, AssetOrder } from '@app/infra/entities'; import { Inject, Injectable } from '@nestjs/common'; -import { AssetOrder, AssetResponseDto, mapAsset } from '../asset'; +import { AssetResponseDto, mapAsset } from '../asset'; import { AuthDto } from '../auth'; import { PersonResponseDto } from '../person'; import { @@ -60,7 +60,7 @@ export class SearchService { this.assetRepository.getAssetIdByTag(auth.user.id, options), ]); const assetIds = new Set(results.flatMap((field) => field.items.map((item) => item.data))); - const assets = await this.assetRepository.getByIds([...assetIds]); + const assets = await this.assetRepository.getByIdsWithAllRelations([...assetIds]); const assetMap = new Map(assets.map((asset) => [asset.id, mapAsset(asset)])); return results.map(({ fieldName, items }) => ({ diff --git a/server/src/domain/smart-info/smart-info.service.ts b/server/src/domain/smart-info/smart-info.service.ts index 19d5668cc..b7dd1a91f 100644 --- a/server/src/domain/smart-info/smart-info.service.ts +++ b/server/src/domain/smart-info/smart-info.service.ts @@ -10,6 +10,7 @@ import { IMachineLearningRepository, ISearchRepository, ISystemConfigRepository, + JobStatus, WithoutProperty, } from '../repositories'; import { SystemConfigCore } from '../system-config'; @@ -44,10 +45,10 @@ export class SmartInfoService { await this.jobRepository.resume(QueueName.SMART_SEARCH); } - async handleQueueEncodeClip({ force }: IBaseJob) { + async handleQueueEncodeClip({ force }: IBaseJob): Promise { const { machineLearning } = await this.configCore.getConfig(); if (!machineLearning.enabled || !machineLearning.clip.enabled) { - return true; + return JobStatus.SKIPPED; } if (force) { @@ -66,18 +67,22 @@ export class SmartInfoService { ); } - return true; + return JobStatus.SUCCESS; } - async handleEncodeClip({ id }: IEntityJob) { + async handleEncodeClip({ id }: IEntityJob): Promise { const { machineLearning } = await this.configCore.getConfig(); if (!machineLearning.enabled || !machineLearning.clip.enabled) { - return true; + return JobStatus.SKIPPED; } const [asset] = await this.assetRepository.getByIds([id]); + if (!asset) { + return JobStatus.FAILED; + } + if (!asset.resizePath) { - return false; + return JobStatus.FAILED; } const clipEmbedding = await this.machineLearning.encodeImage( @@ -93,6 +98,6 @@ export class SmartInfoService { await this.repository.upsert({ assetId: asset.id }, clipEmbedding); - return true; + return JobStatus.SUCCESS; } } diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index 1db312d78..21fa6ef7d 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/server/src/domain/storage-template/storage-template.service.spec.ts @@ -8,10 +8,11 @@ import { IStorageRepository, ISystemConfigRepository, IUserRepository, + JobStatus, StorageTemplateService, defaults, } from '@app/domain'; -import { AssetPathType, SystemConfigKey } from '@app/infra/entities'; +import { AssetPathType, SystemConfig, SystemConfigKey } from '@app/infra/entities'; import { assetStub, newAlbumRepositoryMock, @@ -73,10 +74,39 @@ describe(StorageTemplateService.name, () => { SystemConfigCore.create(configMock).config$.next(defaults); }); + describe('validate', () => { + it('should allow valid templates', () => { + expect(() => + sut.validate({ + newConfig: { + storageTemplate: { + template: + '{{y}}{{M}}{{W}}{{d}}{{h}}{{m}}{{s}}{{filename}}{{ext}}{{filetype}}{{filetypefull}}{{assetId}}{{album}}', + }, + } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).not.toThrow(); + }); + + it('should fail for an invalid template', () => { + expect(() => + sut.validate({ + newConfig: { + storageTemplate: { + template: '{{foo}}', + }, + } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).toThrow(/Invalid storage template.*/); + }); + }); + describe('handleMigrationSingle', () => { it('should skip when storage template is disabled', async () => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: false }]); - await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true); + await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(storageMock.checkFileExists).not.toHaveBeenCalled(); expect(storageMock.rename).not.toHaveBeenCalled(); @@ -101,11 +131,11 @@ describe(StorageTemplateService.name, () => { .mockResolvedValue(assetStub.livePhotoMotionAsset); when(assetMock.getByIds) - .calledWith([assetStub.livePhotoStillAsset.id]) + .calledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }) .mockResolvedValue([assetStub.livePhotoStillAsset]); when(assetMock.getByIds) - .calledWith([assetStub.livePhotoMotionAsset.id]) + .calledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }) .mockResolvedValue([assetStub.livePhotoMotionAsset]); when(moveMock.create) @@ -138,10 +168,12 @@ describe(StorageTemplateService.name, () => { newPath: newMotionPicturePath, }); - await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true); + await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( + JobStatus.SUCCESS, + ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, @@ -172,7 +204,9 @@ describe(StorageTemplateService.name, () => { .calledWith({ id: assetStub.image.id, originalPath: newPath }) .mockResolvedValue(assetStub.image); - when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]); + when(assetMock.getByIds) + .calledWith([assetStub.image.id], { exifInfo: true }) + .mockResolvedValue([assetStub.image]); when(moveMock.update) .calledWith({ @@ -188,9 +222,9 @@ describe(StorageTemplateService.name, () => { newPath, }); - await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true); + await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); expect(moveMock.update).toHaveBeenCalledWith({ @@ -227,7 +261,9 @@ describe(StorageTemplateService.name, () => { .calledWith({ id: assetStub.image.id, originalPath: newPath }) .mockResolvedValue(assetStub.image); - when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]); + when(assetMock.getByIds) + .calledWith([assetStub.image.id], { exifInfo: true }) + .mockResolvedValue([assetStub.image]); when(moveMock.update) .calledWith({ @@ -243,9 +279,9 @@ describe(StorageTemplateService.name, () => { newPath, }); - await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true); + await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath); expect(storageMock.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath); @@ -275,7 +311,9 @@ describe(StorageTemplateService.name, () => { .calledWith({ id: assetStub.image.id, originalPath: newPath }) .mockResolvedValue(assetStub.image); - when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]); + when(assetMock.getByIds) + .calledWith([assetStub.image.id], { exifInfo: true }) + .mockResolvedValue([assetStub.image]); when(moveMock.create) .calledWith({ @@ -292,9 +330,9 @@ describe(StorageTemplateService.name, () => { newPath, }); - await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true); + await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(1); expect(storageMock.stat).toHaveBeenCalledWith(newPath); expect(moveMock.create).toHaveBeenCalledWith({ @@ -340,7 +378,9 @@ describe(StorageTemplateService.name, () => { .calledWith({ id: assetStub.image.id, originalPath: newPath }) .mockResolvedValue(assetStub.image); - when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]); + when(assetMock.getByIds) + .calledWith([assetStub.image.id], { exifInfo: true }) + .mockResolvedValue([assetStub.image]); when(moveMock.update) .calledWith({ @@ -356,9 +396,9 @@ describe(StorageTemplateService.name, () => { newPath, }); - await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true); + await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath); expect(storageMock.rename).not.toHaveBeenCalled(); diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index 857d1df32..ffdbfbefb 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -1,6 +1,7 @@ import { AssetEntity, AssetPathType, AssetType, SystemConfig } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import handlebar from 'handlebars'; import * as luxon from 'luxon'; import path from 'node:path'; @@ -18,6 +19,9 @@ import { IStorageRepository, ISystemConfigRepository, IUserRepository, + InternalEvent, + InternalEventMap, + JobStatus, } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; import { @@ -73,7 +77,6 @@ export class StorageTemplateService { @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, ) { this.configCore = SystemConfigCore.create(configRepository); - this.configCore.addValidator((config) => this.validate(config)); this.configCore.config$.subscribe((config) => this.onConfig(config)); this.storageCore = StorageCore.create( assetRepository, @@ -85,14 +88,38 @@ export class StorageTemplateService { ); } - async handleMigrationSingle({ id }: IEntityJob) { + @OnEvent(InternalEvent.VALIDATE_CONFIG) + validate({ newConfig }: InternalEventMap[InternalEvent.VALIDATE_CONFIG]) { + try { + const { compiled } = this.compile(newConfig.storageTemplate.template); + this.render(compiled, { + asset: { + fileCreatedAt: new Date(), + originalPath: '/upload/test/IMG_123.jpg', + type: AssetType.IMAGE, + id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e', + } as AssetEntity, + filename: 'IMG_123', + extension: 'jpg', + albumName: 'album', + }); + } catch (error) { + this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`); + throw new Error(`Invalid storage template: ${error}`); + } + } + + async handleMigrationSingle({ id }: IEntityJob): Promise { const config = await this.configCore.getConfig(); const storageTemplateEnabled = config.storageTemplate.enabled; if (!storageTemplateEnabled) { - return true; + return JobStatus.SKIPPED; } - const [asset] = await this.assetRepository.getByIds([id]); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); + if (!asset) { + return JobStatus.FAILED; + } const user = await this.userRepository.get(asset.ownerId, {}); const storageLabel = user?.storageLabel || null; @@ -101,20 +128,23 @@ export class StorageTemplateService { // move motion part of live photo if (asset.livePhotoVideoId) { - const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]); + const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId], { exifInfo: true }); + if (!livePhotoVideo) { + return JobStatus.FAILED; + } const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); } - return true; + return JobStatus.SUCCESS; } - async handleMigration() { + async handleMigration(): Promise { this.logger.log('Starting storage template migration'); const { storageTemplate } = await this.configCore.getConfig(); const { enabled } = storageTemplate; if (!enabled) { this.logger.log('Storage template migration disabled, skipping'); - return true; + return JobStatus.SKIPPED; } const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => this.assetRepository.getAll(pagination, { withExif: true }), @@ -136,7 +166,7 @@ export class StorageTemplateService { this.logger.log('Finished storage template migration'); - return true; + return JobStatus.SUCCESS; } async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { @@ -252,26 +282,6 @@ export class StorageTemplateService { } } - private validate(config: SystemConfig) { - try { - const { compiled } = this.compile(config.storageTemplate.template); - this.render(compiled, { - asset: { - fileCreatedAt: new Date(), - originalPath: '/upload/test/IMG_123.jpg', - type: AssetType.IMAGE, - id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e', - } as AssetEntity, - filename: 'IMG_123', - extension: 'jpg', - albumName: 'album', - }); - } catch (error) { - this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`); - throw new Error(`Invalid storage template: ${error}`); - } - } - private onConfig(config: SystemConfig) { const template = config.storageTemplate.template; if (!this._template || template !== this.template.raw) { diff --git a/server/src/domain/storage/storage.core.ts b/server/src/domain/storage/storage.core.ts index 30a6002be..36e600b24 100644 --- a/server/src/domain/storage/storage.core.ts +++ b/server/src/domain/storage/storage.core.ts @@ -20,6 +20,9 @@ export enum StorageFolder { THUMBNAILS = 'thumbs', } +export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS)); +export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO)); + export interface MoveRequest { entityId: string; pathType: PathType; @@ -115,6 +118,10 @@ export class StorageCore { return resolve(path).startsWith(resolve(APP_MEDIA_LOCATION)); } + static isGeneratedAsset(path: string) { + return path.startsWith(THUMBNAIL_DIR) || path.startsWith(ENCODED_VIDEO_DIR); + } + async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) { const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset; switch (pathType) { diff --git a/server/src/domain/storage/storage.service.ts b/server/src/domain/storage/storage.service.ts index 994a2b6fd..95c311881 100644 --- a/server/src/domain/storage/storage.service.ts +++ b/server/src/domain/storage/storage.service.ts @@ -1,7 +1,7 @@ import { ImmichLogger } from '@app/infra/logger'; import { Inject, Injectable } from '@nestjs/common'; import { IDeleteFilesJob } from '../job'; -import { IStorageRepository } from '../repositories'; +import { IStorageRepository, JobStatus } from '../repositories'; import { StorageCore, StorageFolder } from './storage.core'; @Injectable() @@ -31,6 +31,6 @@ export class StorageService { } } - return true; + return JobStatus.SUCCESS; } } diff --git a/server/src/domain/system-config/system-config.constants.ts b/server/src/domain/system-config/system-config.constants.ts index 47bddb97b..0290472aa 100644 --- a/server/src/domain/system-config/system-config.constants.ts +++ b/server/src/domain/system-config/system-config.constants.ts @@ -4,7 +4,7 @@ export const supportedWeekTokens = ['W', 'WW']; export const supportedDayTokens = ['d', 'dd']; export const supportedHourTokens = ['h', 'hh', 'H', 'HH']; export const supportedMinuteTokens = ['m', 'mm']; -export const supportedSecondTokens = ['s', 'ss']; +export const supportedSecondTokens = ['s', 'ss', 'SSS']; export const supportedPresetTokens = [ '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', '{{y}}/{{MM}}-{{dd}}/{{filename}}', diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 4a45de93e..93a4937cb 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -167,7 +167,6 @@ let instance: SystemConfigCore | null; @Injectable() export class SystemConfigCore { private logger = new ImmichLogger(SystemConfigCore.name); - private validators: SystemConfigValidator[] = []; private configCache: SystemConfigEntity[] | null = null; public config$ = new Subject(); @@ -245,10 +244,6 @@ export class SystemConfigCore { return defaults; } - public addValidator(validator: SystemConfigValidator) { - this.validators.push(validator); - } - public async getConfig(force = false): Promise { const configFilePath = process.env.IMMICH_CONFIG_FILE; const config = _.cloneDeep(defaults); @@ -283,17 +278,6 @@ export class SystemConfigCore { throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); } - const oldConfig = await this.getConfig(); - - try { - for (const validator of this.validators) { - await validator(newConfig, oldConfig); - } - } catch (error) { - this.logger.warn(`Unable to save system config due to a validation error: ${error}`); - throw new BadRequestException(error instanceof Error ? error.message : error); - } - const updates: SystemConfigEntity[] = []; const deletes: SystemConfigEntity[] = []; diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 8fa203ae2..fd9c16463 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -16,7 +16,7 @@ import { BadRequestException } from '@nestjs/common'; import { newCommunicationRepositoryMock, newSystemConfigRepositoryMock } from '@test'; import { QueueName } from '../job'; import { ICommunicationRepository, ISearchRepository, ISystemConfigRepository, ServerEvent } from '../repositories'; -import { defaults, SystemConfigValidator } from './system-config.core'; +import { defaults } from './system-config.core'; import { SystemConfigService } from './system-config.service'; const updates: SystemConfigEntity[] = [ @@ -172,15 +172,6 @@ describe(SystemConfigService.name, () => { }); }); - describe('addValidator', () => { - it('should call the validator on config changes', async () => { - const validator: SystemConfigValidator = jest.fn(); - sut.addValidator(validator); - await sut.updateConfig(defaults); - expect(validator).toHaveBeenCalledWith(defaults, defaults); - }); - }); - describe('getConfig', () => { let warnLog: jest.SpyInstance; @@ -323,7 +314,7 @@ describe(SystemConfigService.name, () => { '{{y}}/{{y}}-{{WW}}/{{assetId}}', '{{album}}/{{filename}}', ], - secondOptions: ['s', 'ss'], + secondOptions: ['s', 'ss', 'SSS'], weekOptions: ['W', 'WW'], yearOptions: ['y', 'yy'], }); @@ -341,17 +332,6 @@ describe(SystemConfigService.name, () => { expect(configMock.saveAll).toHaveBeenCalledWith(updates); }); - it('should throw an error if the config is not valid', async () => { - const validator = jest.fn().mockRejectedValue('invalid config'); - - sut.addValidator(validator); - - await expect(sut.updateConfig(updatedConfig)).rejects.toBeInstanceOf(BadRequestException); - - expect(validator).toHaveBeenCalledWith(updatedConfig, defaults); - expect(configMock.saveAll).not.toHaveBeenCalled(); - }); - it('should throw an error if a config file is in use', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; configMock.readFile.mockResolvedValue(JSON.stringify({})); diff --git a/server/src/domain/system-config/system-config.service.ts b/server/src/domain/system-config/system-config.service.ts index 54d113cf6..7e68cf0b9 100644 --- a/server/src/domain/system-config/system-config.service.ts +++ b/server/src/domain/system-config/system-config.service.ts @@ -1,6 +1,7 @@ import { LogLevel, SystemConfig } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; -import { Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; import { @@ -8,6 +9,8 @@ import { ICommunicationRepository, ISearchRepository, ISystemConfigRepository, + InternalEvent, + InternalEventMap, ServerEvent, } from '../repositories'; import { SystemConfigDto, mapConfig } from './dto/system-config.dto'; @@ -22,7 +25,7 @@ import { supportedWeekTokens, supportedYearTokens, } from './system-config.constants'; -import { SystemConfigCore, SystemConfigValidator } from './system-config.core'; +import { SystemConfigCore } from './system-config.core'; @Injectable() export class SystemConfigService { @@ -37,7 +40,6 @@ export class SystemConfigService { this.core = SystemConfigCore.create(repository); this.communicationRepository.on(ServerEvent.CONFIG_UPDATE, () => this.handleConfigUpdate()); this.core.config$.subscribe((config) => this.setLogLevel(config)); - this.core.addValidator((newConfig, oldConfig) => this.validateConfig(newConfig, oldConfig)); } async init() { @@ -59,8 +61,23 @@ export class SystemConfigService { return mapConfig(config); } + @OnEvent(InternalEvent.VALIDATE_CONFIG) + validateConfig({ newConfig, oldConfig }: InternalEventMap[InternalEvent.VALIDATE_CONFIG]) { + if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { + throw new Error('Logging cannot be changed while the environment variable LOG_LEVEL is set.'); + } + } + async updateConfig(dto: SystemConfigDto): Promise { const oldConfig = await this.core.getConfig(); + + try { + await this.communicationRepository.emitAsync(InternalEvent.VALIDATE_CONFIG, { newConfig: dto, oldConfig }); + } catch (error) { + this.logger.warn(`Unable to save system config due to a validation error: ${error}`); + throw new BadRequestException(error instanceof Error ? error.message : error); + } + const newConfig = await this.core.updateConfig(dto); this.communicationRepository.broadcast(ClientEvent.CONFIG_UPDATE, {}); @@ -79,10 +96,6 @@ export class SystemConfigService { return true; } - addValidator(validator: SystemConfigValidator) { - this.core.addValidator(validator); - } - getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { const options = new SystemConfigTemplateStorageOptionDto(); @@ -129,10 +142,4 @@ export class SystemConfigService { private getEnvLogLevel() { return process.env.LOG_LEVEL as LogLevel; } - - private validateConfig(newConfig: SystemConfig, oldConfig: SystemConfig) { - if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { - throw new Error('Logging cannot be changed while the environment variable LOG_LEVEL is set.'); - } - } } diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index 564163d77..a1db1fb04 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -14,6 +14,7 @@ import { IStorageRepository, ISystemConfigRepository, IUserRepository, + JobStatus, UserFindOptions, } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; @@ -143,12 +144,12 @@ export class UserService { return { admin, password, provided: !!providedPassword }; } - async handleUserSyncUsage() { + async handleUserSyncUsage(): Promise { await this.userRepository.syncUsage(); - return true; + return JobStatus.SUCCESS; } - async handleUserDeleteCheck() { + async handleUserDeleteCheck(): Promise { const users = await this.userRepository.getDeletedUsers(); const config = await this.configCore.getConfig(); await this.jobRepository.queueAll( @@ -158,20 +159,20 @@ export class UserService { : [], ), ); - return true; + return JobStatus.SUCCESS; } - async handleUserDelete({ id, force }: IEntityJob) { + async handleUserDelete({ id, force }: IEntityJob): Promise { const config = await this.configCore.getConfig(); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { - return false; + return JobStatus.FAILED; } // just for extra protection here if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) { this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`); - return false; + return JobStatus.SKIPPED; } this.logger.log(`Deleting user: ${user.id}`); @@ -193,7 +194,7 @@ export class UserService { await this.albumRepository.deleteAll(user.id); await this.userRepository.delete(user, true); - return true; + return JobStatus.SUCCESS; } private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean { diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index bc59e2dfe..37b561490 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -13,7 +13,6 @@ import { Res, UploadedFiles, UseInterceptors, - ValidationPipe, } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; @@ -58,7 +57,7 @@ export class AssetController { async uploadFile( @Auth() auth: AuthDto, @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles, - @Body(new ValidationPipe({ transform: true })) dto: CreateAssetDto, + @Body() dto: CreateAssetDto, @Res({ passthrough: true }) res: Response, ): Promise { const file = mapToUploadFile(files.assetData[0]); @@ -90,7 +89,7 @@ export class AssetController { @Next() next: NextFunction, @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, - @Query(new ValidationPipe({ transform: true })) dto: ServeFileDto, + @Query() dto: ServeFileDto, ) { await sendFile(res, next, () => this.serviceV1.serveFile(auth, id, dto)); } @@ -103,7 +102,7 @@ export class AssetController { @Next() next: NextFunction, @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, - @Query(new ValidationPipe({ transform: true })) dto: GetAssetThumbnailDto, + @Query() dto: GetAssetThumbnailDto, ) { await sendFile(res, next, () => this.serviceV1.serveThumbnail(auth, id, dto)); } @@ -133,10 +132,7 @@ export class AssetController { required: false, schema: { type: 'string' }, }) - getAllAssets( - @Auth() auth: AuthDto, - @Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto, - ): Promise { + getAllAssets(@Auth() auth: AuthDto, @Query() dto: AssetSearchDto): Promise { return this.serviceV1.getAllAssets(auth, dto); } @@ -147,7 +143,7 @@ export class AssetController { @HttpCode(HttpStatus.OK) checkExistingAssets( @Auth() auth: AuthDto, - @Body(ValidationPipe) dto: CheckExistingAssetsDto, + @Body() dto: CheckExistingAssetsDto, ): Promise { return this.serviceV1.checkExistingAssets(auth, dto); } @@ -159,7 +155,7 @@ export class AssetController { @HttpCode(HttpStatus.OK) checkBulkUpload( @Auth() auth: AuthDto, - @Body(ValidationPipe) dto: AssetBulkUploadCheckDto, + @Body() dto: AssetBulkUploadCheckDto, ): Promise { return this.serviceV1.bulkUploadCheck(auth, dto); } diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index 8d02a44a9..662f45f7c 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -1,8 +1,8 @@ import { DomainModule } from '@app/domain'; import { InfraModule } from '@app/infra'; import { AssetEntity, ExifEntity } from '@app/infra/entities'; -import { Module, OnModuleInit } from '@nestjs/common'; -import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { Module, OnModuleInit, ValidationPipe } from '@nestjs/common'; +import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AssetRepositoryV1, IAssetRepositoryV1 } from './api-v1/asset/asset-repository'; @@ -70,6 +70,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors'; PersonController, ], providers: [ + { provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) }, { provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, { provide: APP_GUARD, useClass: AppGuard }, { provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 }, diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts index 938f6f470..26aca2a9b 100644 --- a/server/src/immich/app.utils.ts +++ b/server/src/immich/app.utils.ts @@ -17,28 +17,15 @@ import { SwaggerDocumentOptions, SwaggerModule, } from '@nestjs/swagger'; +import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; import { NextFunction, Response } from 'express'; import _ from 'lodash'; import { writeFileSync } from 'node:fs'; import { access, constants } from 'node:fs/promises'; import path, { isAbsolute } from 'node:path'; import { promisify } from 'node:util'; - -import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common'; -import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; import { Metadata } from './app.guard'; -export function UseValidation() { - return applyDecorators( - UsePipes( - new ValidationPipe({ - transform: true, - whitelist: true, - }), - ), - ); -} - type SendFile = Parameters; type SendFileOptions = SendFile[1]; diff --git a/server/src/immich/controllers/activity.controller.ts b/server/src/immich/controllers/activity.controller.ts index d01d4e903..0808c7d4d 100644 --- a/server/src/immich/controllers/activity.controller.ts +++ b/server/src/immich/controllers/activity.controller.ts @@ -11,13 +11,11 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query import { ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { Auth, Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Activity') @Controller('activity') @Authenticated() -@UseValidation() export class ActivityController { constructor(private service: ActivityService) {} diff --git a/server/src/immich/controllers/album.controller.ts b/server/src/immich/controllers/album.controller.ts index d388dba77..ea1c5a428 100644 --- a/server/src/immich/controllers/album.controller.ts +++ b/server/src/immich/controllers/album.controller.ts @@ -15,13 +15,11 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@ import { ApiTags } from '@nestjs/swagger'; import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe'; import { Auth, Authenticated, SharedLinkRoute } from '../app.guard'; -import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Album') @Controller('album') @Authenticated() -@UseValidation() export class AlbumController { constructor(private service: AlbumService) {} diff --git a/server/src/immich/controllers/api-key.controller.ts b/server/src/immich/controllers/api-key.controller.ts index ba0aaf661..5b5072533 100644 --- a/server/src/immich/controllers/api-key.controller.ts +++ b/server/src/immich/controllers/api-key.controller.ts @@ -9,13 +9,11 @@ import { import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('API Key') @Controller('api-key') @Authenticated() -@UseValidation() export class APIKeyController { constructor(private service: APIKeyService) {} diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index 57a67d33f..39a36b175 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -8,7 +8,6 @@ import { AssetStatsResponseDto, AuthDto, DeviceIdDto, - DownloadService, MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto, @@ -19,21 +18,18 @@ import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto, - TrashService, UpdateAssetDto as UpdateDto, UpdateStackParentDto, } from '@app/domain'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated, SharedLinkRoute } from '../app.guard'; -import { UseValidation } from '../app.utils'; import { Route } from '../interceptors'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Asset') @Controller('assets') @Authenticated() -@UseValidation() export class AssetsController { constructor(private searchService: SearchService) {} @@ -50,13 +46,8 @@ export class AssetsController { @ApiTags('Asset') @Controller(Route.ASSET) @Authenticated() -@UseValidation() export class AssetController { - constructor( - private service: AssetService, - private downloadService: DownloadService, - private trashService: TrashService, - ) {} + constructor(private service: AssetService) {} @Get('map-marker') getMapMarkers(@Auth() auth: AuthDto, @Query() options: MapMarkerDto): Promise { diff --git a/server/src/immich/controllers/audit.controller.ts b/server/src/immich/controllers/audit.controller.ts index 785d48bd2..09b707b8a 100644 --- a/server/src/immich/controllers/audit.controller.ts +++ b/server/src/immich/controllers/audit.controller.ts @@ -11,12 +11,10 @@ import { import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AdminRoute, Auth, Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; @ApiTags('Audit') @Controller('audit') @Authenticated() -@UseValidation() export class AuditController { constructor(private service: AuditService) {} diff --git a/server/src/immich/controllers/auth.controller.ts b/server/src/immich/controllers/auth.controller.ts index c8ffd52fd..ac1fea2bc 100644 --- a/server/src/immich/controllers/auth.controller.ts +++ b/server/src/immich/controllers/auth.controller.ts @@ -19,13 +19,11 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Req, import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from '../app.guard'; -import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Authentication') @Controller('auth') @Authenticated() -@UseValidation() export class AuthController { constructor(private service: AuthService) {} diff --git a/server/src/immich/controllers/download.controller.ts b/server/src/immich/controllers/download.controller.ts index fcddac53d..743797f74 100644 --- a/server/src/immich/controllers/download.controller.ts +++ b/server/src/immich/controllers/download.controller.ts @@ -3,13 +3,12 @@ import { Body, Controller, HttpCode, HttpStatus, Next, Param, Post, Res, Streama import { ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../app.guard'; -import { UseValidation, asStreamableFile, sendFile } from '../app.utils'; +import { asStreamableFile, sendFile } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Download') @Controller('download') @Authenticated() -@UseValidation() export class DownloadController { constructor(private service: DownloadService) {} diff --git a/server/src/immich/controllers/face.controller.ts b/server/src/immich/controllers/face.controller.ts index 59473950c..f4014713b 100644 --- a/server/src/immich/controllers/face.controller.ts +++ b/server/src/immich/controllers/face.controller.ts @@ -2,13 +2,11 @@ import { AssetFaceResponseDto, AuthDto, FaceDto, PersonResponseDto, PersonServic import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Face') @Controller('face') @Authenticated() -@UseValidation() export class FaceController { constructor(private service: PersonService) {} diff --git a/server/src/immich/controllers/job.controller.ts b/server/src/immich/controllers/job.controller.ts index 243b7537d..413af44de 100644 --- a/server/src/immich/controllers/job.controller.ts +++ b/server/src/immich/controllers/job.controller.ts @@ -2,12 +2,10 @@ import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobService, JobS import { Body, Controller, Get, Param, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; @ApiTags('Job') @Controller('jobs') @Authenticated({ admin: true }) -@UseValidation() export class JobController { constructor(private service: JobService) {} diff --git a/server/src/immich/controllers/library.controller.ts b/server/src/immich/controllers/library.controller.ts index 9ad711979..801dc173d 100644 --- a/server/src/immich/controllers/library.controller.ts +++ b/server/src/immich/controllers/library.controller.ts @@ -13,13 +13,11 @@ import { import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AdminRoute, Auth, Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Library') @Controller('library') @Authenticated() -@UseValidation() @AdminRoute() export class LibraryController { constructor(private service: LibraryService) {} diff --git a/server/src/immich/controllers/oauth.controller.ts b/server/src/immich/controllers/oauth.controller.ts index a62458715..c7a5717af 100644 --- a/server/src/immich/controllers/oauth.controller.ts +++ b/server/src/immich/controllers/oauth.controller.ts @@ -12,12 +12,10 @@ import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@ne import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from '../app.guard'; -import { UseValidation } from '../app.utils'; @ApiTags('OAuth') @Controller('oauth') @Authenticated() -@UseValidation() export class OAuthController { constructor(private service: AuthService) {} diff --git a/server/src/immich/controllers/partner.controller.ts b/server/src/immich/controllers/partner.controller.ts index 6370d8e71..65d95438d 100644 --- a/server/src/immich/controllers/partner.controller.ts +++ b/server/src/immich/controllers/partner.controller.ts @@ -3,13 +3,11 @@ import { PartnerResponseDto, UpdatePartnerDto } from '@app/domain/partner/partne import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiQuery, ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Partner') @Controller('partner') @Authenticated() -@UseValidation() export class PartnerController { constructor(private service: PartnerService) {} diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index 2447f982b..3408aa6ec 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -17,13 +17,12 @@ import { Body, Controller, Get, Next, Param, Post, Put, Query, Res } from '@nest import { ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { Auth, Authenticated, FileResponse } from '../app.guard'; -import { UseValidation, sendFile } from '../app.utils'; +import { sendFile } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Person') @Controller('person') @Authenticated() -@UseValidation() export class PersonController { constructor(private service: PersonService) {} diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index df1bec7c6..d508531dd 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -15,12 +15,10 @@ import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-sugges import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; @ApiTags('Search') @Controller('search') @Authenticated() -@UseValidation() export class SearchController { constructor(private service: SearchService) {} diff --git a/server/src/immich/controllers/server-info.controller.ts b/server/src/immich/controllers/server-info.controller.ts index 66835501c..4987a8984 100644 --- a/server/src/immich/controllers/server-info.controller.ts +++ b/server/src/immich/controllers/server-info.controller.ts @@ -12,12 +12,10 @@ import { import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AdminRoute, Authenticated, PublicRoute } from '../app.guard'; -import { UseValidation } from '../app.utils'; @ApiTags('Server Info') @Controller('server-info') @Authenticated() -@UseValidation() export class ServerInfoController { constructor(private service: ServerInfoService) {} diff --git a/server/src/immich/controllers/shared-link.controller.ts b/server/src/immich/controllers/shared-link.controller.ts index 86045433d..d265d018d 100644 --- a/server/src/immich/controllers/shared-link.controller.ts +++ b/server/src/immich/controllers/shared-link.controller.ts @@ -13,13 +13,11 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { Auth, Authenticated, SharedLinkRoute } from '../app.guard'; -import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Shared Link') @Controller('shared-link') @Authenticated() -@UseValidation() export class SharedLinkController { constructor(private readonly service: SharedLinkService) {} diff --git a/server/src/immich/controllers/system-config.controller.ts b/server/src/immich/controllers/system-config.controller.ts index da2402efe..73cf2c3c0 100644 --- a/server/src/immich/controllers/system-config.controller.ts +++ b/server/src/immich/controllers/system-config.controller.ts @@ -3,12 +3,10 @@ import { MapThemeDto } from '@app/domain/system-config/system-config-map-theme.d import { Body, Controller, Get, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AdminRoute, Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; @ApiTags('System Config') @Controller('system-config') @Authenticated({ admin: true }) -@UseValidation() export class SystemConfigController { constructor(private readonly service: SystemConfigService) {} diff --git a/server/src/immich/controllers/tag.controller.ts b/server/src/immich/controllers/tag.controller.ts index b9d363600..0d0c563d4 100644 --- a/server/src/immich/controllers/tag.controller.ts +++ b/server/src/immich/controllers/tag.controller.ts @@ -11,13 +11,11 @@ import { import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Tag') @Controller('tag') @Authenticated() -@UseValidation() export class TagController { constructor(private service: TagService) {} diff --git a/server/src/immich/controllers/trash.controller.ts b/server/src/immich/controllers/trash.controller.ts index 9f7abe311..b61893817 100644 --- a/server/src/immich/controllers/trash.controller.ts +++ b/server/src/immich/controllers/trash.controller.ts @@ -2,12 +2,10 @@ import { AuthDto, BulkIdsDto, TrashService } from '@app/domain'; import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; -import { UseValidation } from '../app.utils'; @ApiTags('Trash') @Controller('trash') @Authenticated() -@UseValidation() export class TrashController { constructor(private service: TrashService) {} diff --git a/server/src/immich/controllers/user.controller.ts b/server/src/immich/controllers/user.controller.ts index 7fa7ccd0f..0b3828f5c 100644 --- a/server/src/immich/controllers/user.controller.ts +++ b/server/src/immich/controllers/user.controller.ts @@ -27,14 +27,13 @@ import { import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { AdminRoute, Auth, Authenticated, FileResponse } from '../app.guard'; -import { UseValidation, sendFile } from '../app.utils'; +import { sendFile } from '../app.utils'; import { FileUploadInterceptor, Route } from '../interceptors'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('User') @Controller(Route.USER) @Authenticated() -@UseValidation() export class UserController { constructor(private service: UserService) {} diff --git a/server/src/infra/entities/album.entity.ts b/server/src/infra/entities/album.entity.ts index fbc125351..daa8fcbc3 100644 --- a/server/src/infra/entities/album.entity.ts +++ b/server/src/infra/entities/album.entity.ts @@ -14,6 +14,12 @@ import { AssetEntity } from './asset.entity'; import { SharedLinkEntity } from './shared-link.entity'; import { UserEntity } from './user.entity'; +// ran into issues when importing the enum from `asset.dto.ts` +export enum AssetOrder { + ASC = 'asc', + DESC = 'desc', +} + @Entity('albums') export class AlbumEntity { @PrimaryGeneratedColumn('uuid') @@ -59,4 +65,7 @@ export class AlbumEntity { @Column({ default: true }) isActivityEnabled!: boolean; + + @Column({ type: 'varchar', default: AssetOrder.DESC }) + order!: AssetOrder; } diff --git a/server/src/infra/entities/asset-face.entity.ts b/server/src/infra/entities/asset-face.entity.ts index acd69f2be..1561f67d0 100644 --- a/server/src/infra/entities/asset-face.entity.ts +++ b/server/src/infra/entities/asset-face.entity.ts @@ -3,6 +3,7 @@ import { AssetEntity } from './asset.entity'; import { PersonEntity } from './person.entity'; @Entity('asset_faces', { synchronize: false }) +@Index('IDX_asset_faces_assetId_personId', ['assetId', 'personId']) @Index(['personId', 'assetId']) export class AssetFaceEntity { @PrimaryGeneratedColumn('uuid') diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 96438a07d..78a961757 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -35,6 +35,7 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum'; @Index('IDX_day_of_month', { synchronize: false }) @Index('IDX_month', { synchronize: false }) @Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId']) +@Index('IDX_asset_id_stackId', ['id', 'stackId']) @Index('idx_originalFileName_trigram', { synchronize: false }) // For all assets, each originalpath must be unique per user and library export class AssetEntity { @@ -145,7 +146,7 @@ export class AssetEntity { smartSearch?: SmartSearchEntity; @ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true }) - @JoinTable({ name: 'tag_asset' }) + @JoinTable({ name: 'tag_asset', synchronize: false }) tags!: TagEntity[]; @ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true }) diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index cdd5ab442..df3773deb 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -31,6 +31,7 @@ import { import { BullModule } from '@nestjs/bullmq'; import { Global, Module, Provider } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OpenTelemetryModule } from 'nestjs-otel'; @@ -103,6 +104,7 @@ const providers: Provider[] = [ @Module({ imports: [ ConfigModule.forRoot(immichAppConfig), + EventEmitterModule.forRoot(), TypeOrmModule.forRoot(databaseConfig), TypeOrmModule.forFeature(databaseEntities), ScheduleModule, @@ -119,6 +121,7 @@ export class InfraModule {} @Module({ imports: [ ConfigModule.forRoot(immichAppConfig), + EventEmitterModule.forRoot(), TypeOrmModule.forRoot(databaseConfig), TypeOrmModule.forFeature(databaseEntities), ScheduleModule, diff --git a/server/src/infra/migrations/1710182081326-AscendingOrderAlbum.ts b/server/src/infra/migrations/1710182081326-AscendingOrderAlbum.ts new file mode 100644 index 000000000..b672ff2b2 --- /dev/null +++ b/server/src/infra/migrations/1710182081326-AscendingOrderAlbum.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AscendingOrderAlbum1710182081326 implements MigrationInterface { + name = 'AscendingOrderAlbum1710182081326' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums" ADD "order" character varying NOT NULL DEFAULT 'desc'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "order"`); + } + +} diff --git a/server/src/infra/migrations/1710293990203-AddAssetRelationIndices.ts b/server/src/infra/migrations/1710293990203-AddAssetRelationIndices.ts new file mode 100644 index 000000000..dd0abf7fd --- /dev/null +++ b/server/src/infra/migrations/1710293990203-AddAssetRelationIndices.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAssetRelationIndices1710293990203 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX "IDX_asset_id_stackId" on assets ("id", "stackId")`); + await queryRunner.query(`CREATE INDEX "IDX_tag_asset_assetsId_tagsId" on tag_asset ("assetsId", "tagsId")`); + await queryRunner.query(`CREATE INDEX "IDX_asset_faces_assetId_personId" on asset_faces ("assetId", "personId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_asset_id_stackId" on assets ("id", "stackId")`); + await queryRunner.query(`DROP INDEX "IDX_tag_asset_assetsId_tagsId" on tag_asset ("assetsId", "tagsId")`); + await queryRunner.query(`DROP INDEX "IDX_asset_faces_assetId_personId" on asset_faces ("assetId", "personId")`); + } +} diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index c91ef5e0b..871a44460 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -36,7 +36,7 @@ import { Not, Repository, } from 'typeorm'; -import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity, SmartInfoEntity } from '../entities'; +import { AssetEntity, AssetJobStatusEntity, AssetOrder, AssetType, ExifEntity, SmartInfoEntity } from '../entities'; import { DummyValue, GenerateSql } from '../infra.util'; import { Chunked, ChunkedArray, OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from '../infra.utils'; import { Instrumentation } from '../instrumentation'; @@ -137,8 +137,20 @@ export class AssetRepository implements IAssetRepository { relations?: FindOptionsRelations, select?: FindOptionsSelect, ): Promise { - if (!relations) { - relations = { + return this.repository.find({ + where: { id: In(ids) }, + relations, + select, + withDeleted: true, + }); + } + + @GenerateSql({ params: [[DummyValue.UUID]] }) + @ChunkedArray() + getByIdsWithAllRelations(ids: string[]): Promise { + return this.repository.find({ + where: { id: In(ids) }, + relations: { exifInfo: true, smartInfo: true, tags: true, @@ -148,13 +160,7 @@ export class AssetRepository implements IAssetRepository { stack: { assets: true, }, - }; - } - - return this.repository.find({ - where: { id: In(ids) }, - relations, - select, + }, withDeleted: true, }); } @@ -601,7 +607,7 @@ export class AssetRepository implements IAssetRepository { .select(`COUNT(asset.id)::int`, 'count') .addSelect(truncated, 'timeBucket') .groupBy(truncated) - .orderBy(truncated, 'DESC') + .orderBy(truncated, options.order === AssetOrder.ASC ? 'ASC' : 'DESC') .getRawMany(); } @@ -614,7 +620,7 @@ export class AssetRepository implements IAssetRepository { // First sort by the day in localtime (put it in the right bucket) .orderBy(truncated, 'DESC') // and then sort by the actual time - .addOrderBy('asset.fileCreatedAt', 'DESC') + .addOrderBy('asset.fileCreatedAt', options.order === AssetOrder.ASC ? 'ASC' : 'DESC') .getMany() ); } diff --git a/server/src/infra/repositories/communication.repository.ts b/server/src/infra/repositories/communication.repository.ts index b7ac6ac99..6429b6e19 100644 --- a/server/src/infra/repositories/communication.repository.ts +++ b/server/src/infra/repositories/communication.repository.ts @@ -2,11 +2,13 @@ import { AuthService, ClientEvent, ICommunicationRepository, + InternalEventMap, OnConnectCallback, OnServerEventCallback, ServerEvent, } from '@app/domain'; import { ImmichLogger } from '@app/infra/logger'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { OnGatewayConnection, OnGatewayDisconnect, @@ -35,7 +37,10 @@ export class CommunicationRepository @WebSocketServer() private server?: Server; - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService, + private eventEmitter: EventEmitter2, + ) {} afterInit(server: Server) { this.logger.log('Initialized websocket server'); @@ -97,4 +102,12 @@ export class CommunicationRepository this.logger.debug(`Server event: ${event} (send)`); this.server?.serverSideEmit(event); } + + emit(event: E, data: InternalEventMap[E]): boolean { + return this.eventEmitter.emit(event, data); + } + + emitAsync(event: E, data: InternalEventMap[E]): Promise { + return this.eventEmitter.emitAsync(event, data) as Promise; + } } diff --git a/server/src/infra/repositories/filesystem.provider.ts b/server/src/infra/repositories/filesystem.provider.ts index 386ee5d71..c4f577ed2 100644 --- a/server/src/infra/repositories/filesystem.provider.ts +++ b/server/src/infra/repositories/filesystem.provider.ts @@ -11,7 +11,7 @@ import { import { ImmichLogger } from '@app/infra/logger'; import archiver from 'archiver'; import chokidar, { WatchOptions } from 'chokidar'; -import { glob } from 'fast-glob'; +import { glob, globStream } from 'fast-glob'; import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -141,10 +141,7 @@ export class FilesystemProvider implements IStorageRepository { return Promise.resolve([]); } - const base = pathsToCrawl.length === 1 ? pathsToCrawl[0] : `{${pathsToCrawl.join(',')}}`; - const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`; - - return glob(`${base}/**/${extensions}`, { + return glob(this.asGlob(pathsToCrawl), { absolute: true, caseSensitiveMatch: false, onlyFiles: true, @@ -153,6 +150,26 @@ export class FilesystemProvider implements IStorageRepository { }); } + async *walk(crawlOptions: CrawlOptionsDto): AsyncGenerator { + const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions; + if (pathsToCrawl.length === 0) { + async function* emptyGenerator() {} + return emptyGenerator(); + } + + const stream = globStream(this.asGlob(pathsToCrawl), { + absolute: true, + caseSensitiveMatch: false, + onlyFiles: true, + dot: includeHidden, + ignore: exclusionPatterns, + }); + + for await (const value of stream) { + yield value as string; + } + } + watch(paths: string[], options: WatchOptions, events: Partial) { const watcher = chokidar.watch(paths, options); @@ -164,4 +181,10 @@ export class FilesystemProvider implements IStorageRepository { return () => watcher.close(); } + + private asGlob(pathsToCrawl: string[]): string { + const base = pathsToCrawl.length === 1 ? pathsToCrawl[0] : `{${pathsToCrawl.join(',')}}`; + const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`; + return `${base}/**/${extensions}`; + } } diff --git a/server/src/infra/sql/album.repository.sql b/server/src/infra/sql/album.repository.sql index d9b2e896e..ddedc0095 100644 --- a/server/src/infra/sql/album.repository.sql +++ b/server/src/infra/sql/album.repository.sql @@ -15,6 +15,7 @@ FROM "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", + "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", @@ -91,6 +92,7 @@ SELECT "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", + "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", @@ -149,6 +151,7 @@ SELECT "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", + "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", @@ -279,6 +282,7 @@ SELECT "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", + "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", @@ -352,6 +356,7 @@ SELECT "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", + "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", @@ -462,6 +467,7 @@ SELECT "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", + "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", @@ -553,6 +559,7 @@ SELECT "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", + "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", diff --git a/server/src/infra/sql/asset.repository.sql b/server/src/infra/sql/asset.repository.sql index 75b5291b6..39f46e0d0 100644 --- a/server/src/infra/sql/asset.repository.sql +++ b/server/src/infra/sql/asset.repository.sql @@ -160,6 +160,42 @@ ORDER BY "entity"."localDateTime" DESC -- AssetRepository.getByIds +SELECT + "AssetEntity"."id" AS "AssetEntity_id", + "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", + "AssetEntity"."ownerId" AS "AssetEntity_ownerId", + "AssetEntity"."libraryId" AS "AssetEntity_libraryId", + "AssetEntity"."deviceId" AS "AssetEntity_deviceId", + "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."originalPath" AS "AssetEntity_originalPath", + "AssetEntity"."resizePath" AS "AssetEntity_resizePath", + "AssetEntity"."webpPath" AS "AssetEntity_webpPath", + "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", + "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", + "AssetEntity"."createdAt" AS "AssetEntity_createdAt", + "AssetEntity"."updatedAt" AS "AssetEntity_updatedAt", + "AssetEntity"."deletedAt" AS "AssetEntity_deletedAt", + "AssetEntity"."fileCreatedAt" AS "AssetEntity_fileCreatedAt", + "AssetEntity"."localDateTime" AS "AssetEntity_localDateTime", + "AssetEntity"."fileModifiedAt" AS "AssetEntity_fileModifiedAt", + "AssetEntity"."isFavorite" AS "AssetEntity_isFavorite", + "AssetEntity"."isArchived" AS "AssetEntity_isArchived", + "AssetEntity"."isExternal" AS "AssetEntity_isExternal", + "AssetEntity"."isReadOnly" AS "AssetEntity_isReadOnly", + "AssetEntity"."isOffline" AS "AssetEntity_isOffline", + "AssetEntity"."checksum" AS "AssetEntity_checksum", + "AssetEntity"."duration" AS "AssetEntity_duration", + "AssetEntity"."isVisible" AS "AssetEntity_isVisible", + "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", + "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", + "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", + "AssetEntity"."stackId" AS "AssetEntity_stackId" +FROM + "assets" "AssetEntity" +WHERE + (("AssetEntity"."id" IN ($1))) + +-- AssetRepository.getByIdsWithAllRelations SELECT "AssetEntity"."id" AS "AssetEntity_id", "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", diff --git a/server/src/infra/sql/shared.link.repository.sql b/server/src/infra/sql/shared.link.repository.sql index b5e689413..27531cfc9 100644 --- a/server/src/infra/sql/shared.link.repository.sql +++ b/server/src/infra/sql/shared.link.repository.sql @@ -87,6 +87,7 @@ FROM "SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt", "SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId", "SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled", + "SharedLinkEntity__SharedLinkEntity_album"."order" AS "SharedLinkEntity__SharedLinkEntity_album_order", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."id" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_id", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceAssetId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceAssetId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."ownerId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_ownerId", @@ -248,6 +249,7 @@ SELECT "SharedLinkEntity__SharedLinkEntity_album"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_album_deletedAt", "SharedLinkEntity__SharedLinkEntity_album"."albumThumbnailAssetId" AS "SharedLinkEntity__SharedLinkEntity_album_albumThumbnailAssetId", "SharedLinkEntity__SharedLinkEntity_album"."isActivityEnabled" AS "SharedLinkEntity__SharedLinkEntity_album_isActivityEnabled", + "SharedLinkEntity__SharedLinkEntity_album"."order" AS "SharedLinkEntity__SharedLinkEntity_album_order", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."avatarColor" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_avatarColor", diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index 2fdc5b5dd..bfb6acb6d 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -1,4 +1,4 @@ -import { AlbumEntity } from '@app/infra/entities'; +import { AlbumEntity, AssetOrder } from '@app/infra/entities'; import { assetStub } from './asset.stub'; import { authStub } from './auth.stub'; import { userStub } from './user.stub'; @@ -19,6 +19,7 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + order: AssetOrder.DESC, }), sharedWithUser: Object.freeze({ id: 'album-2', @@ -35,6 +36,7 @@ export const albumStub = { sharedLinks: [], sharedUsers: [userStub.user1], isActivityEnabled: true, + order: AssetOrder.DESC, }), sharedWithMultiple: Object.freeze({ id: 'album-3', @@ -51,6 +53,7 @@ export const albumStub = { sharedLinks: [], sharedUsers: [userStub.user1, userStub.user2], isActivityEnabled: true, + order: AssetOrder.DESC, }), sharedWithAdmin: Object.freeze({ id: 'album-3', @@ -67,6 +70,7 @@ export const albumStub = { sharedLinks: [], sharedUsers: [userStub.admin], isActivityEnabled: true, + order: AssetOrder.DESC, }), oneAsset: Object.freeze({ id: 'album-4', @@ -83,6 +87,7 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + order: AssetOrder.DESC, }), twoAssets: Object.freeze({ id: 'album-4a', @@ -99,6 +104,7 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + order: AssetOrder.DESC, }), emptyWithInvalidThumbnail: Object.freeze({ id: 'album-5', @@ -115,6 +121,7 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + order: AssetOrder.DESC, }), emptyWithValidThumbnail: Object.freeze({ id: 'album-5', @@ -131,6 +138,7 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + order: AssetOrder.DESC, }), oneAssetInvalidThumbnail: Object.freeze({ id: 'album-6', @@ -147,6 +155,7 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + order: AssetOrder.DESC, }), oneAssetValidThumbnail: Object.freeze({ id: 'album-6', @@ -163,5 +172,6 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + order: AssetOrder.DESC, }), }; diff --git a/server/test/fixtures/library.stub.ts b/server/test/fixtures/library.stub.ts index ec8e19683..db7687f28 100644 --- a/server/test/fixtures/library.stub.ts +++ b/server/test/fixtures/library.stub.ts @@ -1,4 +1,6 @@ +import { APP_MEDIA_LOCATION, THUMBNAIL_DIR } from '@app/domain'; import { LibraryEntity, LibraryType } from '@app/infra/entities'; +import { join } from 'node:path'; import { userStub } from './user.stub'; export const libraryStub = { @@ -100,4 +102,18 @@ export const libraryStub = { isVisible: true, exclusionPatterns: ['**/dir1/**'], }), + hasImmichPaths: Object.freeze({ + id: 'library-id1337', + name: 'importpath-exclusion-library1', + assets: [], + owner: userStub.admin, + ownerId: 'user-id', + type: LibraryType.EXTERNAL, + importPaths: [join(THUMBNAIL_DIR, 'library'), '/xyz', join(APP_MEDIA_LOCATION, 'library')], + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + refreshedAt: null, + isVisible: true, + exclusionPatterns: ['**/dir1/**'], + }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 61b44a544..109f05190 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -1,5 +1,5 @@ import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLinkResponseDto } from '@app/domain'; -import { AssetType, SharedLinkEntity, SharedLinkType, UserEntity } from '@app/infra/entities'; +import { AssetOrder, AssetType, SharedLinkEntity, SharedLinkType, UserEntity } from '@app/infra/entities'; import { assetStub } from './asset.stub'; import { authStub } from './auth.stub'; import { libraryStub } from './library.stub'; @@ -101,6 +101,7 @@ const albumResponse: AlbumResponseDto = { assets: [], assetCount: 1, isActivityEnabled: true, + order: AssetOrder.DESC, }; export const sharedLinkStub = { @@ -181,6 +182,7 @@ export const sharedLinkStub = { sharedUsers: [], sharedLinks: [], isActivityEnabled: true, + order: AssetOrder.DESC, assets: [ { id: 'id_1', diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index e1a5fed83..b291b7183 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -8,6 +8,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getByDate: jest.fn(), getByDayOfYear: jest.fn(), getByIds: jest.fn().mockResolvedValue([]), + getByIdsWithAllRelations: jest.fn().mockResolvedValue([]), getByAlbumId: jest.fn(), getByUserId: jest.fn(), getById: jest.fn(), diff --git a/server/test/repositories/communication.repository.mock.ts b/server/test/repositories/communication.repository.mock.ts index 6fb95bffd..e98e0a68f 100644 --- a/server/test/repositories/communication.repository.mock.ts +++ b/server/test/repositories/communication.repository.mock.ts @@ -6,5 +6,7 @@ export const newCommunicationRepositoryMock = (): jest.Mocked { }); const sdkMock: MockedObject = sdk as MockedObject; +const onShowContextMenu = vi.fn(); describe('AlbumCard component', () => { let sut: RenderResult; @@ -90,34 +91,30 @@ describe('AlbumCard component', () => { expect(albumDetailsElement).toHaveTextContent('0 items'); }); + it('hides context menu when "onShowContextMenu" is undefined', () => { + const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null })); + sut = render(AlbumCard, { album }); + + const contextButtonParent = sut.queryByTestId('context-button-parent'); + expect(contextButtonParent).not.toBeInTheDocument(); + }); + describe('with rendered component - no thumbnail', () => { const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null })); beforeEach(async () => { - sut = render(AlbumCard, { album }); + sut = render(AlbumCard, { album, onShowContextMenu }); const albumImgElement = sut.getByTestId('album-image'); await waitFor(() => expect(albumImgElement).toHaveAttribute('src')); }); - it('dispatches custom "click" event with the album in context', async () => { - const onClickHandler = vi.fn(); - sut.component.$on('click', onClickHandler); - const albumCardElement = sut.getByTestId('album-card'); - - await fireEvent.click(albumCardElement); - expect(onClickHandler).toHaveBeenCalledTimes(1); - expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: album })); - }); - - it('dispatches custom "click" event on context menu click with mouse coordinates', async () => { - const onClickHandler = vi.fn(); - sut.component.$on('showalbumcontextmenu', onClickHandler); - - const contextMenuButtonParent = sut.getByTestId('context-button-parent'); + it('dispatches "onShowContextMenu" event on context menu click with mouse coordinates', async () => { + const contextMenuButton = sut.getByTestId('context-button-parent').children[0]; + expect(contextMenuButton).toBeDefined(); // Mock getBoundingClientRect to return a bounding rectangle that will result in the expected position - contextMenuButtonParent.getBoundingClientRect = () => ({ + contextMenuButton.getBoundingClientRect = () => ({ x: 123, y: 456, width: 0, @@ -130,14 +127,14 @@ describe('AlbumCard component', () => { }); await fireEvent( - contextMenuButtonParent, + contextMenuButton, new MouseEvent('click', { clientX: 123, clientY: 456, }), ); - expect(onClickHandler).toHaveBeenCalledTimes(1); - expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: { x: 123, y: 456 } })); + expect(onShowContextMenu).toHaveBeenCalledTimes(1); + expect(onShowContextMenu).toHaveBeenCalledWith(expect.objectContaining({ x: 123, y: 456 })); }); }); }); diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte index 8e54af18c..295866077 100644 --- a/web/src/lib/components/album-page/album-card.svelte +++ b/web/src/lib/components/album-page/album-card.svelte @@ -5,25 +5,20 @@ import { getAssetThumbnailUrl } from '$lib/utils'; import { ThumbnailFormat, getAssetThumbnail, getUserById, type AlbumResponseDto } from '@immich/sdk'; import { mdiDotsVertical } from '@mdi/js'; - import { createEventDispatcher, onMount } from 'svelte'; - import { getContextMenuPosition } from '../../utils/context-menu'; + import { onMount } from 'svelte'; + import { getContextMenuPosition, type ContextMenuPosition } from '../../utils/context-menu'; import IconButton from '../elements/buttons/icon-button.svelte'; - import type { OnClick, OnShowContextMenu } from './album-card'; export let album: AlbumResponseDto; export let isSharingView = false; export let showItemCount = true; - export let showContextMenu = true; export let preload = false; - let showVerticalDots = false; + export let onShowContextMenu: ((position: ContextMenuPosition) => void) | undefined = undefined; $: imageData = album.albumThumbnailAssetId ? getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp) : null; - const dispatchClick = createEventDispatcher(); - const dispatchShowContextMenu = createEventDispatcher(); - const loadHighQualityThumbnail = async (assetId: string | null) => { if (!assetId) { return; @@ -33,8 +28,11 @@ return URL.createObjectURL(data); }; - const showAlbumContextMenu = (e: MouseEvent) => - dispatchShowContextMenu('showalbumcontextmenu', getContextMenuPosition(e)); + const showAlbumContextMenu = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + onShowContextMenu?.(getContextMenuPosition(e)); + }; onMount(async () => { imageData = (await loadHighQualityThumbnail(album.albumThumbnailAssetId)) || null; @@ -43,25 +41,17 @@ const getAlbumOwnerInfo = () => getUserById({ id: album.ownerId }); -
dispatchClick('click', album)} - on:keydown={() => dispatchClick('click', album)} - on:mouseenter={() => (showVerticalDots = true)} - on:mouseleave={() => (showVerticalDots = false)} + class="group relative rounded-2xl border border-transparent p-5 hover:bg-gray-100 hover:border-gray-200 dark:hover:border-gray-800 dark:hover:bg-gray-900" data-testid="album-card" > - - {#if showContextMenu} + {#if onShowContextMenu}
- +
diff --git a/web/src/lib/components/album-page/album-card.ts b/web/src/lib/components/album-page/album-card.ts deleted file mode 100644 index deb98e976..000000000 --- a/web/src/lib/components/album-page/album-card.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { AlbumResponseDto } from '@immich/sdk'; - -export type OnShowContextMenu = { - showalbumcontextmenu: OnShowContextMenuDetail; -}; - -export type OnClick = { - click: OnClickDetail; -}; - -export type OnShowContextMenuDetail = { x: number; y: number }; -export type OnClickDetail = AlbumResponseDto; diff --git a/web/src/lib/components/album-page/album-description.svelte b/web/src/lib/components/album-page/album-description.svelte index 9e988ba75..e860fbcd2 100644 --- a/web/src/lib/components/album-page/album-description.svelte +++ b/web/src/lib/components/album-page/album-description.svelte @@ -2,6 +2,7 @@ import { autoGrowHeight } from '$lib/utils/autogrow'; import { updateAlbumInfo } from '@immich/sdk'; import { handleError } from '$lib/utils/handle-error'; + import { shortcut } from '$lib/utils/shortcut'; export let id: string; export let description: string; @@ -37,6 +38,10 @@ on:focusout={handleUpdateDescription} use:autoGrowHeight placeholder="Add description" + use:shortcut={{ + shortcut: { key: 'Enter', ctrl: true }, + onShortcut: (e) => e.currentTarget.blur(), + }} /> {:else if description}

diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte index 6cbce418b..d5f816047 100644 --- a/web/src/lib/components/album-page/album-options.svelte +++ b/web/src/lib/components/album-page/album-options.svelte @@ -1,22 +1,55 @@ dispatch('close')}> @@ -34,8 +67,16 @@

-

SHARING

-
+

SETTINGS

+
+ {#if order} + + {/if} import { updateAlbumInfo } from '@immich/sdk'; import { handleError } from '$lib/utils/handle-error'; + import { shortcut } from '$lib/utils/shortcut'; export let id: string; export let albumName: string; @@ -29,7 +30,7 @@ e.key === 'Enter' && e.currentTarget.blur()} + use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }} on:blur={handleUpdateName} class="w-[99%] mb-2 border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned ? 'hover:border-gray-400' diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index ab5eb91d5..08d610ceb 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,11 +1,9 @@ + { + if (!$showAssetViewer && $isMultiSelectState) { + assetInteractionStore.clearMultiselect(); + } + }, + }} +/> +
{#if $isMultiSelectState} - - + + diff --git a/web/src/lib/components/album-page/albums-controls.svelte b/web/src/lib/components/album-page/albums-controls.svelte new file mode 100644 index 000000000..a58dbc0e3 --- /dev/null +++ b/web/src/lib/components/album-page/albums-controls.svelte @@ -0,0 +1,68 @@ + + + + +
+ + Create album +
+
+ + { + return { + title: option.title, + icon: option.sortDesc ? mdiArrowDownThin : mdiArrowUpThin, + }; + }} + on:select={(event) => { + for (const key of sortByOptions) { + if (key.title === event.detail.title) { + key.sortDesc = !key.sortDesc; + $albumViewSettings.sortBy = key.title; + break; + } + } + }} +/> + + handleChangeListMode()}> +
+ {#if $albumViewSettings.view === AlbumViewMode.List} + + + {:else} + + + {/if} +
+
diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte new file mode 100644 index 000000000..607457e1c --- /dev/null +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -0,0 +1,282 @@ + + + + +{#if shouldShowEditAlbumForm} + (shouldShowEditAlbumForm = false)}> + successModifyAlbum()} + on:cancel={() => (shouldShowEditAlbumForm = false)} + /> + +{/if} + +{#if albums.length > 0} + + {#if $albumViewSettings.view === AlbumViewMode.Cover} +
+ {#each albumsFiltered as album, index (album.id)} + + showAlbumContextMenu(position, album)} + /> + + {/each} +
+ {:else if $albumViewSettings.view === AlbumViewMode.List} + chooseAlbumToDelete(album)} + onAlbumToEdit={(album) => handleEdit(album)} + /> + {/if} + + +{:else} + +{/if} + + +{#if isShowContextMenu} +
+ + setAlbumToDelete()}> + + +

Delete album

+
+
+
+
+{/if} + +{#if albumToDelete} + (albumToDelete = null)} + > + +

Are you sure you want to delete the album {albumToDelete.albumName}?

+

If this album is shared, other users will not be able to access it anymore.

+
+
+{/if} diff --git a/web/src/lib/components/elements/table-header.svelte b/web/src/lib/components/album-page/albums-table-header.svelte similarity index 57% rename from web/src/lib/components/elements/table-header.svelte rename to web/src/lib/components/album-page/albums-table-header.svelte index 0b68dd0e5..b10e34e11 100644 --- a/web/src/lib/components/elements/table-header.svelte +++ b/web/src/lib/components/album-page/albums-table-header.svelte @@ -1,14 +1,15 @@ @@ -18,7 +19,7 @@ class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50" on:click={() => handleSort()} > - {#if albumViewSettings === option.title} + {#if $albumViewSettings.sortBy === option.title} {#if option.sortDesc} ↓ {:else} diff --git a/web/src/lib/components/album-page/albums-table.svelte b/web/src/lib/components/album-page/albums-table.svelte new file mode 100644 index 000000000..e77dc93d8 --- /dev/null +++ b/web/src/lib/components/album-page/albums-table.svelte @@ -0,0 +1,85 @@ + + + + + + {#each sortByOptions as option, index (index)} + + {/each} + + + + + {#each albumsFiltered as album (album.id)} + goto(`${AppRoute.ALBUMS}/${album.id}`)} + > + + + + + + + + + + + {/each} + +
{album.albumName} + {album.assetCount} + {album.assetCount > 1 ? `items` : `item`} +
diff --git a/web/src/lib/components/album-page/thumbnail-selection.svelte b/web/src/lib/components/album-page/thumbnail-selection.svelte index c12abc891..e558592b2 100644 --- a/web/src/lib/components/album-page/thumbnail-selection.svelte +++ b/web/src/lib/components/album-page/thumbnail-selection.svelte @@ -45,7 +45,7 @@
{#each album.assets as asset (asset.id)} - (selectedThumbnail = asset)} selected={isSelected(asset.id)} /> + (selectedThumbnail = asset)} selected={isSelected(asset.id)} /> {/each}
diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 46305314b..cf98c527f 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -25,6 +25,7 @@ import { NotificationType, notificationController } from '../shared-components/notification/notification'; import UserAvatar from '../shared-components/user-avatar.svelte'; import { locale } from '$lib/stores/preferences.store'; + import { shortcut } from '$lib/utils/shortcut'; const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; @@ -95,14 +96,6 @@ } }; - const handleEnter = async (event: KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - await handleSendComment(); - return; - } - }; - const timeOptions = { year: 'numeric', month: '2-digit', @@ -295,7 +288,10 @@ use:autoGrowHeight={'5px'} placeholder={disabled ? 'Comments are disabled' : 'Say something'} on:input={() => autoGrowHeight(textArea, '5px')} - on:keypress={handleEnter} + use:shortcut={{ + shortcut: { key: 'Enter' }, + onShortcut: () => handleSendComment(), + }} class="h-[18px] {disabled ? 'cursor-not-allowed' : ''} w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200" diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index b1f70cbeb..f9d2f787b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -13,7 +13,7 @@ import { getAssetJobMessage, isSharedLink, handlePromiseError } from '$lib/utils'; import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; - import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; + import { shortcuts } from '$lib/utils/shortcut'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { AssetJobName, @@ -55,6 +55,7 @@ export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; + export let preloadAssets: AssetResponseDto[] = []; export let showNavigation = true; export let sharedLink: SharedLinkResponseDto | undefined = undefined; $: isTrashEnabled = $featureFlags.trash; @@ -103,6 +104,11 @@ $stackAssetsStore = [...$stackAssetsStore, asset].sort( (a, b) => new Date(b.fileCreatedAt).getTime() - new Date(a.fileCreatedAt).getTime(), ); + + // if its a stack, add the next stack image in addition to the next asset + if (asset.stackCount > 1) { + preloadAssets.push($stackAssetsStore[1]); + } } if (!$stackAssetsStore.map((a) => a.id).includes(asset.id)) { @@ -250,68 +256,9 @@ isShowActivity = !isShowActivity; }; - const handleKeypress = async (event: KeyboardEvent) => { - if (shouldIgnoreShortcut(event)) { - return; - } - - const key = event.key; - const shiftKey = event.shiftKey; - const ctrlKey = event.ctrlKey; - - if (ctrlKey) { - return; - } - - switch (key) { - case 'a': - case 'A': { - if (shiftKey) { - await toggleArchive(); - } - return; - } - case 'ArrowLeft': { - await navigateAsset('previous'); - return; - } - case 'ArrowRight': { - await navigateAsset('next'); - return; - } - case 'd': - case 'D': { - if (shiftKey) { - await downloadFile(asset); - } - return; - } - case 'Delete': { - await trashOrDelete(shiftKey); - return; - } - case 'Escape': { - if (isShowDeleteConfirmation) { - isShowDeleteConfirmation = false; - return; - } - if (isShowShareModal) { - isShowShareModal = false; - return; - } - closeViewer(); - return; - } - case 'f': { - await toggleFavorite(); - return; - } - case 'i': { - isShowActivity = false; - $isShowDetail = !$isShowDetail; - return; - } - } + const toggleDetailPanel = () => { + isShowActivity = false; + $isShowDetail = !$isShowDetail; }; const handleCloseViewer = () => { @@ -551,7 +498,19 @@ }; - + navigateAsset('previous') }, + { shortcut: { key: 'ArrowRight' }, onShortcut: () => navigateAsset('next') }, + { shortcut: { key: 'd', shift: true }, onShortcut: () => downloadFile(asset) }, + { shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete(false) }, + { shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) }, + { shortcut: { key: 'Escape' }, onShortcut: closeViewer }, + { shortcut: { key: 'f' }, onShortcut: toggleFavorite }, + { shortcut: { key: 'i' }, onShortcut: toggleDetailPanel }, + ]} +/>
+ {:else} {:else} - + {/if} {:else}
- {#each $stackAssetsStore as stackedAsset (stackedAsset.id)} + {#each $stackAssetsStore as stackedAsset, index (stackedAsset.id)}
(asset = stackedAsset)} + onClick={() => { + asset = stackedAsset; + preloadAssets = index + 1 >= $stackAssetsStore.length ? [] : [$stackAssetsStore[index + 1]]; + }} on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)} readonly thumbnailSize={stackedAsset.id == asset.id ? 65 : 60} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 334b406bb..318007280 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -41,6 +41,7 @@ import UserAvatar from '../shared-components/user-avatar.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; + import { shortcut } from '$lib/utils/shortcut'; export let asset: AssetResponseDto; export let albums: AlbumResponseDto[] = []; @@ -105,20 +106,6 @@ closeViewer: void; }>(); - const handleKeypress = async (event: KeyboardEvent) => { - if (event.target !== textArea) { - return; - } - const ctrl = event.ctrlKey; - switch (event.key) { - case 'Enter': { - if (ctrl && event.target === textArea) { - await handleFocusOut(); - } - } - } - }; - const getMegapixel = (width: number, height: number): number | undefined => { const megapixel = Math.round((height * width) / 1_000_000); @@ -180,8 +167,6 @@ } - -
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 00ef670c9..14bbb6b1f 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -6,8 +6,8 @@ import { downloadRequest, getAssetFileUrl, handlePromiseError } from '$lib/utils'; import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; - import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; - import { type AssetResponseDto } from '@immich/sdk'; + import { shortcuts } from '$lib/utils/shortcut'; + import { type AssetResponseDto, AssetTypeEnum } from '@immich/sdk'; import { useZoomImageWheel } from '@zoom-image/svelte'; import { onDestroy, onMount } from 'svelte'; import { fade } from 'svelte/transition'; @@ -16,6 +16,7 @@ import { getAltText } from '$lib/utils/thumbnail-util'; export let asset: AssetResponseDto; + export let preloadAssets: AssetResponseDto[] | null = null; export let element: HTMLDivElement | undefined = undefined; export let haveFadeTransition = true; @@ -25,6 +26,7 @@ let hasZoomed = false; let copyImageToClipboard: (source: string) => Promise; let canCopyImagesToClipboard: () => boolean; + let imageLoaded: boolean = false; const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset); @@ -41,6 +43,9 @@ const module = await import('copy-image-clipboard'); copyImageToClipboard = module.copyImageToClipboard; canCopyImagesToClipboard = module.canCopyImagesToClipboard; + + imageLoaded = false; + await loadAssetData({ loadOriginal: loadOriginalByDefault }); }); onDestroy(() => { @@ -60,20 +65,22 @@ }); assetData = URL.createObjectURL(data); - } catch { - // Do nothing - } - }; + imageLoaded = true; - const handleKeypress = async (event: KeyboardEvent) => { - if (shouldIgnoreShortcut(event)) { - return; - } - if (window.getSelection()?.type === 'Range') { - return; - } - if ((event.metaKey || event.ctrlKey) && event.key === 'c') { - await doCopy(); + if (!preloadAssets) { + return; + } + + for (const preloadAsset of preloadAssets) { + if (preloadAsset.type === AssetTypeEnum.Image) { + await downloadRequest({ + url: getAssetFileUrl(preloadAsset.id, !loadOriginal, false), + signal: abortController.signal, + }); + } + } + } catch { + imageLoaded = false; } }; @@ -119,18 +126,32 @@ handlePromiseError(loadAssetData({ loadOriginal: true })); } }); + + const onCopyShortcut = () => { + if (window.getSelection()?.type === 'Range') { + return; + } + handlePromiseError(doCopy()); + }; - +
- {#await loadAssetData({ loadOriginal: loadOriginalByDefault })} + {#if !imageLoaded} - {:then} + {:else}
{/each}
- {/await} + {/if}
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 796be13be..efbe38424 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -21,9 +21,9 @@ import { fade } from 'svelte/transition'; import ImageThumbnail from './image-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte'; + import { shortcut } from '$lib/utils/shortcut'; const dispatch = createEventDispatcher<{ - click: { asset: AssetResponseDto }; select: { asset: AssetResponseDto }; 'mouse-event': { isMouseOver: boolean; selectedGroupIndex: number }; }>(); @@ -40,12 +40,13 @@ export let readonly = false; export let showArchiveIcon = false; export let showStackedIcon = true; - export let intersecting = false; + export let onClick: ((asset: AssetResponseDto) => void) | undefined = undefined; let className = ''; export { className as class }; let mouseOver = false; + $: clickable = !disabled && onClick; $: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex }); @@ -62,14 +63,8 @@ })(); const thumbnailClickedHandler = () => { - if (!disabled) { - dispatch('click', { asset }); - } - }; - - const thumbnailKeyDownHandler = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - thumbnailClickedHandler(); + if (clickable) { + onClick?.(asset); } }; @@ -89,20 +84,22 @@ }; - - + +
{#if intersecting}
@@ -140,6 +137,11 @@ class:rounded-xl={selected} /> + +
+ {#if !isSharedLink() && asset.isFavorite}
diff --git a/web/src/lib/components/elements/buttons/link-button.svelte b/web/src/lib/components/elements/buttons/link-button.svelte index 2cb22d41d..6911fd2fc 100644 --- a/web/src/lib/components/elements/buttons/link-button.svelte +++ b/web/src/lib/components/elements/buttons/link-button.svelte @@ -8,8 +8,9 @@ export let color: Color = 'transparent-gray'; export let disabled = false; export let fullwidth = false; + export let title: string | undefined = undefined; - diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index 13a797a9c..cdd25c8d6 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -13,6 +13,7 @@ export let user: UserResponseDto; export let canResetPassword = true; + export let newPassword: string; let error: string; let success: string; @@ -53,12 +54,12 @@ const resetPassword = async () => { try { - const defaultPassword = 'password'; + newPassword = generatePassword(); await updateUser({ updateUserDto: { id: user.id, - password: defaultPassword, + password: newPassword, shouldChangePassword: true, }, }); @@ -70,6 +71,23 @@ isShowResetPasswordConfirmation = false; } }; + + // TODO move password reset server-side + function generatePassword(length: number = 16) { + let generatedPassword = ''; + + const characterSet = '0123456789' + 'abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + ',.-{}+!#$%/()=?'; + + for (let i = 0; i < length; i++) { + let randomNumber = crypto.getRandomValues(new Uint32Array(1))[0]; + randomNumber = randomNumber / 2 ** 32; + randomNumber = Math.floor(randomNumber * characterSet.length); + + generatedPassword += characterSet[randomNumber]; + } + + return generatedPassword; + }
{ - if (e.key === 'ArrowRight' && canGoForward) { - e.preventDefault(); - await toNext(); - } else if (e.key === 'ArrowLeft' && canGoBack) { - e.preventDefault(); - await toPrevious(); - } else if (e.key === 'Escape') { - e.preventDefault(); - await goto(AppRoute.PHOTOS); - } - }; - onMount(async () => { if (!$memoryStore) { const localTime = new Date(); @@ -101,7 +89,13 @@ let galleryInView = false; - + canGoForward && toNext() }, + { shortcut: { key: 'ArrowLeft' }, onShortcut: () => canGoBack && toPrevious() }, + { shortcut: { key: 'Escape' }, onShortcut: () => goto(AppRoute.PHOTOS) }, + ]} +/>
{#if currentMemory} diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 6f7af44ef..e3af25984 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -178,7 +178,7 @@ {showArchiveIcon} {asset} {groupIndex} - on:click={() => assetClickHandler(asset, groupAssets, groupTitle)} + onClick={() => assetClickHandler(asset, groupAssets, groupTitle)} on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)} on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)} selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index b66bfc96f..c21439d24 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -1,5 +1,4 @@ - + {#if isShowDeleteConfirmation} goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}> - - + + diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 786e8721b..bf5c4f5cf 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -1,7 +1,7 @@ - -
-
(hideSuggestion = true)}> +
(hideSuggestion = true) }} + use:listNavigation={suggestionContainer} + > -
+
{#if !hideSuggestion} {#each suggestedPlaces as place, index} - {/each} +
+ {#each suggestedPeople as person, index (person.id)} + + {/each} +
{/if}
{/if} diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index c9893166f..77ebe7ad2 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -20,7 +20,7 @@ import { AppRoute, QueryParameter } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { preventRaceConditionSearchBar } from '$lib/stores/search.store'; - import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; + import { shortcut } from '$lib/utils/shortcut'; import { type AssetResponseDto, searchSmart, @@ -52,27 +52,19 @@ let searchResultAssets: AssetResponseDto[] = []; let isLoading = true; - const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); - - const handleKeyboardPress = async (event: KeyboardEvent) => { - if (shouldIgnoreShortcut(event)) { + const onEscape = () => { + if ($showAssetViewer) { return; } - if (!$showAssetViewer) { - switch (event.key) { - case 'Escape': { - if (isMultiSelectionMode) { - selectedAssets = new Set(); - return; - } - if (!$preventRaceConditionSearchBar) { - await goto(previousRoute); - } - $preventRaceConditionSearchBar = false; - return; - } - } + + if (isMultiSelectionMode) { + selectedAssets = new Set(); + return; } + if (!$preventRaceConditionSearchBar) { + handlePromiseError(goto(previousRoute)); + } + $preventRaceConditionSearchBar = false; }; afterNavigate(({ from }) => { @@ -201,7 +193,7 @@ } - +
{#if isMultiSelectionMode} @@ -275,16 +267,10 @@ {#if searchResultAlbums.length > 0}
ALBUMS
-
+
{#each searchResultAlbums as album, index (album.id)} - + {/each}
diff --git a/web/src/routes/(user)/share/[key]/+page.svelte b/web/src/routes/(user)/share/[key]/+page.svelte index f64575924..1b87063da 100644 --- a/web/src/routes/(user)/share/[key]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/+page.svelte @@ -38,7 +38,7 @@ - + diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte index 2d6e5b532..8ff990644 100644 --- a/web/src/routes/(user)/sharing/+page.svelte +++ b/web/src/routes/(user)/sharing/+page.svelte @@ -80,10 +80,10 @@
-
+
{#each data.sharedAlbums as album, index (album.id)} - + {/each}
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index dbcee1b91..a0fb30b8b 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -1,6 +1,8 @@