diff --git a/.github/mise.toml b/.github/mise.toml new file mode 100644 index 0000000000..6930d41187 --- /dev/null +++ b/.github/mise.toml @@ -0,0 +1,10 @@ +[tasks.install] +run = "pnpm install --filter github --frozen-lockfile" + +[tasks.format] +env._.path = "./node_modules/.bin" +run = "prettier --check ." + +[tasks."format-fix"] +env._.path = "./node_modules/.bin" +run = "prettier --write ." diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index e39a8f5ea9..c214ba564e 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -165,7 +165,7 @@ jobs: fi - name: Publish Android Artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: release-apk-signed path: mobile/build/app/outputs/flutter-apk/*.apk @@ -188,8 +188,8 @@ jobs: needs: pre-job permissions: contents: read - # Run on main branch or workflow_dispatch - if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true && github.ref == 'refs/heads/main' }} + # Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload) + if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }} runs-on: macos-latest steps: @@ -303,12 +303,20 @@ jobs: APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} ENVIRONMENT: ${{ inputs.environment || 'development' }} + BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }} + GITHUB_REF: ${{ github.ref }} working-directory: ./mobile/ios run: | - if [[ "$ENVIRONMENT" == "development" ]]; then - bundle exec fastlane gha_testflight_dev + # Only upload to TestFlight on main branch + if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then + if [[ "$ENVIRONMENT" == "development" ]]; then + bundle exec fastlane gha_testflight_dev + else + bundle exec fastlane gha_release_prod + fi else - bundle exec fastlane gha_release_prod + # Build only, no TestFlight upload for non-main branches + bundle exec fastlane gha_build_only fi - name: Clean up keychain @@ -317,7 +325,7 @@ jobs: security delete-keychain build.keychain || true - name: Upload IPA artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ios-release-ipa path: mobile/ios/Runner.ipa diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index dae8cec1fd..fc2c9f6853 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -84,7 +84,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 @@ -105,7 +105,7 @@ jobs: - name: Generate docker image tags id: metadata - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 with: flavor: | latest=false diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml index ba360b50dc..b3c79f81d8 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -35,7 +35,7 @@ jobs: needs: [get_body, should_run] if: ${{ needs.should_run.outputs.should_run == 'true' }} container: - image: ghcr.io/immich-app/mdq:main@sha256:6b8450bfc06770af1af66bce9bf2ced7d1d9b90df1a59fc4c83a17777a9f6723 + image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271 outputs: checked: ${{ steps.get_checkbox.outputs.checked }} steps: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3f32478c0c..34228843ad 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 2a28b57569..823aa98fc8 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -85,7 +85,7 @@ jobs: run: pnpm build - name: Upload build output - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: docs-build-output path: docs/build/ diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index a74a2ec613..5d01646fef 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -174,7 +174,7 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} working-directory: 'deployment/modules/cloudflare/docs' - run: 'mise run tf apply' + run: 'mise run //deployment:tf apply' - name: Deploy Docs Subdomain Output id: docs-output @@ -186,7 +186,7 @@ jobs: TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} working-directory: 'deployment/modules/cloudflare/docs' run: | - mise run tf output -- -json | jq -r ' + mise run //deployment:tf output -- -json | jq -r ' "projectName=\(.pages_project_name.value)", "subdomain=\(.immich_app_branch_subdomain.value)" ' >> $GITHUB_OUTPUT @@ -211,7 +211,7 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} working-directory: 'deployment/modules/cloudflare/docs-release' - run: 'mise run tf apply' + run: 'mise run //deployment:tf apply' - name: Comment uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0 diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index 7de2d81858..3ad3f3558e 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -39,7 +39,7 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} working-directory: 'deployment/modules/cloudflare/docs' - run: 'mise run tf destroy -- -refresh=false' + run: 'mise run //deployment:tf destroy -- -refresh=false' - name: Comment uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 77f32ace4f..6e7ee8f608 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -62,7 +62,7 @@ jobs: ref: main - name: Install uv - uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1 + uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 @@ -138,7 +138,7 @@ jobs: persist-credentials: false - name: Download APK - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: release-apk-signed github-token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 0000000000..524f6bc77c --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,170 @@ +name: Manage release PR +on: + workflow_dispatch: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +permissions: {} + +jobs: + bump: + runs-on: ubuntu-latest + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + with: + app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} + private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + token: ${{ steps.generate-token.outputs.token }} + persist-credentials: true + ref: main + + - name: Install uv + uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 + + - name: Setup pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + + - name: Setup Node + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version-file: './server/.nvmrc' + cache: 'pnpm' + cache-dependency-path: '**/pnpm-lock.yaml' + + - name: Determine release type + id: bump-type + uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0 + with: + token: ${{ steps.generate-token.outputs.token }} + + - name: Bump versions + env: + TYPE: ${{ steps.bump-type.outputs.bump }} + run: | + if [ "$TYPE" == "none" ]; then + exit 1 # TODO: Is there a cleaner way to abort the workflow? + fi + misc/release/pump-version.sh -s $TYPE -m true + + - name: Manage Outline release document + id: outline + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }} + NEXT_VERSION: ${{ steps.bump-type.outputs.next }} + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const fs = require('fs'); + + const outlineKey = process.env.OUTLINE_API_KEY; + const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9' + const collectionId = 'e2910656-714c-4871-8721-447d9353bd73'; + const baseUrl = 'https://outline.immich.cloud'; + + const listResponse = await fetch(`${baseUrl}/api/documents.list`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${outlineKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ parentDocumentId }) + }); + + if (!listResponse.ok) { + throw new Error(`Outline list failed: ${listResponse.statusText}`); + } + + const listData = await listResponse.json(); + const allDocuments = listData.data || []; + + const document = allDocuments.find(doc => doc.title === 'next'); + + let documentId; + let documentUrl; + let documentText; + + if (!document) { + // Create new document + console.log('No existing document found. Creating new one...'); + const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8'); + const createResponse = await fetch(`${baseUrl}/api/documents.create`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${outlineKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: 'next', + text: notesTmpl, + collectionId: collectionId, + parentDocumentId: parentDocumentId, + publish: true + }) + }); + + if (!createResponse.ok) { + throw new Error(`Failed to create document: ${createResponse.statusText}`); + } + + const createData = await createResponse.json(); + documentId = createData.data.id; + const urlId = createData.data.urlId; + documentUrl = `${baseUrl}/doc/next-${urlId}`; + documentText = createData.data.text || ''; + console.log(`Created new document: ${documentUrl}`); + } else { + documentId = document.id; + const docPath = document.url; + documentUrl = `${baseUrl}${docPath}`; + documentText = document.text || ''; + console.log(`Found existing document: ${documentUrl}`); + } + + // Generate GitHub release notes + console.log('Generating GitHub release notes...'); + const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: `${process.env.NEXT_VERSION}`, + }); + + // Combine the content + const changelog = ` + # ${process.env.NEXT_VERSION} + + ${documentText} + + ${releaseNotesResponse.data.body} + + --- + + ` + + const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : ''; + fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8'); + + core.setOutput('document_url', documentUrl); + + - name: Create PR + id: create-pr + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + with: + token: ${{ steps.generate-token.outputs.token }} + commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}' + title: 'chore: release ${{ steps.bump-type.outputs.next }}' + body: 'Release notes: ${{ steps.outline.outputs.document_url }}' + labels: 'changelog:skip' + branch: 'release/next' + draft: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c7eae6532..44d7250f2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -382,6 +382,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false + submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 @@ -562,7 +563,7 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Install uv - uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1 + uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) # with: diff --git a/cli/mise.toml b/cli/mise.toml new file mode 100644 index 0000000000..740184e03d --- /dev/null +++ b/cli/mise.toml @@ -0,0 +1,29 @@ +[tasks.install] +run = "pnpm install --filter @immich/cli --frozen-lockfile" + +[tasks.build] +env._.path = "./node_modules/.bin" +run = "vite build" + +[tasks.test] +env._.path = "./node_modules/.bin" +run = "vite" + +[tasks.lint] +env._.path = "./node_modules/.bin" +run = "eslint \"src/**/*.ts\" --max-warnings 0" + +[tasks."lint-fix"] +run = { task = "lint --fix" } + +[tasks.format] +env._.path = "./node_modules/.bin" +run = "prettier --check ." + +[tasks."format-fix"] +env._.path = "./node_modules/.bin" +run = "prettier --write ." + +[tasks.check] +env._.path = "./node_modules/.bin" +run = "tsc --noEmit" diff --git a/cli/package.json b/cli/package.json index 0dc0136cf2..6fed806003 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.100", + "version": "2.2.101", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", @@ -20,7 +20,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.18.12", + "@types/node": "^22.19.0", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/deployment/mise.toml b/deployment/mise.toml new file mode 100644 index 0000000000..f3d07ac31f --- /dev/null +++ b/deployment/mise.toml @@ -0,0 +1,20 @@ +[tools] +terragrunt = "0.91.2" +opentofu = "1.10.6" + +[tasks."tg:fmt"] +run = "terragrunt hclfmt" +description = "Format terragrunt files" + +[tasks.tf] +run = "terragrunt run --all" +description = "Wrapper for terragrunt run-all" +dir = "{{cwd}}" + +[tasks."tf:fmt"] +run = "tofu fmt -recursive tf/" +description = "Format terraform files" + +[tasks."tf:init"] +run = { task = "tf init -- -reconfigure" } +dir = "{{cwd}}" diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5968c5bb3a..e2fb8fbc30 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -41,6 +41,7 @@ services: - app-node_modules:/usr/src/app/node_modules - sveltekit:/usr/src/app/web/.svelte-kit - coverage:/usr/src/app/web/coverage + - ../plugins:/build/corePlugin env_file: - .env environment: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index a8c0de7454..e01f4ead22 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -83,7 +83,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7 + image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md index fd9b8a5e4d..2b7527623f 100644 --- a/docs/docs/administration/postgres-standalone.md +++ b/docs/docs/administration/postgres-standalone.md @@ -10,16 +10,19 @@ Running with a pre-existing Postgres server can unlock powerful administrative f ## Prerequisites -You must install `pgvector` (`>= 0.7.0, < 1.0.0`), as it is a prerequisite for `vchord`. +You must install pgvector as it is a prerequisite for VectorChord. The easiest way to do this on Debian/Ubuntu is by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`). You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`. -:::note -Immich is known to work with Postgres versions `>= 14, < 18`. +:::note Supported versions +Immich is known to work with Postgres versions `>= 14, < 19`. -Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 0.5.0`. +VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`. + +The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found. +The current accepted range for VectorChord is `>= 0.3, < 0.6`. ::: ## Specifying the connection URL diff --git a/docs/docs/developer/database-migrations.md b/docs/docs/developer/database-migrations.md index f032048b7a..a73e7e747c 100644 --- a/docs/docs/developer/database-migrations.md +++ b/docs/docs/developer/database-migrations.md @@ -12,3 +12,13 @@ pnpm run migrations:generate 3. Move the migration file to folder `./server/src/schema/migrations` in your code editor. The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately. + +## Reverting a Migration + +If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run: + +```bash +pnpm run migrations:revert +``` + +This command rolls back the latest migration and brings the database schema back to its previous state. diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index bf1003b781..f262743dcd 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -5,7 +5,7 @@ sidebar_position: 2 # Setup :::note -If there's a feature you're planning to work on, just give us a heads up in [Discord](https://discord.com/channels/979116623879368755/1071165397228855327) so we can: +If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can: 1. Let you know if it's something we would accept into Immich 2. Provide any guidance on how something like that would ideally be implemented diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index 5cdcdc04c4..c2328d2bb8 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -106,14 +106,14 @@ SELECT "user"."email", "asset"."type", COUNT(*) FROM "asset" ```sql title="Count by tag" SELECT "t"."value" AS "tag_name", COUNT(*) AS "number_assets" FROM "tag" "t" - JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id" + JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id" WHERE "a"."visibility" != 'hidden' GROUP BY "t"."value" ORDER BY "number_assets" DESC; ``` ```sql title="Count by tag (per user)" SELECT "t"."value" AS "tag_name", "u"."email" as "user_email", COUNT(*) AS "number_assets" FROM "tag" "t" - JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id" + JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id" WHERE "a"."visibility" != 'hidden' GROUP BY "t"."value", "u"."email" ORDER BY "number_assets" DESC; ``` diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index 3fb0687e4a..a6aaae149b 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -16,48 +16,76 @@ The default configuration looks like this: ```json { - "ffmpeg": { - "crf": 23, - "threads": 0, - "preset": "ultrafast", - "targetVideoCodec": "h264", - "acceptedVideoCodecs": ["h264"], - "targetAudioCodec": "aac", - "acceptedAudioCodecs": ["aac", "mp3", "libopus", "pcm_s16le"], - "acceptedContainers": ["mov", "ogg", "webm"], - "targetResolution": "720", - "maxBitrate": "0", - "bframes": -1, - "refs": 0, - "gopSize": 0, - "temporalAQ": false, - "cqMode": "auto", - "twoPass": false, - "preferredHwDevice": "auto", - "transcode": "required", - "tonemap": "hable", - "accel": "disabled", - "accelDecode": false - }, "backup": { "database": { - "enabled": true, "cronExpression": "0 02 * * *", + "enabled": true, "keepLastAmount": 14 } }, + "ffmpeg": { + "accel": "disabled", + "accelDecode": false, + "acceptedAudioCodecs": ["aac", "mp3", "libopus"], + "acceptedContainers": ["mov", "ogg", "webm"], + "acceptedVideoCodecs": ["h264"], + "bframes": -1, + "cqMode": "auto", + "crf": 23, + "gopSize": 0, + "maxBitrate": "0", + "preferredHwDevice": "auto", + "preset": "ultrafast", + "refs": 0, + "targetAudioCodec": "aac", + "targetResolution": "720", + "targetVideoCodec": "h264", + "temporalAQ": false, + "threads": 0, + "tonemap": "hable", + "transcode": "required", + "twoPass": false + }, + "image": { + "colorspace": "p3", + "extractEmbedded": false, + "fullsize": { + "enabled": false, + "format": "jpeg", + "quality": 80 + }, + "preview": { + "format": "jpeg", + "quality": 80, + "size": 1440 + }, + "thumbnail": { + "format": "webp", + "quality": 80, + "size": 250 + } + }, "job": { "backgroundTask": { "concurrency": 5 }, - "smartSearch": { + "faceDetection": { "concurrency": 2 }, + "library": { + "concurrency": 5 + }, "metadataExtraction": { "concurrency": 5 }, - "faceDetection": { - "concurrency": 2 + "migration": { + "concurrency": 5 + }, + "notifications": { + "concurrency": 5 + }, + "ocr": { + "concurrency": 1 }, "search": { "concurrency": 5 @@ -65,20 +93,23 @@ The default configuration looks like this: "sidecar": { "concurrency": 5 }, - "library": { - "concurrency": 5 - }, - "migration": { - "concurrency": 5 + "smartSearch": { + "concurrency": 2 }, "thumbnailGeneration": { "concurrency": 3 }, "videoConversion": { "concurrency": 1 + } + }, + "library": { + "scan": { + "cronExpression": "0 0 * * *", + "enabled": true }, - "notifications": { - "concurrency": 5 + "watch": { + "enabled": false } }, "logging": { @@ -86,8 +117,11 @@ The default configuration looks like this: "level": "log" }, "machineLearning": { - "enabled": true, - "urls": ["http://immich-machine-learning:3003"], + "availabilityChecks": { + "enabled": true, + "interval": 30000, + "timeout": 2000 + }, "clip": { "enabled": true, "modelName": "ViT-B-32__openai" @@ -96,27 +130,59 @@ The default configuration looks like this: "enabled": true, "maxDistance": 0.01 }, + "enabled": true, "facialRecognition": { "enabled": true, - "modelName": "buffalo_l", - "minScore": 0.7, "maxDistance": 0.5, - "minFaces": 3 - } + "minFaces": 3, + "minScore": 0.7, + "modelName": "buffalo_l" + }, + "ocr": { + "enabled": true, + "maxResolution": 736, + "minDetectionScore": 0.5, + "minRecognitionScore": 0.8, + "modelName": "PP-OCRv5_mobile" + }, + "urls": ["http://immich-machine-learning:3003"] }, "map": { + "darkStyle": "https://tiles.immich.cloud/v1/style/dark.json", "enabled": true, - "lightStyle": "https://tiles.immich.cloud/v1/style/light.json", - "darkStyle": "https://tiles.immich.cloud/v1/style/dark.json" - }, - "reverseGeocoding": { - "enabled": true + "lightStyle": "https://tiles.immich.cloud/v1/style/light.json" }, "metadata": { "faces": { "import": false } }, + "newVersionCheck": { + "enabled": true + }, + "nightlyTasks": { + "clusterNewFaces": true, + "databaseCleanup": true, + "generateMemories": true, + "missingThumbnails": true, + "startTime": "00:00", + "syncQuotaUsage": true + }, + "notifications": { + "smtp": { + "enabled": false, + "from": "", + "replyTo": "", + "transport": { + "host": "", + "ignoreCert": false, + "password": "", + "port": 587, + "secure": false, + "username": "" + } + } + }, "oauth": { "autoLaunch": false, "autoRegister": true, @@ -128,70 +194,44 @@ The default configuration looks like this: "issuerUrl": "", "mobileOverrideEnabled": false, "mobileRedirectUri": "", + "profileSigningAlgorithm": "none", + "roleClaim": "immich_role", "scope": "openid email profile", "signingAlgorithm": "RS256", - "profileSigningAlgorithm": "none", "storageLabelClaim": "preferred_username", - "storageQuotaClaim": "immich_quota" + "storageQuotaClaim": "immich_quota", + "timeout": 30000, + "tokenEndpointAuthMethod": "client_secret_post" }, "passwordLogin": { "enabled": true }, + "reverseGeocoding": { + "enabled": true + }, + "server": { + "externalDomain": "", + "loginPageMessage": "", + "publicUsers": true + }, "storageTemplate": { "enabled": false, "hashVerificationEnabled": true, "template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}" }, - "image": { - "thumbnail": { - "format": "webp", - "size": 250, - "quality": 80 - }, - "preview": { - "format": "jpeg", - "size": 1440, - "quality": 80 - }, - "colorspace": "p3", - "extractEmbedded": false - }, - "newVersionCheck": { - "enabled": true - }, - "trash": { - "enabled": true, - "days": 30 + "templates": { + "email": { + "albumInviteTemplate": "", + "albumUpdateTemplate": "", + "welcomeTemplate": "" + } }, "theme": { "customCss": "" }, - "library": { - "scan": { - "enabled": true, - "cronExpression": "0 0 * * *" - }, - "watch": { - "enabled": false - } - }, - "server": { - "externalDomain": "", - "loginPageMessage": "" - }, - "notifications": { - "smtp": { - "enabled": false, - "from": "", - "replyTo": "", - "transport": { - "ignoreCert": false, - "host": "", - "port": 587, - "username": "", - "password": "" - } - } + "trash": { + "days": 30, + "enabled": true }, "user": { "deleteDelay": 7 diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 78a5289bf4..55c226d507 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -149,29 +149,31 @@ Redis (Sentinel) URL example JSON before encoding: ## Machine Learning -| Variable | Description | Default | Containers | -| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- | -| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | -| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning | -| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | -| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | -| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | -| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | -| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | -| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning | -| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning | -| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning | -| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning | -| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning | -| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning | -| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | -| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | -| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | -| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning | -| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning | -| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning | -| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning | -| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning | +| Variable | Description | Default | Containers | +| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- | +| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | +| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning | +| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | +| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | +| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | +| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | +| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | +| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning | +| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning | +| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | +| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | +| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | +| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning | +| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning | +| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning | +| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning | +| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning | +| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning | +| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning | \*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. diff --git a/docs/mise.toml b/docs/mise.toml new file mode 100644 index 0000000000..4ffb7d5cce --- /dev/null +++ b/docs/mise.toml @@ -0,0 +1,25 @@ +[tasks.install] +run = "pnpm install --filter documentation --frozen-lockfile" + +[tasks.start] +env._.path = "./node_modules/.bin" +run = "docusaurus --port 3005" + +[tasks.build] +env._.path = "./node_modules/.bin" +run = [ + "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0", + "docusaurus build", +] + +[tasks.preview] +env._.path = "./node_modules/.bin" +run = "docusaurus serve" + +[tasks.format] +env._.path = "./node_modules/.bin" +run = "prettier --check ." + +[tasks."format-fix"] +env._.path = "./node_modules/.bin" +run = "prettier --write ." diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 1d50ae54f8..6affb532c9 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v2.2.3", + "url": "https://docs.v2.2.3.archive.immich.app" + }, { "label": "v2.2.2", "url": "https://docs.v2.2.2.archive.immich.app" diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 9aef2288f6..3d62c8a34a 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -35,7 +35,7 @@ services: - 2285:2285 redis: - image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc + image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb database: image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 diff --git a/e2e/mise.toml b/e2e/mise.toml new file mode 100644 index 0000000000..c298115e40 --- /dev/null +++ b/e2e/mise.toml @@ -0,0 +1,29 @@ +[tasks.install] +run = "pnpm install --filter immich-e2e --frozen-lockfile" + +[tasks.test] +env._.path = "./node_modules/.bin" +run = "vitest --run" + +[tasks."test-web"] +env._.path = "./node_modules/.bin" +run = "playwright test" + +[tasks.format] +env._.path = "./node_modules/.bin" +run = "prettier --check ." + +[tasks."format-fix"] +env._.path = "./node_modules/.bin" +run = "prettier --write ." + +[tasks.lint] +env._.path = "./node_modules/.bin" +run = "eslint \"src/**/*.ts\" --max-warnings 0" + +[tasks."lint-fix"] +run = { task = "lint --fix" } + +[tasks.check] +env._.path = "./node_modules/.bin" +run = "tsc --noEmit" diff --git a/e2e/package.json b/e2e/package.json index a6721a1bec..84e1823e0c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "2.2.2", + "version": "2.2.3", "description": "", "main": "index.js", "type": "module", @@ -25,7 +25,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^22.18.12", + "@types/node": "^22.19.0", "@types/oidc-provider": "^9.0.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 5c30ff5cbe..ab3252c40b 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -15,7 +15,6 @@ import { DateTime } from 'luxon'; import { randomBytes } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; -import sharp from 'sharp'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; import { makeRandomImage } from 'src/generators'; @@ -41,40 +40,6 @@ const today = DateTime.fromObject({ }) as DateTime; const yesterday = today.minus({ days: 1 }); -const createTestImageWithExif = async (filename: string, exifData: Record) => { - // Generate unique color to ensure different checksums for each image - const r = Math.floor(Math.random() * 256); - const g = Math.floor(Math.random() * 256); - const b = Math.floor(Math.random() * 256); - - // Create a 100x100 solid color JPEG using Sharp - const imageBytes = await sharp({ - create: { - width: 100, - height: 100, - channels: 3, - background: { r, g, b }, - }, - }) - .jpeg({ quality: 90 }) - .toBuffer(); - - // Add random suffix to filename to avoid collisions - const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`); - const filepath = join(tempDir, uniqueFilename); - await writeFile(filepath, imageBytes); - - // Filter out undefined values before writing EXIF - const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined)); - - await exiftool.write(filepath, cleanExifData); - - // Re-read the image bytes after EXIF has been written - const finalImageBytes = await readFile(filepath); - - return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename }; -}; - describe('/asset', () => { let admin: LoginResponseDto; let websocket: Socket; @@ -1249,411 +1214,6 @@ describe('/asset', () => { }); }); - describe('EXIF metadata extraction', () => { - describe('Additional date tag extraction', () => { - describe('Date-time vs time-only tag handling', () => { - it('should fall back to file timestamps when only time-only tags are available', async () => { - const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', { - TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal - // Exclude all date-time tags to force fallback to file timestamps - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - SubSecMediaCreateDate: undefined, - CreateDate: undefined, - MediaCreateDate: undefined, - CreationDate: undefined, - DateTimeCreated: undefined, - GPSDateTime: undefined, - DateTimeUTC: undefined, - SonyDateTime2: undefined, - GPSDateStamp: undefined, - }); - - const oldDate = new Date('2020-01-01T00:00:00.000Z'); - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - fileCreatedAt: oldDate.toISOString(), - fileModifiedAt: oldDate.toISOString(), - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should fall back to file timestamps, which we set to 2020-01-01 - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2020-01-01T00:00:00.000Z').getTime(), - ); - }); - - it('should prefer DateTimeOriginal over time-only tags', async () => { - const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', { - DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred - TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only) - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should use DateTimeOriginal, not TimeCreated - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-10-10T10:00:00.000Z').getTime(), - ); - }); - }); - - describe('GPSDateTime tag extraction', () => { - it('should extract GPSDateTime with GPS coordinates', async () => { - const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', { - GPSDateTime: '2023:11:15 12:30:00Z', - GPSLatitude: 37.7749, - GPSLongitude: -122.4194, - // Exclude other date tags - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - SubSecMediaCreateDate: undefined, - CreateDate: undefined, - MediaCreateDate: undefined, - CreationDate: undefined, - DateTimeCreated: undefined, - TimeCreated: undefined, - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4); - expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4); - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-11-15T12:30:00.000Z').getTime(), - ); - }); - }); - - describe('CreateDate tag extraction', () => { - it('should extract CreateDate when available', async () => { - const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', { - CreateDate: '2023:11:15 10:30:00', - // Exclude other higher priority date tags - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - SubSecMediaCreateDate: undefined, - MediaCreateDate: undefined, - CreationDate: undefined, - DateTimeCreated: undefined, - TimeCreated: undefined, - GPSDateTime: undefined, - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-11-15T10:30:00.000Z').getTime(), - ); - }); - }); - - describe('GPSDateStamp tag extraction', () => { - it('should fall back to file timestamps when only date-only tags are available', async () => { - const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', { - GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal - // Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation - GPSLatitude: 51.5074, - GPSLongitude: -0.1278, - // Explicitly exclude all testable date-time tags to force fallback to file timestamps - DateTimeOriginal: undefined, - CreateDate: undefined, - CreationDate: undefined, - GPSDateTime: undefined, - }); - - const oldDate = new Date('2020-01-01T00:00:00.000Z'); - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - fileCreatedAt: oldDate.toISOString(), - fileModifiedAt: oldDate.toISOString(), - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4); - expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4); - // Should fall back to file timestamps, which we set to 2020-01-01 - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2020-01-01T00:00:00.000Z').getTime(), - ); - }); - }); - - /* - * NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files: - * - * NOT WRITABLE to JPEG: - * - MediaCreateDate: Can be read from video files but not written to JPEG - * - DateTimeCreated: Read-only tag in JPEG format - * - DateTimeUTC: Cannot be written to JPEG files - * - SonyDateTime2: Proprietary Sony tag, not writable to JPEG - * - SubSecMediaCreateDate: Tag not defined for JPEG format - * - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG - * - * WRITABLE but NOT READABLE from JPEG: - * - SubSecDateTimeOriginal: Can be written but not read back from JPEG - * - SubSecCreateDate: Can be written but not read back from JPEG - * - * EFFECTIVELY TESTABLE TAGS (writable and readable): - * - DateTimeOriginal ✓ - * - CreateDate ✓ - * - CreationDate ✓ - * - GPSDateTime ✓ - * - * The metadata service correctly handles non-readable tags and will fall back to - * file timestamps when only non-readable tags are present. - */ - - describe('Date tag priority order', () => { - it('should respect the complete date tag priority order', async () => { - // Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG) - const testCases = [ - { - name: 'DateTimeOriginal has highest priority among testable tags', - exifData: { - DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags - CreateDate: '2023:05:05 05:00:00', // TESTABLE - CreationDate: '2023:07:07 07:00:00', // TESTABLE - GPSDateTime: '2023:10:10 10:00:00', // TESTABLE - }, - expectedDate: '2023-04-04T04:00:00.000Z', - }, - { - name: 'CreationDate when DateTimeOriginal missing', - exifData: { - CreationDate: '2023:05:05 05:00:00', // TESTABLE - CreateDate: '2023:07:07 07:00:00', // TESTABLE - GPSDateTime: '2023:10:10 10:00:00', // TESTABLE - }, - expectedDate: '2023-05-05T05:00:00.000Z', - }, - { - name: 'CreationDate when standard EXIF tags missing', - exifData: { - CreationDate: '2023:07:07 07:00:00', // TESTABLE - GPSDateTime: '2023:10:10 10:00:00', // TESTABLE - }, - expectedDate: '2023-07-07T07:00:00.000Z', - }, - { - name: 'GPSDateTime when no other testable date tags present', - exifData: { - GPSDateTime: '2023:10:10 10:00:00', // TESTABLE - Make: 'SONY', - }, - expectedDate: '2023-10-10T10:00:00.000Z', - }, - ]; - - for (const testCase of testCases) { - const { imageBytes, filename } = await createTestImageWithExif( - `${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`, - testCase.exifData, - ); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined(); - expect( - new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(), - `Date mismatch for: ${testCase.name}`, - ).toBe(new Date(testCase.expectedDate).getTime()); - } - }); - }); - - describe('Edge cases for date tag handling', () => { - it('should fall back to file timestamps with GPSDateStamp alone', async () => { - const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', { - GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal - // Intentionally no GPSTimeStamp - // Exclude all other date tags - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - SubSecMediaCreateDate: undefined, - CreateDate: undefined, - MediaCreateDate: undefined, - CreationDate: undefined, - DateTimeCreated: undefined, - TimeCreated: undefined, - GPSDateTime: undefined, - DateTimeUTC: undefined, - }); - - const oldDate = new Date('2020-01-01T00:00:00.000Z'); - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - fileCreatedAt: oldDate.toISOString(), - fileModifiedAt: oldDate.toISOString(), - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should fall back to file timestamps, which we set to 2020-01-01 - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2020-01-01T00:00:00.000Z').getTime(), - ); - }); - - it('should handle all testable date tags present to verify complete priority order', async () => { - const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', { - // All TESTABLE date tags to JPEG format (writable AND readable) - DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags - CreateDate: '2023:05:05 05:00:00', // TESTABLE - CreationDate: '2023:07:07 07:00:00', // TESTABLE - GPSDateTime: '2023:10:10 10:00:00', // TESTABLE - // Note: Excluded non-testable tags: - // SubSec tags: writable but not readable from JPEG - // Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc. - // Time-only/date-only tags: already excluded from EXIF_DATE_TAGS - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should use DateTimeOriginal as it has the highest priority among testable tags - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-04-04T04:00:00.000Z').getTime(), - ); - }); - - it('should use CreationDate when SubSec tags are missing', async () => { - const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', { - CreationDate: '2023:07:07 07:00:00', // WRITABLE - GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - // Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG - // Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only) - // Exclude SubSec and standard EXIF tags - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - CreateDate: undefined, - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should use CreationDate when available - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-07-07T07:00:00.000Z').getTime(), - ); - }); - - it('should skip invalid date formats and use next valid tag', async () => { - const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', { - // Note: Testing invalid date handling with only WRITABLE tags - GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date - CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date - // Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG - // Exclude other date tags - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - CreateDate: undefined, - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should skip invalid dates and use the first valid one (GPSDateTime) - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-10-10T10:00:00.000Z').getTime(), - ); - }); - }); - }); - }); - describe('POST /assets/exist', () => { it('ignores invalid deviceAssetIds', async () => { const response = await utils.checkExistingAssets(user1.accessToken, { diff --git a/e2e/src/api/specs/jobs.e2e-spec.ts b/e2e/src/api/specs/jobs.e2e-spec.ts index a9afd8475f..be7984404b 100644 --- a/e2e/src/api/specs/jobs.e2e-spec.ts +++ b/e2e/src/api/specs/jobs.e2e-spec.ts @@ -1,4 +1,4 @@ -import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk'; +import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich/sdk'; import { cpSync, rmSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; @@ -17,28 +17,28 @@ describe('/jobs', () => { describe('PUT /jobs', () => { afterEach(async () => { - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.FaceDetection, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.FaceDetection, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.SmartSearch, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.SmartSearch, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.DuplicateDetection, { + command: QueueCommand.Resume, force: false, }); @@ -59,8 +59,8 @@ describe('/jobs', () => { it('should queue metadata extraction for missing assets', async () => { const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`; - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Pause, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Pause, force: false, }); @@ -77,20 +77,20 @@ describe('/jobs', () => { expect(asset.exifInfo?.make).toBeNull(); } - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Empty, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Empty, force: false, }); await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Start, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Start, force: false, }); @@ -124,8 +124,8 @@ describe('/jobs', () => { cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path); - await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { - command: JobCommand.Start, + await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, { + command: QueueCommand.Start, force: false, }); @@ -144,8 +144,8 @@ describe('/jobs', () => { it('should queue thumbnail extraction for assets missing thumbs', async () => { const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`; - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Pause, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Pause, force: false, }); @@ -153,32 +153,32 @@ describe('/jobs', () => { assetData: { bytes: await readFile(path), filename: basename(path) }, }); - await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); - await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration); const assetBefore = await utils.getAssetInfo(admin.accessToken, id); expect(assetBefore.thumbhash).toBeNull(); - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Empty, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Empty, force: false, }); - await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); - await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration); - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Resume, force: false, }); - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Start, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Start, force: false, }); - await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); - await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration); const assetAfter = await utils.getAssetInfo(admin.accessToken, id); expect(assetAfter.thumbhash).not.toBeNull(); @@ -193,26 +193,26 @@ describe('/jobs', () => { assetData: { bytes: await readFile(path), filename: basename(path) }, }); - await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); - await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration); const assetBefore = await utils.getAssetInfo(admin.accessToken, id); cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path); - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Resume, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Resume, force: false, }); // This runs the missing thumbnail job - await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { - command: JobCommand.Start, + await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, { + command: QueueCommand.Start, force: false, }); - await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); - await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration); const assetAfter = await utils.getAssetInfo(admin.accessToken, id); diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index 2d6e08b5fb..793c508a36 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -1,6 +1,6 @@ import { - JobName, LoginResponseDto, + QueueName, createStack, deleteUserAdmin, getMyUser, @@ -328,7 +328,7 @@ describe('/admin/users', () => { { headers: asBearerAuth(user.accessToken) }, ); - await utils.waitForQueueFinish(admin.accessToken, JobName.BackgroundTask); + await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask); const { status, body } = await request(app) .delete(`/admin/users/${user.userId}`) diff --git a/e2e/src/generate-date-tag-test-images.ts b/e2e/src/generate-date-tag-test-images.ts deleted file mode 100644 index 34cc956416..0000000000 --- a/e2e/src/generate-date-tag-test-images.ts +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env node - -/** - * Script to generate test images with additional EXIF date tags - * This creates actual JPEG images with embedded metadata for testing - * Images are generated into e2e/test-assets/metadata/dates/ - */ - -import { execSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import sharp from 'sharp'; - -interface TestImage { - filename: string; - description: string; - exifTags: Record; -} - -const testImages: TestImage[] = [ - { - filename: 'time-created.jpg', - description: 'Image with TimeCreated tag', - exifTags: { - TimeCreated: '2023:11:15 14:30:00', - Make: 'Canon', - Model: 'EOS R5', - }, - }, - { - filename: 'gps-datetime.jpg', - description: 'Image with GPSDateTime and coordinates', - exifTags: { - GPSDateTime: '2023:11:15 12:30:00Z', - GPSLatitude: '37.7749', - GPSLongitude: '-122.4194', - GPSLatitudeRef: 'N', - GPSLongitudeRef: 'W', - }, - }, - { - filename: 'datetime-utc.jpg', - description: 'Image with DateTimeUTC tag', - exifTags: { - DateTimeUTC: '2023:11:15 10:30:00', - Make: 'Nikon', - Model: 'D850', - }, - }, - { - filename: 'gps-datestamp.jpg', - description: 'Image with GPSDateStamp and GPSTimeStamp', - exifTags: { - GPSDateStamp: '2023:11:15', - GPSTimeStamp: '08:30:00', - GPSLatitude: '51.5074', - GPSLongitude: '-0.1278', - GPSLatitudeRef: 'N', - GPSLongitudeRef: 'W', - }, - }, - { - filename: 'sony-datetime2.jpg', - description: 'Sony camera image with SonyDateTime2 tag', - exifTags: { - SonyDateTime2: '2023:11:15 06:30:00', - Make: 'SONY', - Model: 'ILCE-7RM5', - }, - }, - { - filename: 'date-priority-test.jpg', - description: 'Image with multiple date tags to test priority', - exifTags: { - SubSecDateTimeOriginal: '2023:01:01 01:00:00', - DateTimeOriginal: '2023:02:02 02:00:00', - SubSecCreateDate: '2023:03:03 03:00:00', - CreateDate: '2023:04:04 04:00:00', - CreationDate: '2023:05:05 05:00:00', - DateTimeCreated: '2023:06:06 06:00:00', - TimeCreated: '2023:07:07 07:00:00', - GPSDateTime: '2023:08:08 08:00:00', - DateTimeUTC: '2023:09:09 09:00:00', - GPSDateStamp: '2023:10:10', - SonyDateTime2: '2023:11:11 11:00:00', - }, - }, - { - filename: 'new-tags-only.jpg', - description: 'Image with only additional date tags (no standard tags)', - exifTags: { - TimeCreated: '2023:12:01 15:45:30', - GPSDateTime: '2023:12:01 13:45:30Z', - DateTimeUTC: '2023:12:01 13:45:30', - GPSDateStamp: '2023:12:01', - SonyDateTime2: '2023:12:01 08:45:30', - GPSLatitude: '40.7128', - GPSLongitude: '-74.0060', - GPSLatitudeRef: 'N', - GPSLongitudeRef: 'W', - }, - }, -]; - -const generateTestImages = async (): Promise => { - // Target directory: e2e/test-assets/metadata/dates/ - // Current file is in: e2e/src/ - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates'); - - console.log('Generating test images with additional EXIF date tags...'); - console.log(`Target directory: ${targetDir}`); - - for (const image of testImages) { - try { - const imagePath = join(targetDir, image.filename); - - // Create unique JPEG file using Sharp - const r = Math.floor(Math.random() * 256); - const g = Math.floor(Math.random() * 256); - const b = Math.floor(Math.random() * 256); - - const jpegData = await sharp({ - create: { - width: 100, - height: 100, - channels: 3, - background: { r, g, b }, - }, - }) - .jpeg({ quality: 90 }) - .toBuffer(); - - writeFileSync(imagePath, jpegData); - - // Build exiftool command to add EXIF data - const exifArgs = Object.entries(image.exifTags) - .map(([tag, value]) => `-${tag}="${value}"`) - .join(' '); - - const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`; - - console.log(`Creating ${image.filename}: ${image.description}`); - execSync(command, { stdio: 'pipe' }); - - // Verify the tags were written - const verifyCommand = `exiftool -json "${imagePath}"`; - const result = execSync(verifyCommand, { encoding: 'utf8' }); - const metadata = JSON.parse(result)[0]; - - console.log(` ✓ Created with ${Object.keys(image.exifTags).length} EXIF tags`); - - // Log first date tag found for verification - const firstDateTag = Object.keys(image.exifTags).find( - (tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'), - ); - if (firstDateTag && metadata[firstDateTag]) { - console.log(` ✓ Verified ${firstDateTag}: ${metadata[firstDateTag]}`); - } - } catch (error) { - console.error(`Failed to create ${image.filename}:`, (error as Error).message); - } - } - - console.log('\nTest image generation complete!'); - console.log('Files created in:', targetDir); - console.log('\nTo test these images:'); - console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`); -}; - -export { generateTestImages }; - -// Run the generator if this file is executed directly -if (import.meta.url === `file://${process.argv[1]}`) { - generateTestImages().catch(console.error); -} diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index b33d6cb190..8f34bbe40a 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -1,5 +1,4 @@ import { - AllJobStatusResponseDto, AssetMediaCreateDto, AssetMediaResponseDto, AssetResponseDto, @@ -7,11 +6,12 @@ import { CheckExistingAssetsDto, CreateAlbumDto, CreateLibraryDto, - JobCommandDto, - JobName, MetadataSearchDto, Permission, PersonCreateDto, + QueueCommandDto, + QueueName, + QueuesResponseDto, SharedLinkCreateDto, UpdateLibraryDto, UserAdminCreateDto, @@ -27,14 +27,14 @@ import { createStack, createUserAdmin, deleteAssets, - getAllJobsStatus, getAssetInfo, getConfig, getConfigDefaults, + getQueuesLegacy, login, + runQueueCommandLegacy, scanLibrary, searchAssets, - sendJobCommand, setBaseUrl, signUpAdmin, tagAssets, @@ -477,8 +477,8 @@ export const utils = { tagAssets: (accessToken: string, tagId: string, assetIds: string[]) => tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }), - jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) => - sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }), + queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) => + runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }), setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') => await context.addCookies([ @@ -524,13 +524,13 @@ export const utils = { await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); }, - isQueueEmpty: async (accessToken: string, queue: keyof AllJobStatusResponseDto) => { - const queues = await getAllJobsStatus({ headers: asBearerAuth(accessToken) }); + isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => { + const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) }); const jobCounts = queues[queue].jobCounts; return !jobCounts.active && !jobCounts.waiting; }, - waitForQueueFinish: (accessToken: string, queue: keyof AllJobStatusResponseDto, ms?: number) => { + waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000); diff --git a/e2e/src/web/specs/user-admin.e2e-spec.ts b/e2e/src/web/specs/user-admin.e2e-spec.ts index 3d64e47aef..611a1b3dec 100644 --- a/e2e/src/web/specs/user-admin.e2e-spec.ts +++ b/e2e/src/web/specs/user-admin.e2e-spec.ts @@ -52,7 +52,7 @@ test.describe('User Administration', () => { await page.goto(`/admin/users/${user.userId}`); - await page.getByRole('button', { name: 'Edit user' }).click(); + await page.getByRole('button', { name: 'Edit' }).click(); await expect(page.getByLabel('Admin User')).not.toBeChecked(); await page.getByText('Admin User').click(); await expect(page.getByLabel('Admin User')).toBeChecked(); @@ -77,7 +77,7 @@ test.describe('User Administration', () => { await page.goto(`/admin/users/${user.userId}`); - await page.getByRole('button', { name: 'Edit user' }).click(); + await page.getByRole('button', { name: 'Edit' }).click(); await expect(page.getByLabel('Admin User')).toBeChecked(); await page.getByText('Admin User').click(); await expect(page.getByLabel('Admin User')).not.toBeChecked(); diff --git a/e2e/test-assets b/e2e/test-assets index 37f60ea537..163c251744 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 37f60ea537c0228f5f92e4f42dc42f0bb39a6d7f +Subproject commit 163c251744e0a35d7ecfd02682452043f149fc2b diff --git a/i18n/en.json b/i18n/en.json index 28e1250c8a..c64f940ff8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -32,6 +32,7 @@ "add_to_album_toggle": "Toggle selection for {album}", "add_to_albums": "Add to albums", "add_to_albums_count": "Add to albums ({count})", + "add_to_bottom_bar": "Add to", "add_to_shared_album": "Add to shared album", "add_upload_to_stack": "Add upload to stack", "add_url": "Add URL", @@ -430,6 +431,7 @@ "age_months": "Age {months, plural, one {# month} other {# months}}", "age_year_months": "Age 1 year, {months, plural, one {# month} other {# months}}", "age_years": "{years, plural, other {Age #}}", + "album": "Album", "album_added": "Album added", "album_added_notification_setting_description": "Receive an email notification when you are added to a shared album", "album_cover_updated": "Album cover updated", @@ -475,6 +477,7 @@ "allow_edits": "Allow edits", "allow_public_user_to_download": "Allow public user to download", "allow_public_user_to_upload": "Allow public user to upload", + "allowed": "Allowed", "alt_text_qr_code": "QR code image", "anti_clockwise": "Anti-clockwise", "api_key": "API Key", @@ -1196,6 +1199,8 @@ "import_path": "Import path", "in_albums": "In {count, plural, one {# album} other {# albums}}", "in_archive": "In archive", + "in_year": "In {year}", + "in_year_selector": "In", "include_archived": "Include archived", "include_shared_albums": "Include shared albums", "include_shared_partner_assets": "Include shared partner assets", @@ -1232,6 +1237,7 @@ "language_setting_description": "Select your preferred language", "large_files": "Large Files", "last": "Last", + "last_months": "{count, plural, one {Last month} other {Last # months}}", "last_seen": "Last seen", "latest_version": "Latest Version", "latitude": "Latitude", @@ -1314,6 +1320,10 @@ "main_menu": "Main menu", "make": "Make", "manage_geolocation": "Manage location", + "manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.", + "manage_media_access_settings": "Open settings", + "manage_media_access_subtitle": "Allow the Immich app to manage and move media files.", + "manage_media_access_title": "Media Management Access", "manage_shared_links": "Manage shared links", "manage_sharing_with_partners": "Manage sharing with partners", "manage_the_app_settings": "Manage the app settings", @@ -1377,6 +1387,7 @@ "more": "More", "move": "Move", "move_off_locked_folder": "Move out of locked folder", + "move_to": "Move to", "move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_locked_folder": "Move to locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", @@ -1406,6 +1417,7 @@ "new_pin_code": "New PIN code", "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page", "new_timeline": "New Timeline", + "new_update": "New update", "new_user_created": "New user created", "new_version_available": "NEW VERSION AVAILABLE", "newest_first": "Newest first", @@ -1421,6 +1433,7 @@ "no_cast_devices_found": "No cast devices found", "no_checksum_local": "No checksum available - cannot fetch local assets", "no_checksum_remote": "No checksum available - cannot fetch remote asset", + "no_devices": "No authorized devices", "no_duplicates_found": "No duplicates were found.", "no_exif_info_available": "No exif info available", "no_explore_results_message": "Upload more photos to explore your collection.", @@ -1437,6 +1450,7 @@ "no_results_description": "Try a synonym or more general keyword", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", "no_uploads_in_progress": "No uploads in progress", + "not_allowed": "Not allowed", "not_available": "N/A", "not_in_any_album": "Not in any album", "not_selected": "Not selected", @@ -1547,6 +1561,8 @@ "photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}", "photos_from_previous_years": "Photos from previous years", "pick_a_location": "Pick a location", + "pick_custom_range": "Custom range", + "pick_date_range": "Select a date range", "pin_code_changed_successfully": "Successfully changed PIN code", "pin_code_reset_successfully": "Successfully reset PIN code", "pin_code_setup_successfully": "Successfully setup a PIN code", @@ -2029,6 +2045,7 @@ "third_party_resources": "Third-Party Resources", "time": "Time", "time_based_memories": "Time-based memories", + "time_based_memories_duration": "Number of seconds to display each image.", "timeline": "Timeline", "timezone": "Timezone", "to_archive": "Archive", @@ -2169,6 +2186,7 @@ "welcome": "Welcome", "welcome_to_immich": "Welcome to Immich", "wifi_name": "Wi-Fi Name", + "workflow": "Workflow", "wrong_pin_code": "Wrong PIN code", "year": "Year", "years_ago": "{years, plural, one {# year} other {# years}} ago", diff --git a/machine-learning/immich_ml/config.py b/machine-learning/immich_ml/config.py index 68d00625a3..19fd5300df 100644 --- a/machine-learning/immich_ml/config.py +++ b/machine-learning/immich_ml/config.py @@ -13,6 +13,8 @@ from rich.logging import RichHandler from uvicorn import Server from uvicorn.workers import UvicornWorker +from .schemas import ModelPrecision + class ClipSettings(BaseModel): textual: str | None = None @@ -24,6 +26,11 @@ class FacialRecognitionSettings(BaseModel): detection: str | None = None +class OcrSettings(BaseModel): + recognition: str | None = None + detection: str | None = None + + class PreloadModelData(BaseModel): clip_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__CLIP", None) facial_recognition_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION", None) @@ -37,6 +44,7 @@ class PreloadModelData(BaseModel): del os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION"] clip: ClipSettings = ClipSettings() facial_recognition: FacialRecognitionSettings = FacialRecognitionSettings() + ocr: OcrSettings = OcrSettings() class MaxBatchSize(BaseModel): @@ -70,6 +78,7 @@ class Settings(BaseSettings): rknn_threads: int = 1 preload: PreloadModelData | None = None max_batch_size: MaxBatchSize | None = None + openvino_precision: ModelPrecision = ModelPrecision.FP32 @property def device_id(self) -> str: diff --git a/machine-learning/immich_ml/main.py b/machine-learning/immich_ml/main.py index 35f04d77ef..3d34d9bf9d 100644 --- a/machine-learning/immich_ml/main.py +++ b/machine-learning/immich_ml/main.py @@ -103,6 +103,20 @@ async def preload_models(preload: PreloadModelData) -> None: ModelTask.FACIAL_RECOGNITION, ) + if preload.ocr.detection is not None: + await load_models( + preload.ocr.detection, + ModelType.DETECTION, + ModelTask.OCR, + ) + + if preload.ocr.recognition is not None: + await load_models( + preload.ocr.recognition, + ModelType.RECOGNITION, + ModelTask.OCR, + ) + if preload.clip_fallback is not None: log.warning( "Deprecated env variable: 'MACHINE_LEARNING_PRELOAD__CLIP'. " diff --git a/machine-learning/immich_ml/models/constants.py b/machine-learning/immich_ml/models/constants.py index 10a4ae48a9..db9e7cfa4d 100644 --- a/machine-learning/immich_ml/models/constants.py +++ b/machine-learning/immich_ml/models/constants.py @@ -78,6 +78,14 @@ _INSIGHTFACE_MODELS = { _PADDLE_MODELS = { "PP-OCRv5_server", "PP-OCRv5_mobile", + "CH__PP-OCRv5_server", + "CH__PP-OCRv5_mobile", + "EL__PP-OCRv5_mobile", + "EN__PP-OCRv5_mobile", + "ESLAV__PP-OCRv5_mobile", + "KOREAN__PP-OCRv5_mobile", + "LATIN__PP-OCRv5_mobile", + "TH__PP-OCRv5_mobile", } SUPPORTED_PROVIDERS = [ diff --git a/machine-learning/immich_ml/models/ocr/detection.py b/machine-learning/immich_ml/models/ocr/detection.py index 0a9d09b599..4101a5c6f7 100644 --- a/machine-learning/immich_ml/models/ocr/detection.py +++ b/machine-learning/immich_ml/models/ocr/detection.py @@ -6,7 +6,7 @@ from numpy.typing import NDArray from PIL import Image from rapidocr.ch_ppocr_det.utils import DBPostProcess from rapidocr.inference_engine.base import FileInfo, InferSession -from rapidocr.utils import DownloadFile, DownloadFileInput +from rapidocr.utils.download_file import DownloadFile, DownloadFileInput from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType from rapidocr.utils.typings import ModelType as RapidModelType @@ -23,7 +23,7 @@ class TextDetector(InferenceModel): identity = (ModelType.DETECTION, ModelTask.OCR) def __init__(self, model_name: str, **model_kwargs: Any) -> None: - super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX) + super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX) self.max_resolution = 736 self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32) self.std_inv = np.float32(1.0) / (np.array([0.5, 0.5, 0.5], dtype=np.float32) * 255.0) diff --git a/machine-learning/immich_ml/models/ocr/recognition.py b/machine-learning/immich_ml/models/ocr/recognition.py index 0f91fc4105..e968392881 100644 --- a/machine-learning/immich_ml/models/ocr/recognition.py +++ b/machine-learning/immich_ml/models/ocr/recognition.py @@ -6,7 +6,7 @@ from PIL import Image from rapidocr.ch_ppocr_rec import TextRecInput from rapidocr.ch_ppocr_rec import TextRecognizer as RapidTextRecognizer from rapidocr.inference_engine.base import FileInfo, InferSession -from rapidocr.utils import DownloadFile, DownloadFileInput +from rapidocr.utils.download_file import DownloadFile, DownloadFileInput from rapidocr.utils.typings import EngineType, LangRec, OCRVersion, TaskType from rapidocr.utils.typings import ModelType as RapidModelType from rapidocr.utils.vis_res import VisRes @@ -25,6 +25,7 @@ class TextRecognizer(InferenceModel): identity = (ModelType.RECOGNITION, ModelTask.OCR) def __init__(self, model_name: str, **model_kwargs: Any) -> None: + self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH self.min_score = model_kwargs.get("minScore", 0.9) self._empty: TextRecognitionOutput = { "box": np.empty(0, dtype=np.float32), @@ -41,7 +42,7 @@ class TextRecognizer(InferenceModel): engine_type=EngineType.ONNXRUNTIME, ocr_version=OCRVersion.PPOCRV5, task_type=TaskType.REC, - lang_type=LangRec.CH, + lang_type=self.language, model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER, ) ) @@ -61,6 +62,7 @@ class TextRecognizer(InferenceModel): session=session.session, rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6, rec_img_shape=(3, 48, 320), + lang_type=self.language, ) ) return session diff --git a/machine-learning/immich_ml/models/ocr/schemas.py b/machine-learning/immich_ml/models/ocr/schemas.py index a63c8dd8e5..78e8619a0b 100644 --- a/machine-learning/immich_ml/models/ocr/schemas.py +++ b/machine-learning/immich_ml/models/ocr/schemas.py @@ -20,8 +20,8 @@ class TextRecognitionOutput(TypedDict): # RapidOCR expects `engine_type`, `lang_type`, and `font_path` to be attributes class OcrOptions(dict[str, Any]): - def __init__(self, **options: Any) -> None: + def __init__(self, lang_type: LangRec | None = None, **options: Any) -> None: super().__init__(**options) self.engine_type = EngineType.ONNXRUNTIME - self.lang_type = LangRec.CH + self.lang_type = lang_type self.font_path = None diff --git a/machine-learning/immich_ml/schemas.py b/machine-learning/immich_ml/schemas.py index bfb40b9c84..41706180de 100644 --- a/machine-learning/immich_ml/schemas.py +++ b/machine-learning/immich_ml/schemas.py @@ -46,6 +46,11 @@ class ModelSource(StrEnum): PADDLE = "paddle" +class ModelPrecision(StrEnum): + FP16 = "FP16" + FP32 = "FP32" + + ModelIdentity = tuple[ModelType, ModelTask] diff --git a/machine-learning/immich_ml/sessions/ort.py b/machine-learning/immich_ml/sessions/ort.py index b6f709a323..6c52936722 100644 --- a/machine-learning/immich_ml/sessions/ort.py +++ b/machine-learning/immich_ml/sessions/ort.py @@ -93,10 +93,12 @@ class OrtSession: case "CUDAExecutionProvider" | "ROCMExecutionProvider": options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id} case "OpenVINOExecutionProvider": + openvino_dir = self.model_path.parent / "openvino" + device = f"GPU.{settings.device_id}" options = { - "device_type": f"GPU.{settings.device_id}", - "precision": "FP32", - "cache_dir": (self.model_path.parent / "openvino").as_posix(), + "device_type": device, + "precision": settings.openvino_precision.value, + "cache_dir": openvino_dir.as_posix(), } case "CoreMLExecutionProvider": options = { diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 1d36648c3d..a93ab1c2af 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "immich-ml" -version = "2.2.2" +version = "2.2.3" description = "" authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] requires-python = ">=3.10,<4.0" diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py index 582a05a950..eb8706fc19 100644 --- a/machine-learning/test_main.py +++ b/machine-learning/test_main.py @@ -26,7 +26,7 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn from immich_ml.models.clip.visual import OpenClipVisualEncoder from immich_ml.models.facial_recognition.detection import FaceDetector from immich_ml.models.facial_recognition.recognition import FaceRecognizer -from immich_ml.schemas import ModelFormat, ModelTask, ModelType +from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType from immich_ml.sessions.ann import AnnSession from immich_ml.sessions.ort import OrtSession from immich_ml.sessions.rknn import RknnSession, run_inference @@ -240,11 +240,16 @@ class TestOrtSession: @pytest.mark.ov_device_ids(["GPU.0", "CPU"]) def test_sets_default_provider_options(self, ov_device_ids: list[str]) -> None: - model_path = "/cache/ViT-B-32__openai/model.onnx" + model_path = "/cache/ViT-B-32__openai/textual/model.onnx" + session = OrtSession(model_path, providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"]) assert session.provider_options == [ - {"device_type": "GPU.0", "precision": "FP32", "cache_dir": "/cache/ViT-B-32__openai/openvino"}, + { + "device_type": "GPU.0", + "precision": "FP32", + "cache_dir": "/cache/ViT-B-32__openai/textual/openvino", + }, {"arena_extend_strategy": "kSameAsRequested"}, ] @@ -262,6 +267,21 @@ class TestOrtSession: } ] + def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None: + model_path = "/cache/ViT-B-32__openai/textual/model.onnx" + os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" + mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16) + + session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"]) + + assert session.provider_options == [ + { + "device_type": "GPU.1", + "precision": "FP16", + "cache_dir": "/cache/ViT-B-32__openai/textual/openvino", + } + ] + def test_sets_provider_options_for_cuda(self) -> None: os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" @@ -417,7 +437,7 @@ class TestRknnSession: session.run(None, input_feed) rknn_session.return_value.put.assert_called_once_with([input1, input2]) - np_spy.call_count == 2 + assert np_spy.call_count == 2 np_spy.assert_has_calls([mock.call(input1), mock.call(input2)]) @@ -925,11 +945,34 @@ class TestCache: any_order=True, ) + async def test_preloads_ocr_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None: + os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile" + os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile" + + settings = Settings() + assert settings.preload is not None + assert settings.preload.ocr.detection == "PP-OCRv5_mobile" + assert settings.preload.ocr.recognition == "PP-OCRv5_mobile" + + model_cache = ModelCache() + monkeypatch.setattr("immich_ml.main.model_cache", model_cache) + + await preload_models(settings.preload) + mock_get_model.assert_has_calls( + [ + mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR), + mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR), + ], + any_order=True, + ) + async def test_preloads_all_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None: os.environ["MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"] = "ViT-B-32__openai" os.environ["MACHINE_LEARNING_PRELOAD__CLIP__VISUAL"] = "ViT-B-32__openai" os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = "buffalo_s" os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = "buffalo_s" + os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile" + os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile" settings = Settings() assert settings.preload is not None @@ -937,6 +980,8 @@ class TestCache: assert settings.preload.clip.textual == "ViT-B-32__openai" assert settings.preload.facial_recognition.recognition == "buffalo_s" assert settings.preload.facial_recognition.detection == "buffalo_s" + assert settings.preload.ocr.detection == "PP-OCRv5_mobile" + assert settings.preload.ocr.recognition == "PP-OCRv5_mobile" model_cache = ModelCache() monkeypatch.setattr("immich_ml.main.model_cache", model_cache) @@ -948,6 +993,8 @@ class TestCache: mock.call("ViT-B-32__openai", ModelType.VISUAL, ModelTask.SEARCH), mock.call("buffalo_s", ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION), mock.call("buffalo_s", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION), + mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR), + mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR), ], any_order=True, ) diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 65a2e70e50..2dc772ca91 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -3,12 +3,12 @@ # # Pump one or both of the server/mobile versions in appropriate files # -# usage: './scripts/pump-version.sh -s <-m> +# usage: './scripts/pump-version.sh -s <-m> # # examples: -# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50 -# ./scripts/pump-version.sh -s minor -m # 1.0.0+50 => 1.1.0+51 -# ./scripts/pump-version.sh -m # 1.0.0+50 => 1.0.0+51 +# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50 +# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51 +# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51 # SERVER_PUMP="false" @@ -88,7 +88,6 @@ if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then fi sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile -sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml diff --git a/mise.toml b/mise.toml index b4ccd76565..cf3b86c6cc 100644 --- a/mise.toml +++ b/mise.toml @@ -1,7 +1,9 @@ +experimental_monorepo_root = true + [tools] node = "24.11.0" flutter = "3.35.7" -pnpm = "10.19.0" +pnpm = "10.20.0" terragrunt = "0.91.2" opentofu = "1.10.6" @@ -14,514 +16,21 @@ postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm" experimental = true pin = true -# .github -[tasks."github:install"] -run = "pnpm install --filter github --frozen-lockfile" - -[tasks."github:format"] -env._.path = "./.github/node_modules/.bin" -dir = ".github" -run = "prettier --check ." - -[tasks."github:format-fix"] -env._.path = "./.github/node_modules/.bin" -dir = ".github" -run = "prettier --write ." - -# @immich/cli -[tasks."cli:install"] -run = "pnpm install --filter @immich/cli --frozen-lockfile" - -[tasks."cli:build"] -env._.path = "./cli/node_modules/.bin" -dir = "cli" -run = "vite build" - -[tasks."cli:test"] -env._.path = "./cli/node_modules/.bin" -dir = "cli" -run = "vite" - -[tasks."cli:lint"] -env._.path = "./cli/node_modules/.bin" -dir = "cli" -run = "eslint \"src/**/*.ts\" --max-warnings 0" - -[tasks."cli:lint-fix"] -run = "mise run cli:lint --fix" - -[tasks."cli:format"] -env._.path = "./cli/node_modules/.bin" -dir = "cli" -run = "prettier --check ." - -[tasks."cli:format-fix"] -env._.path = "./cli/node_modules/.bin" -dir = "cli" -run = "prettier --write ." - -[tasks."cli:check"] -env._.path = "./cli/node_modules/.bin" -dir = "cli" -run = "tsc --noEmit" - -# @immich/sdk +# SDK tasks [tasks."sdk:install"] +dir = "open-api/typescript-sdk" run = "pnpm install --filter @immich/sdk --frozen-lockfile" [tasks."sdk:build"] -env._.path = "./open-api/typescript-sdk/node_modules/.bin" -dir = "./open-api/typescript-sdk" +dir = "open-api/typescript-sdk" +env._.path = "./node_modules/.bin" run = "tsc" -# docs -[tasks."docs:install"] -run = "pnpm install --filter documentation --frozen-lockfile" - -[tasks."docs:start"] -env._.path = "./docs/node_modules/.bin" -dir = "docs" -run = "docusaurus --port 3005" - -[tasks."docs:build"] -env._.path = "./docs/node_modules/.bin" -dir = "docs" -run = [ - "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0", - "docusaurus build", -] - - -[tasks."docs:preview"] -env._.path = "./docs/node_modules/.bin" -dir = "docs" -run = "docusaurus serve" - - -[tasks."docs:format"] -env._.path = "./docs/node_modules/.bin" -dir = "docs" -run = "prettier --check ." - -[tasks."docs:format-fix"] -env._.path = "./docs/node_modules/.bin" -dir = "docs" -run = "prettier --write ." - - -# e2e -[tasks."e2e:install"] -run = "pnpm install --filter immich-e2e --frozen-lockfile" - -[tasks."e2e:test"] -env._.path = "./e2e/node_modules/.bin" -dir = "e2e" -run = "vitest --run" - -[tasks."e2e:test-web"] -env._.path = "./e2e/node_modules/.bin" -dir = "e2e" -run = "playwright test" - -[tasks."e2e:format"] -env._.path = "./e2e/node_modules/.bin" -dir = "e2e" -run = "prettier --check ." - -[tasks."e2e:format-fix"] -env._.path = "./e2e/node_modules/.bin" -dir = "e2e" -run = "prettier --write ." - -[tasks."e2e:lint"] -env._.path = "./e2e/node_modules/.bin" -dir = "e2e" -run = "eslint \"src/**/*.ts\" --max-warnings 0" - -[tasks."e2e:lint-fix"] -run = "mise run e2e:lint --fix" - -[tasks."e2e:check"] -env._.path = "./e2e/node_modules/.bin" -dir = "e2e" -run = "tsc --noEmit" - -# i18n +# i18n tasks [tasks."i18n:format"] -run = "mise run i18n:format-fix" +dir = "i18n" +run = { task = ":i18n:format-fix" } [tasks."i18n:format-fix"] -run = "pnpm dlx sort-json ./i18n/*.json" - - -# server -[tasks."server:install"] -run = "pnpm install --filter immich --frozen-lockfile" - -[tasks."server:build"] -env._.path = "./server/node_modules/.bin" -dir = "server" -run = "nest build" - -[tasks."server:test"] -env._.path = "./server/node_modules/.bin" -dir = "server" -run = "vitest --config test/vitest.config.mjs" - -[tasks."server:test-medium"] -env._.path = "./server/node_modules/.bin" -dir = "server" -run = "vitest --config test/vitest.config.medium.mjs" - -[tasks."server:format"] -env._.path = "./server/node_modules/.bin" -dir = "server" -run = "prettier --check ." - -[tasks."server:format-fix"] -env._.path = "./server/node_modules/.bin" -dir = "server" -run = "prettier --write ." - -[tasks."server:lint"] -env._.path = "./server/node_modules/.bin" -dir = "server" -run = "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0" - -[tasks."server:lint-fix"] -run = "mise run server:lint --fix" - -[tasks."server:check"] -env._.path = "./server/node_modules/.bin" -dir = "server" -run = "tsc --noEmit" - -[tasks."server:sql"] -dir = "server" -run = "node ./dist/bin/sync-open-api.js" - -[tasks."server:open-api"] -dir = "server" -run = "node ./dist/bin/sync-open-api.js" - -[tasks."server:migrations"] -dir = "server" -run = "node ./dist/bin/migrations.js" -description = "Run database migration commands (create, generate, run, debug, or query)" - -[tasks."server:schema-drop"] -run = "mise run server:migrations query 'DROP schema public cascade; CREATE schema public;'" - -[tasks."server:schema-reset"] -run = "mise run server:schema-drop && mise run server:migrations run" - -[tasks."server:email-dev"] -env._.path = "./server/node_modules/.bin" -dir = "server" -run = "email dev -p 3050 --dir src/emails" - -[tasks."server:checklist"] -run = [ - "mise run server:install", - "mise run server:format", - "mise run server:lint", - "mise run server:check", - "mise run server:test-medium --run", - "mise run server:test --run", -] - - -# web -[tasks."web:install"] -run = "pnpm install --filter immich-web --frozen-lockfile" - -[tasks."web:svelte-kit-sync"] -env._.path = "./web/node_modules/.bin" -dir = "web" -run = "svelte-kit sync" - -[tasks."web:build"] -env._.path = "./web/node_modules/.bin" -dir = "web" -run = "vite build" - -[tasks."web:build-stats"] -env.BUILD_STATS = "true" -env._.path = "./web/node_modules/.bin" -dir = "web" -run = "vite build" - -[tasks."web:preview"] -env._.path = "./web/node_modules/.bin" -dir = "web" -run = "vite preview" - -[tasks."web:start"] -env._.path = "web/node_modules/.bin" -dir = "web" -run = "vite dev --host 0.0.0.0 --port 3000" - -[tasks."web:test"] -depends = "web:svelte-kit-sync" -env._.path = "web/node_modules/.bin" -dir = "web" -run = "vitest" - -[tasks."web:format"] -env._.path = "web/node_modules/.bin" -dir = "web" -run = "prettier --check ." - -[tasks."web:format-fix"] -env._.path = "web/node_modules/.bin" -dir = "web" -run = "prettier --write ." - -[tasks."web:lint"] -env._.path = "web/node_modules/.bin" -dir = "web" -run = "eslint . --max-warnings 0 --concurrency 4" - -[tasks."web:lint-fix"] -run = "mise run web:lint --fix" - -[tasks."web:check"] -depends = "web:svelte-kit-sync" -env._.path = "web/node_modules/.bin" -dir = "web" -run = "tsc --noEmit" - -[tasks."web:check-svelte"] -depends = "web:svelte-kit-sync" -env._.path = "web/node_modules/.bin" -dir = "web" -run = "svelte-check --no-tsconfig --fail-on-warnings" - -[tasks."web:checklist"] -run = [ - "mise run web:install", - "mise run web:format", - "mise run web:check", - "mise run web:test --run", - "mise run web:lint", -] - - -# mobile -[tasks."mobile:codegen:dart"] -alias = "mobile:codegen" -description = "Execute build_runner to auto-generate dart code" -dir = "mobile" -sources = [ - "pubspec.yaml", - "build.yaml", - "lib/**/*.dart", - "infrastructure/**/*.drift", -] -outputs = { auto = true } -run = "dart run build_runner build --delete-conflicting-outputs" - -[tasks."mobile:codegen:pigeon"] -alias = "mobile:pigeon" -description = "Generate pigeon platform code" -dir = "mobile" -depends = [ - "mobile:pigeon:native-sync", - "mobile:pigeon:thumbnail", - "mobile:pigeon:background-worker", - "mobile:pigeon:background-worker-lock", - "mobile:pigeon:connectivity", -] - -[tasks."mobile:codegen:translation"] -alias = "mobile:translation" -description = "Generate translations from i18n JSONs" -dir = "mobile" -run = [ - { task = "i18n:format-fix" }, - { tasks = [ - "mobile:i18n:loader", - "mobile:i18n:keys", - ] }, -] - -[tasks."mobile:codegen:app-icon"] -description = "Generate app icons" -dir = "mobile" -run = "flutter pub run flutter_launcher_icons:main" - -[tasks."mobile:codegen:splash"] -description = "Generate splash screen" -dir = "mobile" -run = "flutter pub run flutter_native_splash:create" - -[tasks."mobile:test"] -description = "Run mobile tests" -dir = "mobile" -run = "flutter test" - -[tasks."mobile:lint"] -description = "Analyze Dart code" -dir = "mobile" -depends = ["mobile:analyze:dart", "mobile:analyze:dcm"] - -[tasks."mobile:lint-fix"] -description = "Auto-fix Dart code" -dir = "mobile" -depends = ["mobile:analyze:fix:dart", "mobile:analyze:fix:dcm"] - -[tasks."mobile:format"] -description = "Format Dart code" -dir = "mobile" -run = "dart format --set-exit-if-changed $(find lib -name '*.dart' -not \\( -name '*.g.dart' -o -name '*.drift.dart' -o -name '*.gr.dart' \\))" - -[tasks."mobile:build:android"] -description = "Build Android release" -dir = "mobile" -run = "flutter build appbundle" - -[tasks."mobile:drift:migration"] -alias = "mobile:migration" -description = "Generate database migrations" -dir = "mobile" -run = "dart run drift_dev make-migrations" - - -# mobile internal tasks -[tasks."mobile:pigeon:native-sync"] -description = "Generate native sync API pigeon code" -dir = "mobile" -hide = true -sources = ["pigeon/native_sync_api.dart"] -outputs = [ - "lib/platform/native_sync_api.g.dart", - "ios/Runner/Sync/Messages.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt", -] -run = [ - "dart run pigeon --input pigeon/native_sync_api.dart", - "dart format lib/platform/native_sync_api.g.dart", -] - -[tasks."mobile:pigeon:thumbnail"] -description = "Generate thumbnail API pigeon code" -dir = "mobile" -hide = true -sources = ["pigeon/thumbnail_api.dart"] -outputs = [ - "lib/platform/thumbnail_api.g.dart", - "ios/Runner/Images/Thumbnails.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt", -] -run = [ - "dart run pigeon --input pigeon/thumbnail_api.dart", - "dart format lib/platform/thumbnail_api.g.dart", -] - -[tasks."mobile:pigeon:background-worker"] -description = "Generate background worker API pigeon code" -dir = "mobile" -hide = true -sources = ["pigeon/background_worker_api.dart"] -outputs = [ - "lib/platform/background_worker_api.g.dart", - "ios/Runner/Background/BackgroundWorker.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt", -] -run = [ - "dart run pigeon --input pigeon/background_worker_api.dart", - "dart format lib/platform/background_worker_api.g.dart", -] - -[tasks."mobile:pigeon:background-worker-lock"] -description = "Generate background worker lock API pigeon code" -dir = "mobile" -hide = true -sources = ["pigeon/background_worker_lock_api.dart"] -outputs = [ - "lib/platform/background_worker_lock_api.g.dart", - "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt", -] -run = [ - "dart run pigeon --input pigeon/background_worker_lock_api.dart", - "dart format lib/platform/background_worker_lock_api.g.dart", -] - -[tasks."mobile:pigeon:connectivity"] -description = "Generate connectivity API pigeon code" -dir = "mobile" -hide = true -sources = ["pigeon/connectivity_api.dart"] -outputs = [ - "lib/platform/connectivity_api.g.dart", - "ios/Runner/Connectivity/Connectivity.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt", -] -run = [ - "dart run pigeon --input pigeon/connectivity_api.dart", - "dart format lib/platform/connectivity_api.g.dart", -] - -[tasks."mobile:i18n:loader"] -description = "Generate i18n loader" -dir = "mobile" -hide = true -sources = ["i18n/"] -outputs = "lib/generated/codegen_loader.g.dart" -run = [ - "dart run easy_localization:generate -S ../i18n", - "dart format lib/generated/codegen_loader.g.dart", -] - -[tasks."mobile:i18n:keys"] -description = "Generate i18n keys" -dir = "mobile" -hide = true -sources = ["i18n/en.json"] -outputs = "lib/generated/intl_keys.g.dart" -run = [ - "dart run bin/generate_keys.dart", - "dart format lib/generated/intl_keys.g.dart", -] - -[tasks."mobile:analyze:dart"] -description = "Run Dart analysis" -dir = "mobile" -hide = true -run = "dart analyze --fatal-infos" - -[tasks."mobile:analyze:dcm"] -description = "Run Dart Code Metrics" -dir = "mobile" -hide = true -run = "dcm analyze lib --fatal-style --fatal-warnings" - -[tasks."mobile:analyze:fix:dart"] -description = "Auto-fix Dart analysis" -dir = "mobile" -hide = true -run = "dart fix --apply" - -[tasks."mobile:analyze:fix:dcm"] -description = "Auto-fix Dart Code Metrics" -dir = "mobile" -hide = true -run = "dcm fix lib" - -# docs deployment -[tasks."tg:fmt"] -run = "terragrunt hclfmt" -description = "Format terragrunt files" - -[tasks.tf] -run = "terragrunt run --all" -description = "Wrapper for terragrunt run-all" -dir = "{{cwd}}" - -[tasks."tf:fmt"] -run = "tofu fmt -recursive tf/" -description = "Format terraform files" - -[tasks."tf:init"] -run = "mise run tf init -- -reconfigure" -dir = "{{cwd}}" +dir = "i18n" +run = "pnpm dlx sort-json *.json" diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt index ae2ec22a71..f62f25558d 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt @@ -143,7 +143,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, val mediaUrls = call.argument>("mediaUrls") if (mediaUrls != null) { if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { - moveToTrash(mediaUrls, result) + moveToTrash(mediaUrls, result) } else { result.error("PERMISSION_DENIED", "Media permission required", null) } @@ -155,15 +155,23 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, "restoreFromTrash" -> { val fileName = call.argument("fileName") val type = call.argument("type") + val mediaId = call.argument("mediaId") if (fileName != null && type != null) { if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { restoreFromTrash(fileName, type, result) } else { result.error("PERMISSION_DENIED", "Media permission required", null) } - } else { - result.error("INVALID_NAME", "The file name is not specified.", null) - } + } else + if (mediaId != null && type != null) { + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { + restoreFromTrashById(mediaId, type, result) + } else { + result.error("PERMISSION_DENIED", "Media permission required", null) + } + } else { + result.error("INVALID_PARAMS", "Required params are not specified.", null) + } } "requestManageMediaPermission" -> { @@ -175,6 +183,17 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } } + "hasManageMediaPermission" -> { + if (hasManageMediaPermission()) { + Log.i("Manage storage permission", "Permission already granted") + result.success(true) + } else { + result.success(false) + } + } + + "manageMediaPermission" -> requestManageMediaPermission(result) + else -> result.notImplemented() } } @@ -224,25 +243,47 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } @RequiresApi(Build.VERSION_CODES.R) - private fun toggleTrash(contentUris: List, isTrashed: Boolean, result: Result) { - val activity = activityBinding?.activity - val contentResolver = context?.contentResolver - if (activity == null || contentResolver == null) { - result.error("TrashError", "Activity or ContentResolver not available", null) - return - } + private fun restoreFromTrashById(mediaId: String, type: Int, result: Result) { + val id = mediaId.toLongOrNull() + if (id == null) { + result.error("INVALID_ID", "The file id is not a valid number: $mediaId", null) + return + } + if (!isInTrash(id)) { + result.error("TrashNotFound", "Item with id=$id not found in trash", null) + return + } - try { - val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed) - pendingResult = result // Store for onActivityResult - activity.startIntentSenderForResult( - pendingIntent.intentSender, - trashRequestCode, - null, 0, 0, 0 - ) - } catch (e: Exception) { - Log.e("TrashError", "Error creating or starting trash request", e) - result.error("TrashError", "Error creating or starting trash request", null) + val uri = ContentUris.withAppendedId(contentUriForType(type), id) + + try { + Log.i(TAG, "restoreFromTrashById: uri=$uri (type=$type,id=$id)") + restoreUris(listOf(uri), result) + } catch (e: Exception) { + Log.w(TAG, "restoreFromTrashById failed", e) + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun toggleTrash(contentUris: List, isTrashed: Boolean, result: Result) { + val activity = activityBinding?.activity + val contentResolver = context?.contentResolver + if (activity == null || contentResolver == null) { + result.error("TrashError", "Activity or ContentResolver not available", null) + return + } + + try { + val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed) + pendingResult = result // Store for onActivityResult + activity.startIntentSenderForResult( + pendingIntent.intentSender, + trashRequestCode, + null, 0, 0, 0 + ) + } catch (e: Exception) { + Log.e("TrashError", "Error creating or starting trash request", e) + result.error("TrashError", "Error creating or starting trash request", null) } } @@ -264,14 +305,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor -> if (cursor.moveToFirst()) { val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)) - // same order as AssetType from dart - val contentUri = when (type) { - 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI - 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI - 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - else -> queryUri - } - return ContentUris.withAppendedId(contentUri, id) + return ContentUris.withAppendedId(contentUriForType(type), id) } } return null @@ -315,6 +349,40 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } return false } + + @RequiresApi(Build.VERSION_CODES.R) + private fun isInTrash(id: Long): Boolean { + val contentResolver = context?.contentResolver ?: return false + val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + val args = Bundle().apply { + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?") + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString())) + putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) + putInt(ContentResolver.QUERY_ARG_LIMIT, 1) + } + return contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null) + ?.use { it.moveToFirst() } == true + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun restoreUris(uris: List, result: Result) { + if (uris.isEmpty()) { + result.error("TrashError", "No URIs to restore", null) + return + } + Log.i(TAG, "restoreUris: count=${uris.size}, first=${uris.first()}") + toggleTrash(uris, false, result) + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun contentUriForType(type: Int): Uri = + when (type) { + // same order as AssetType from dart + 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + } } private const val TAG = "BackgroundServicePlugin" diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt index 08ff0e821a..e6cf92f573 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt @@ -305,6 +305,7 @@ interface NativeSyncApi { fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List fun hashAssets(assetIds: List, allowNetworkAccess: Boolean, callback: (Result>) -> Unit) fun cancelHashing() + fun getTrashedAssets(): Map> companion object { /** The codec used by NativeSyncApi. */ @@ -483,6 +484,21 @@ interface NativeSyncApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getTrashedAssets()) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt index 5deacc30db..6d2c35d78f 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt @@ -21,4 +21,9 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na override fun getMediaChanges(): SyncDelta { throw IllegalStateException("Method not supported on this Android version.") } + + override fun getTrashedAssets(): Map> { + //Method not supported on this Android version. + return emptyMap() + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt index 052032e143..ca54c9f823 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt @@ -1,7 +1,9 @@ package app.alextran.immich.sync +import android.content.ContentResolver import android.content.Context import android.os.Build +import android.os.Bundle import android.provider.MediaStore import androidx.annotation.RequiresApi import androidx.annotation.RequiresExtension @@ -86,4 +88,29 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na // Unmounted volumes are handled in dart when the album is removed return SyncDelta(hasChanges, changed, deleted, assetAlbums) } + + override fun getTrashedAssets(): Map> { + + val result = LinkedHashMap>() + val volumes = MediaStore.getExternalVolumeNames(ctx) + + for (volume in volumes) { + + val queryArgs = Bundle().apply { + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, MEDIA_SELECTION) + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, MEDIA_SELECTION_ARGS) + putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) + } + + getCursor(volume, queryArgs).use { cursor -> + getAssets(cursor).forEach { res -> + if (res is AssetResult.ValidAsset) { + result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset) + } + } + } + } + + return result.mapValues { it.value.toList() } + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index ca2781f7b4..b1e9dd7d44 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -4,6 +4,8 @@ import android.annotation.SuppressLint import android.content.ContentUris import android.content.Context import android.database.Cursor +import android.net.Uri +import android.os.Bundle import android.provider.MediaStore import android.util.Base64 import androidx.core.database.getStringOrNull @@ -81,6 +83,16 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { sortOrder, ) + protected fun getCursor( + volume: String, + queryArgs: Bundle + ): Cursor? = ctx.contentResolver.query( + MediaStore.Files.getContentUri(volume), + ASSET_PROJECTION, + queryArgs, + null + ) + protected fun getAssets(cursor: Cursor?): Sequence { return sequence { cursor?.use { c -> diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 1c22a83dc3..6b9ce07465 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 3025, - "android.injected.version.name" => "2.2.2", + "android.injected.version.code" => 3026, + "android.injected.version.name" => "2.2.3", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/drift_schemas/main/drift_schema_v13.json b/mobile/drift_schemas/main/drift_schema_v13.json new file mode 100644 index 0000000000..e527e8d78a --- /dev/null +++ b/mobile/drift_schemas/main/drift_schema_v13.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"library_id","getter_name":"libraryId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"linked_remote_album_id","getter_name":"linkedRemoteAlbumId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":6,"references":[3,5],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":7,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":8,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":9,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n","unique":true,"columns":[]}},{"id":10,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n","unique":true,"columns":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)","unique":false,"columns":[]}},{"id":12,"references":[],"type":"table","data":{"name":"auth_user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"pin_code","getter_name":"pinCode","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":13,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":14,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":15,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":16,"references":[1,4],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":17,"references":[4,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":18,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":19,"references":[1,18],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":20,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":21,"references":[1,20],"type":"table","data":{"name":"asset_face_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"person_id","getter_name":"personId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES person_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES person_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"image_width","getter_name":"imageWidth","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"image_height","getter_name":"imageHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x1","getter_name":"boundingBoxX1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y1","getter_name":"boundingBoxY1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x2","getter_name":"boundingBoxX2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y2","getter_name":"boundingBoxY2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":22,"references":[],"type":"table","data":{"name":"store_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"string_value","getter_name":"stringValue","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"int_value","getter_name":"intValue","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":23,"references":[],"type":"table","data":{"name":"trashed_local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id","album_id"]}},{"id":24,"references":[15],"type":"index","data":{"on":15,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}},{"id":25,"references":[23],"type":"index","data":{"on":23,"name":"idx_trashed_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":26,"references":[23],"type":"index","data":{"on":23,"name":"idx_trashed_local_asset_album","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)","unique":false,"columns":[]}}]} \ No newline at end of file diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 15130702bc..599e7990f4 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -32,6 +32,9 @@ FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; }; FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; }; + FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; }; + FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; }; + FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -153,6 +156,13 @@ path = WidgetExtension; sourceTree = ""; }; + FEE084F22EC172080045228E /* Schemas */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = Schemas; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -160,6 +170,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FEE084F82EC172460045228E /* SQLiteData in Frameworks */, + FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */, + FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */, D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -254,6 +267,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + FEE084F22EC172080045228E /* Schemas */, B231F52D2E93A44A00BC45D1 /* Core */, B25D37792E72CA15008B6CA7 /* Connectivity */, B21E34A62E5AF9760031FDB9 /* Background */, @@ -341,6 +355,7 @@ fileSystemSynchronizedGroups = ( B231F52D2E93A44A00BC45D1 /* Core */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */, + FEE084F22EC172080045228E /* Schemas */, ); name = Runner; productName = Runner; @@ -419,6 +434,10 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */, + FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; @@ -1201,6 +1220,43 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/sqlite-data"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.3.0; + }; + }; + FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-http-structured-headers.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + FEE084F72EC172460045228E /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */; + productName = SQLiteData; + }; + FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */ = { + isa = XCSwiftPackageProductDependency; + package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */; + productName = RawStructuredFieldValues; + }; + FEE084FC2EC1725A0045228E /* StructuredFieldValues */ = { + isa = XCSwiftPackageProductDependency; + package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */; + productName = StructuredFieldValues; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..432e81234d --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,177 @@ +{ + "originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49", + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", + "version" : "1.1.0" + } + }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", + "version" : "7.8.0" + } + }, + { + "identity" : "opencombine", + "kind" : "remoteSourceControl", + "location" : "https://github.com/OpenCombine/OpenCombine.git", + "state" : { + "revision" : "8576f0d579b27020beccbccc3ea6844f3ddfc2c2", + "version" : "0.14.0" + } + }, + { + "identity" : "sqlite-data", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/sqlite-data", + "state" : { + "revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", + "version" : "1.7.2" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", + "version" : "1.10.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", + "version" : "2.0.9" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-sharing", + "state" : { + "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", + "version" : "2.7.4" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", + "version" : "1.18.7" + } + }, + { + "identity" : "swift-structured-queries", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-structured-queries", + "state" : { + "revision" : "9c84335373bae5f5c9f7b5f0adf3ae10f2cab5b9", + "version" : "0.25.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", + "version" : "1.7.0" + } + } + ], + "version" : 3 +} diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..ff8a53ff4b --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,168 @@ +{ + "originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49", + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", + "version" : "7.8.0" + } + }, + { + "identity" : "sqlite-data", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/sqlite-data", + "state" : { + "revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", + "version" : "1.7.2" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", + "version" : "1.10.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", + "version" : "2.0.9" + } + }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-sharing", + "state" : { + "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", + "version" : "2.7.4" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", + "version" : "1.18.7" + } + }, + { + "identity" : "swift-structured-queries", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-structured-queries", + "state" : { + "revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756", + "version" : "0.25.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", + "version" : "1.7.0" + } + } + ], + "version" : 3 +} diff --git a/mobile/ios/Runner/Schemas/Constants.swift b/mobile/ios/Runner/Schemas/Constants.swift new file mode 100644 index 0000000000..a4b0f701a1 --- /dev/null +++ b/mobile/ios/Runner/Schemas/Constants.swift @@ -0,0 +1,177 @@ +import SQLiteData + +struct Endpoint: Codable { + let url: URL + let status: Status + + enum Status: String, Codable { + case loading, valid, error, unknown + } +} + +enum StoreKey: Int, CaseIterable, QueryBindable { + // MARK: - Int + case _version = 0 + static let version = Typed(rawValue: ._version) + case _deviceIdHash = 3 + static let deviceIdHash = Typed(rawValue: ._deviceIdHash) + case _backupTriggerDelay = 8 + static let backupTriggerDelay = Typed(rawValue: ._backupTriggerDelay) + case _tilesPerRow = 103 + static let tilesPerRow = Typed(rawValue: ._tilesPerRow) + case _groupAssetsBy = 105 + static let groupAssetsBy = Typed(rawValue: ._groupAssetsBy) + case _uploadErrorNotificationGracePeriod = 106 + static let uploadErrorNotificationGracePeriod = Typed(rawValue: ._uploadErrorNotificationGracePeriod) + case _thumbnailCacheSize = 110 + static let thumbnailCacheSize = Typed(rawValue: ._thumbnailCacheSize) + case _imageCacheSize = 111 + static let imageCacheSize = Typed(rawValue: ._imageCacheSize) + case _albumThumbnailCacheSize = 112 + static let albumThumbnailCacheSize = Typed(rawValue: ._albumThumbnailCacheSize) + case _selectedAlbumSortOrder = 113 + static let selectedAlbumSortOrder = Typed(rawValue: ._selectedAlbumSortOrder) + case _logLevel = 115 + static let logLevel = Typed(rawValue: ._logLevel) + case _mapRelativeDate = 119 + static let mapRelativeDate = Typed(rawValue: ._mapRelativeDate) + case _mapThemeMode = 124 + static let mapThemeMode = Typed(rawValue: ._mapThemeMode) + + // MARK: - String + case _assetETag = 1 + static let assetETag = Typed(rawValue: ._assetETag) + case _currentUser = 2 + static let currentUser = Typed(rawValue: ._currentUser) + case _deviceId = 4 + static let deviceId = Typed(rawValue: ._deviceId) + case _accessToken = 11 + static let accessToken = Typed(rawValue: ._accessToken) + case _serverEndpoint = 12 + static let serverEndpoint = Typed(rawValue: ._serverEndpoint) + case _sslClientCertData = 15 + static let sslClientCertData = Typed(rawValue: ._sslClientCertData) + case _sslClientPasswd = 16 + static let sslClientPasswd = Typed(rawValue: ._sslClientPasswd) + case _themeMode = 102 + static let themeMode = Typed(rawValue: ._themeMode) + case _customHeaders = 127 + static let customHeaders = Typed<[String: String]>(rawValue: ._customHeaders) + case _primaryColor = 128 + static let primaryColor = Typed(rawValue: ._primaryColor) + case _preferredWifiName = 133 + static let preferredWifiName = Typed(rawValue: ._preferredWifiName) + + // MARK: - Endpoint + case _externalEndpointList = 135 + static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList) + + // MARK: - URL + case _localEndpoint = 134 + static let localEndpoint = Typed(rawValue: ._localEndpoint) + case _serverUrl = 10 + static let serverUrl = Typed(rawValue: ._serverUrl) + + // MARK: - Date + case _backupFailedSince = 5 + static let backupFailedSince = Typed(rawValue: ._backupFailedSince) + + // MARK: - Bool + case _backupRequireWifi = 6 + static let backupRequireWifi = Typed(rawValue: ._backupRequireWifi) + case _backupRequireCharging = 7 + static let backupRequireCharging = Typed(rawValue: ._backupRequireCharging) + case _autoBackup = 13 + static let autoBackup = Typed(rawValue: ._autoBackup) + case _backgroundBackup = 14 + static let backgroundBackup = Typed(rawValue: ._backgroundBackup) + case _loadPreview = 100 + static let loadPreview = Typed(rawValue: ._loadPreview) + case _loadOriginal = 101 + static let loadOriginal = Typed(rawValue: ._loadOriginal) + case _dynamicLayout = 104 + static let dynamicLayout = Typed(rawValue: ._dynamicLayout) + case _backgroundBackupTotalProgress = 107 + static let backgroundBackupTotalProgress = Typed(rawValue: ._backgroundBackupTotalProgress) + case _backgroundBackupSingleProgress = 108 + static let backgroundBackupSingleProgress = Typed(rawValue: ._backgroundBackupSingleProgress) + case _storageIndicator = 109 + static let storageIndicator = Typed(rawValue: ._storageIndicator) + case _advancedTroubleshooting = 114 + static let advancedTroubleshooting = Typed(rawValue: ._advancedTroubleshooting) + case _preferRemoteImage = 116 + static let preferRemoteImage = Typed(rawValue: ._preferRemoteImage) + case _loopVideo = 117 + static let loopVideo = Typed(rawValue: ._loopVideo) + case _mapShowFavoriteOnly = 118 + static let mapShowFavoriteOnly = Typed(rawValue: ._mapShowFavoriteOnly) + case _selfSignedCert = 120 + static let selfSignedCert = Typed(rawValue: ._selfSignedCert) + case _mapIncludeArchived = 121 + static let mapIncludeArchived = Typed(rawValue: ._mapIncludeArchived) + case _ignoreIcloudAssets = 122 + static let ignoreIcloudAssets = Typed(rawValue: ._ignoreIcloudAssets) + case _selectedAlbumSortReverse = 123 + static let selectedAlbumSortReverse = Typed(rawValue: ._selectedAlbumSortReverse) + case _mapwithPartners = 125 + static let mapwithPartners = Typed(rawValue: ._mapwithPartners) + case _enableHapticFeedback = 126 + static let enableHapticFeedback = Typed(rawValue: ._enableHapticFeedback) + case _dynamicTheme = 129 + static let dynamicTheme = Typed(rawValue: ._dynamicTheme) + case _colorfulInterface = 130 + static let colorfulInterface = Typed(rawValue: ._colorfulInterface) + case _syncAlbums = 131 + static let syncAlbums = Typed(rawValue: ._syncAlbums) + case _autoEndpointSwitching = 132 + static let autoEndpointSwitching = Typed(rawValue: ._autoEndpointSwitching) + case _loadOriginalVideo = 136 + static let loadOriginalVideo = Typed(rawValue: ._loadOriginalVideo) + case _manageLocalMediaAndroid = 137 + static let manageLocalMediaAndroid = Typed(rawValue: ._manageLocalMediaAndroid) + case _readonlyModeEnabled = 138 + static let readonlyModeEnabled = Typed(rawValue: ._readonlyModeEnabled) + case _autoPlayVideo = 139 + static let autoPlayVideo = Typed(rawValue: ._autoPlayVideo) + case _photoManagerCustomFilter = 1000 + static let photoManagerCustomFilter = Typed(rawValue: ._photoManagerCustomFilter) + case _betaPromptShown = 1001 + static let betaPromptShown = Typed(rawValue: ._betaPromptShown) + case _betaTimeline = 1002 + static let betaTimeline = Typed(rawValue: ._betaTimeline) + case _enableBackup = 1003 + static let enableBackup = Typed(rawValue: ._enableBackup) + case _useWifiForUploadVideos = 1004 + static let useWifiForUploadVideos = Typed(rawValue: ._useWifiForUploadVideos) + case _useWifiForUploadPhotos = 1005 + static let useWifiForUploadPhotos = Typed(rawValue: ._useWifiForUploadPhotos) + case _needBetaMigration = 1006 + static let needBetaMigration = Typed(rawValue: ._needBetaMigration) + case _shouldResetSync = 1007 + static let shouldResetSync = Typed(rawValue: ._shouldResetSync) + + struct Typed: RawRepresentable { + let rawValue: StoreKey + + @_transparent + init(rawValue value: StoreKey) { + self.rawValue = value + } + } +} + +enum BackupSelection: Int, QueryBindable { + case selected, none, excluded +} + +enum AvatarColor: Int, QueryBindable { + case primary, pink, red, yellow, blue, green, purple, orange, gray, amber +} + +enum AlbumUserRole: Int, QueryBindable { + case editor, viewer +} + +enum MemoryType: Int, QueryBindable { + case onThisDay +} diff --git a/mobile/ios/Runner/Schemas/Store.swift b/mobile/ios/Runner/Schemas/Store.swift new file mode 100644 index 0000000000..ee5280b6c0 --- /dev/null +++ b/mobile/ios/Runner/Schemas/Store.swift @@ -0,0 +1,146 @@ +import SQLiteData + +enum StoreError: Error { + case invalidJSON(String) + case invalidURL(String) + case encodingFailed +} + +protocol StoreConvertible { + associatedtype StorageType + static func fromValue(_ value: StorageType) throws(StoreError) -> Self + static func toValue(_ value: Self) throws(StoreError) -> StorageType +} + +extension Int: StoreConvertible { + static func fromValue(_ value: Int) -> Int { value } + static func toValue(_ value: Int) -> Int { value } +} + +extension Bool: StoreConvertible { + static func fromValue(_ value: Int) -> Bool { value == 1 } + static func toValue(_ value: Bool) -> Int { value ? 1 : 0 } +} + +extension Date: StoreConvertible { + static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) } + static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) } +} + +extension String: StoreConvertible { + static func fromValue(_ value: String) -> String { value } + static func toValue(_ value: String) -> String { value } +} + +extension URL: StoreConvertible { + static func fromValue(_ value: String) throws(StoreError) -> URL { + guard let url = URL(string: value) else { + throw StoreError.invalidURL(value) + } + return url + } + static func toValue(_ value: URL) -> String { value.absoluteString } +} + +extension StoreConvertible where Self: Codable, StorageType == String { + static var jsonDecoder: JSONDecoder { JSONDecoder() } + static var jsonEncoder: JSONEncoder { JSONEncoder() } + + static func fromValue(_ value: String) throws(StoreError) -> Self { + do { + return try jsonDecoder.decode(Self.self, from: Data(value.utf8)) + } catch { + throw StoreError.invalidJSON(value) + } + } + + static func toValue(_ value: Self) throws(StoreError) -> String { + let encoded: Data + do { + encoded = try jsonEncoder.encode(value) + } catch { + throw StoreError.encodingFailed + } + + guard let string = String(data: encoded, encoding: .utf8) else { + throw StoreError.encodingFailed + } + return string + } +} + +extension Array: StoreConvertible where Element: Codable { + typealias StorageType = String +} + +extension Dictionary: StoreConvertible where Key == String, Value: Codable { + typealias StorageType = String +} + +class StoreRepository { + private let db: DatabasePool + + init(db: DatabasePool) { + self.db = db + } + + func get(_ key: StoreKey.Typed) throws -> T? where T.StorageType == Int { + let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) } + if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func get(_ key: StoreKey.Typed) throws -> T? where T.StorageType == String { + let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) } + if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func get(_ key: StoreKey.Typed) async throws -> T? where T.StorageType == Int { + let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) } + if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func get(_ key: StoreKey.Typed) async throws -> T? where T.StorageType == String { + let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) } + if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil { + return try T.fromValue(value) + } + return nil + } + + func set(_ key: StoreKey.Typed, value: T) throws where T.StorageType == Int { + let value = try T.toValue(value) + try db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn) + } + } + + func set(_ key: StoreKey.Typed, value: T) throws where T.StorageType == String { + let value = try T.toValue(value) + try db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn) + } + } + + func set(_ key: StoreKey.Typed, value: T) async throws where T.StorageType == Int { + let value = try T.toValue(value) + try await db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn) + } + } + + func set(_ key: StoreKey.Typed, value: T) async throws where T.StorageType == String { + let value = try T.toValue(value) + try await db.write { conn in + try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn) + } + } +} diff --git a/mobile/ios/Runner/Schemas/Tables.swift b/mobile/ios/Runner/Schemas/Tables.swift new file mode 100644 index 0000000000..c256b0d0ed --- /dev/null +++ b/mobile/ios/Runner/Schemas/Tables.swift @@ -0,0 +1,237 @@ +import GRDB +import SQLiteData + +@Table("asset_face_entity") +struct AssetFace { + let id: String + let assetId: String + let personId: String? + let imageWidth: Int + let imageHeight: Int + let boundingBoxX1: Int + let boundingBoxY1: Int + let boundingBoxX2: Int + let boundingBoxY2: Int + let sourceType: String +} + +@Table("auth_user_entity") +struct AuthUser { + let id: String + let name: String + let email: String + let isAdmin: Bool + let hasProfileImage: Bool + let profileChangedAt: Date + let avatarColor: AvatarColor + let quotaSizeInBytes: Int + let quotaUsageInBytes: Int + let pinCode: String? +} + +@Table("local_album_entity") +struct LocalAlbum { + let id: String + let backupSelection: BackupSelection + let linkedRemoteAlbumId: String? + let marker_: Bool? + let name: String + let isIosSharedAlbum: Bool + let updatedAt: Date +} + +@Table("local_album_asset_entity") +struct LocalAlbumAsset { + let id: ID + let marker_: String? + + @Selection + struct ID { + let assetId: String + let albumId: String + } +} + +@Table("local_asset_entity") +struct LocalAsset { + let id: String + let checksum: String? + let createdAt: Date + let durationInSeconds: Int? + let height: Int? + let isFavorite: Bool + let name: String + let orientation: String + let type: Int + let updatedAt: Date + let width: Int? +} + +@Table("memory_asset_entity") +struct MemoryAsset { + let id: ID + + @Selection + struct ID { + let assetId: String + let albumId: String + } +} + +@Table("memory_entity") +struct Memory { + let id: String + let createdAt: Date + let updatedAt: Date + let deletedAt: Date? + let ownerId: String + let type: MemoryType + let data: String + let isSaved: Bool + let memoryAt: Date + let seenAt: Date? + let showAt: Date? + let hideAt: Date? +} + +@Table("partner_entity") +struct Partner { + let id: ID + let inTimeline: Bool + + @Selection + struct ID { + let sharedById: String + let sharedWithId: String + } +} + +@Table("person_entity") +struct Person { + let id: String + let createdAt: Date + let updatedAt: Date + let ownerId: String + let name: String + let faceAssetId: String? + let isFavorite: Bool + let isHidden: Bool + let color: String? + let birthDate: Date? +} + +@Table("remote_album_entity") +struct RemoteAlbum { + let id: String + let createdAt: Date + let description: String? + let isActivityEnabled: Bool + let name: String + let order: Int + let ownerId: String + let thumbnailAssetId: String? + let updatedAt: Date +} + +@Table("remote_album_asset_entity") +struct RemoteAlbumAsset { + let id: ID + + @Selection + struct ID { + let assetId: String + let albumId: String + } +} + +@Table("remote_album_user_entity") +struct RemoteAlbumUser { + let id: ID + let role: AlbumUserRole + + @Selection + struct ID { + let albumId: String + let userId: String + } +} + +@Table("remote_asset_entity") +struct RemoteAsset { + let id: String + let checksum: String? + let deletedAt: Date? + let isFavorite: Int + let libraryId: String? + let livePhotoVideoId: String? + let localDateTime: Date? + let orientation: String + let ownerId: String + let stackId: String? + let visibility: Int +} + +@Table("remote_exif_entity") +struct RemoteExif { + @Column(primaryKey: true) + let assetId: String + let city: String? + let state: String? + let country: String? + let dateTimeOriginal: Date? + let description: String? + let height: Int? + let width: Int? + let exposureTime: String? + let fNumber: Double? + let fileSize: Int? + let focalLength: Double? + let latitude: Double? + let longitude: Double? + let iso: Int? + let make: String? + let model: String? + let lens: String? + let orientation: String? + let timeZone: String? + let rating: Int? + let projectionType: String? +} + +@Table("stack_entity") +struct Stack { + let id: String + let createdAt: Date + let updatedAt: Date + let ownerId: String + let primaryAssetId: String +} + +@Table("store_entity") +struct Store { + let id: StoreKey + let stringValue: String? + let intValue: Int? +} + +@Table("user_entity") +struct User { + let id: String + let name: String + let email: String + let hasProfileImage: Bool + let profileChangedAt: Date + let avatarColor: AvatarColor +} + +@Table("user_metadata_entity") +struct UserMetadata { + let id: ID + let value: Data + + @Selection + struct ID { + let userId: String + let key: Date + } +} diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift index 6bcafb9215..bbe18e7375 100644 --- a/mobile/ios/Runner/Sync/Messages.g.swift +++ b/mobile/ios/Runner/Sync/Messages.g.swift @@ -364,6 +364,7 @@ protocol NativeSyncApi { func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func cancelHashing() throws + func getTrashedAssets() throws -> [String: [PlatformAsset]] } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -532,5 +533,20 @@ class NativeSyncApiSetup { } else { cancelHashingChannel.setMessageHandler(nil) } + let getTrashedAssetsChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + getTrashedAssetsChannel.setMessageHandler { _, reply in + do { + let result = try api.getTrashedAssets() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getTrashedAssetsChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index 75981fb7ea..03493f57ca 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -3,15 +3,15 @@ import CryptoKit struct AssetWrapper: Hashable, Equatable { let asset: PlatformAsset - + init(with asset: PlatformAsset) { self.asset = asset } - + func hash(into hasher: inout Hasher) { hasher.combine(self.asset.id) } - + static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool { return lhs.asset.id == rhs.asset.id } @@ -19,31 +19,31 @@ struct AssetWrapper: Hashable, Equatable { class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { static let name = "NativeSyncApi" - + static func register(with registrar: any FlutterPluginRegistrar) { let instance = NativeSyncApiImpl() NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) registrar.publish(instance) } - + func detachFromEngine(for registrar: any FlutterPluginRegistrar) { super.detachFromEngine() } - + private let defaults: UserDefaults private let changeTokenKey = "immich:changeToken" private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] private let recoveredAlbumSubType = 1000000219 - + private var hashTask: Task? private static let hashCancelledCode = "HASH_CANCELLED" private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil)) - - + + init(with defaults: UserDefaults = .standard) { self.defaults = defaults } - + @available(iOS 16, *) private func getChangeToken() -> PHPersistentChangeToken? { guard let data = defaults.data(forKey: changeTokenKey) else { @@ -51,7 +51,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { } return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data) } - + @available(iOS 16, *) private func saveChangeToken(token: PHPersistentChangeToken) -> Void { guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else { @@ -59,18 +59,18 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { } defaults.set(data, forKey: changeTokenKey) } - + func clearSyncCheckpoint() -> Void { defaults.removeObject(forKey: changeTokenKey) } - + func checkpointSync() { guard #available(iOS 16, *) else { return } saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken) } - + func shouldFullSync() -> Bool { guard #available(iOS 16, *), PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized, @@ -78,36 +78,36 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { // When we do not have access to photo library, older iOS version or No token available, fallback to full sync return true } - + guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else { // Cannot fetch persistent changes return true } - + return false } - + func getAlbums() throws -> [PlatformAlbum] { var albums: [PlatformAlbum] = [] - + albumTypes.forEach { type in let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) for i in 0.. SyncDelta { guard #available(iOS 16, *) else { throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil) } - + guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else { throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil) } - + guard let storedToken = getChangeToken() else { // No token exists, definitely need a full sync print("MediaManager::getMediaChanges: No token found") throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil) } - + let currentToken = PHPhotoLibrary.shared().currentChangeToken if storedToken == currentToken { return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:]) } - + do { let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) - + var updatedAssets: Set = [] var deletedAssets: Set = [] - + for change in changes { guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue } - + let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) deletedAssets.formUnion(details.deletedLocalIdentifiers) - + if (updated.isEmpty) { continue } - + let options = PHFetchOptions() options.includeHiddenAssets = false let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options) for i in 0..) -> [String: [String]] { guard !assets.isEmpty else { return [:] } - + var albumAssets: [String: [String]] = [:] - + for type in albumTypes { let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) collections.enumerateObjects { (album, _, _) in @@ -211,13 +211,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { } return albumAssets } - + func getAssetIdsForAlbum(albumId: String) throws -> [String] { let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) guard let album = collections.firstObject else { return [] } - + var ids: [String] = [] let options = PHFetchOptions() options.includeHiddenAssets = false @@ -227,13 +227,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { } return ids } - + func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 { let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) guard let album = collections.firstObject else { return 0 } - + let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp)) let options = PHFetchOptions() options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) @@ -241,32 +241,32 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { let assets = getAssetsFromAlbum(in: album, options: options) return Int64(assets.count) } - + func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] { let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) guard let album = collections.firstObject else { return [] } - + let options = PHFetchOptions() options.includeHiddenAssets = false if(updatedTimeCond != nil) { let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) } - + let result = getAssetsFromAlbum(in: album, options: options) if(result.count == 0) { return [] } - + var assets: [PlatformAsset] = [] result.enumerateObjects { (asset, _, _) in assets.append(asset.toPlatformAsset()) } return assets } - + func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) { if let prevTask = hashTask { prevTask.cancel() @@ -284,11 +284,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { missingAssetIds.remove(asset.localIdentifier) assets.append(asset) } - + if Task.isCancelled { return self?.completeWhenActive(for: completion, with: Self.hashCancelled) } - + await withTaskGroup(of: HashResult?.self) { taskGroup in var results = [HashResult]() results.reserveCapacity(assets.count) @@ -301,28 +301,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess) } } - + for await result in taskGroup { guard let result = result else { return self?.completeWhenActive(for: completion, with: Self.hashCancelled) } results.append(result) } - + for missing in missingAssetIds { results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil)) } - + return self?.completeWhenActive(for: completion, with: .success(results)) } } } - + func cancelHashing() { hashTask?.cancel() hashTask = nil } - + private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? { class RequestRef { var id: PHAssetResourceDataRequestID? @@ -332,21 +332,21 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { if Task.isCancelled { return nil } - + guard let resource = asset.getResource() else { return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil) } - + if Task.isCancelled { return nil } - + let options = PHAssetResourceRequestOptions() options.isNetworkAccessAllowed = allowNetworkAccess - + return await withCheckedContinuation { continuation in var hasher = Insecure.SHA1() - + requestRef.id = PHAssetResourceManager.default().requestData( for: resource, options: options, @@ -377,7 +377,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { PHAssetResourceManager.default().cancelDataRequest(requestId) }) } - + + func getTrashedAssets() throws -> [String: [PlatformAsset]] { + throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil) + } + private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult { // Ensure to actually getting all assets for the Recents album if (album.assetCollectionSubtype == .smartAlbumUserLibrary) { diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 8229c65534..d167d5fb2d 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -32,6 +32,17 @@ platform :ios do ) end + # Helper method to get version from pubspec.yaml +def get_version_from_pubspec + require 'yaml' + + pubspec_path = File.join(Dir.pwd, "../..", "pubspec.yaml") + pubspec = YAML.load_file(pubspec_path) + + version_string = pubspec['version'] + version_string ? version_string.split('+').first : nil +end + # Helper method to configure code signing for all targets def configure_code_signing(bundle_id_suffix: "") bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" @@ -101,7 +112,7 @@ platform :ios do workspace: "Runner.xcworkspace", configuration: configuration, export_method: "app-store", - xcargs: "CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", + xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", export_options: { provisioningProfiles: { "#{app_identifier}" => "#{app_identifier} AppStore", @@ -158,7 +169,8 @@ platform :ios do # Build and upload with version number build_and_upload( api_key: api_key, - version_number: "2.1.0" + version_number: get_version_from_pubspec, + distribute_external: false, ) end @@ -168,8 +180,9 @@ platform :ios do path: "./Runner.xcodeproj", targets: ["Runner", "ShareExtension", "WidgetExtension"] ) + increment_version_number( - version_number: "2.2.2" + version_number: get_version_from_pubspec ) increment_build_number( build_number: latest_testflight_build_number + 1, @@ -182,7 +195,7 @@ platform :ios do configuration: "Release", export_method: "app-store", skip_package_ipa: false, - xcargs: "-allowProvisioningUpdates", + xcargs: "-skipMacroValidation -allowProvisioningUpdates", export_options: { method: "app-store", signingStyle: "automatic", @@ -197,4 +210,37 @@ platform :ios do ) end + desc "iOS Build Only (no TestFlight upload)" + lane :gha_build_only do + # Use the same build process as production, just skip the upload + # This ensures PR builds validate the same way as production builds + + # Install provisioning profiles (use development profiles for PR builds) + install_provisioning_profile(path: "profile_dev.mobileprovision") + install_provisioning_profile(path: "profile_dev_share.mobileprovision") + install_provisioning_profile(path: "profile_dev_widget.mobileprovision") + + # Configure code signing for dev bundle IDs + configure_code_signing(bundle_id_suffix: "development") + + # Build the app (same as gha_testflight_dev but without upload) + build_app( + scheme: "Runner", + workspace: "Runner.xcworkspace", + configuration: "Release", + export_method: "app-store", + skip_package_ipa: true, + xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", + export_options: { + provisioningProfiles: { + "#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore", + "#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore", + "#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore" + }, + signingStyle: "manual", + signingCertificate: CODE_SIGN_IDENTITY + } + ) + end + end diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 03acd6a548..8d4636bbe1 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -58,3 +58,6 @@ const int kPhotoTabIndex = 0; const int kSearchTabIndex = 1; const int kAlbumTabIndex = 2; const int kLibraryTabIndex = 3; + +// Workaround for SQLite's variable limit (SQLITE_MAX_VARIABLE_NUMBER = 32766) +const int kDriftMaxChunk = 32000; diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 5c228ba67c..28c87293f9 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -239,7 +239,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? []; return _ref ?.read(uploadServiceProvider) - .startBackupWithHttpClient(currentUser.id, networkCapabilities.hasWifi, _cancellationToken); + .startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken); }, (error, stack) { dPrint(() => "Error in backup zone $error, $stack"); diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart index 90f29b8bc1..5e81643fc5 100644 --- a/mobile/lib/domain/services/hash.service.dart +++ b/mobile/lib/domain/services/hash.service.dart @@ -2,8 +2,10 @@ import 'package:flutter/services.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:logging/logging.dart'; @@ -13,6 +15,7 @@ class HashService { final int _batchSize; final DriftLocalAlbumRepository _localAlbumRepository; final DriftLocalAssetRepository _localAssetRepository; + final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final NativeSyncApi _nativeSyncApi; final bool Function()? _cancelChecker; final _log = Logger('HashService'); @@ -20,11 +23,13 @@ class HashService { HashService({ required DriftLocalAlbumRepository localAlbumRepository, required DriftLocalAssetRepository localAssetRepository, + required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required NativeSyncApi nativeSyncApi, bool Function()? cancelChecker, int? batchSize, }) : _localAlbumRepository = localAlbumRepository, _localAssetRepository = localAssetRepository, + _trashedLocalAssetRepository = trashedLocalAssetRepository, _cancelChecker = cancelChecker, _nativeSyncApi = nativeSyncApi, _batchSize = batchSize ?? kBatchHashFileLimit; @@ -49,6 +54,14 @@ class HashService { await _hashAssets(album, assetsToHash); } } + if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) { + final backupAlbumIds = localAlbums.map((e) => e.id); + final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds); + if (trashedToHash.isNotEmpty) { + final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now()); + await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true); + } + } } on PlatformException catch (e) { if (e.code == _kHashCancelledCode) { _log.warning("Hashing cancelled by platform"); @@ -65,7 +78,7 @@ class HashService { /// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB /// with hash for those that were successfully hashed. Hashes are looked up in a table /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB. - Future _hashAssets(LocalAlbum album, List assetsToHash) async { + Future _hashAssets(LocalAlbum album, List assetsToHash, {bool isTrashed = false}) async { final toHash = {}; for (final asset in assetsToHash) { @@ -76,16 +89,16 @@ class HashService { toHash[asset.id] = asset; if (toHash.length == _batchSize) { - await _processBatch(album, toHash); + await _processBatch(album, toHash, isTrashed); toHash.clear(); } } - await _processBatch(album, toHash); + await _processBatch(album, toHash, isTrashed); } /// Processes a batch of assets. - Future _processBatch(LocalAlbum album, Map toHash) async { + Future _processBatch(LocalAlbum album, Map toHash, bool isTrashed) async { if (toHash.isEmpty) { return; } @@ -120,7 +133,10 @@ class HashService { } _log.fine("Hashed ${hashed.length}/${toHash.length} assets"); - - await _localAssetRepository.updateHashes(hashed); + if (isTrashed) { + await _trashedLocalAssetRepository.updateHashes(hashed); + } else { + await _localAssetRepository.updateHashes(hashed); + } } } diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 94a8a19e73..5cbae9c5a1 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -4,9 +4,14 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:logging/logging.dart'; @@ -14,15 +19,34 @@ import 'package:logging/logging.dart'; class LocalSyncService { final DriftLocalAlbumRepository _localAlbumRepository; final NativeSyncApi _nativeSyncApi; + final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; + final LocalFilesManagerRepository _localFilesManager; + final StorageRepository _storageRepository; final Logger _log = Logger("DeviceSyncService"); - LocalSyncService({required DriftLocalAlbumRepository localAlbumRepository, required NativeSyncApi nativeSyncApi}) - : _localAlbumRepository = localAlbumRepository, - _nativeSyncApi = nativeSyncApi; + LocalSyncService({ + required DriftLocalAlbumRepository localAlbumRepository, + required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, + required LocalFilesManagerRepository localFilesManager, + required StorageRepository storageRepository, + required NativeSyncApi nativeSyncApi, + }) : _localAlbumRepository = localAlbumRepository, + _trashedLocalAssetRepository = trashedLocalAssetRepository, + _localFilesManager = localFilesManager, + _storageRepository = storageRepository, + _nativeSyncApi = nativeSyncApi; Future sync({bool full = false}) async { final Stopwatch stopwatch = Stopwatch()..start(); try { + if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + final hasPermission = await _localFilesManager.hasManageMediaPermission(); + if (hasPermission) { + await _syncTrashedAssets(); + } else { + _log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing"); + } + } if (full || await _nativeSyncApi.shouldFullSync()) { _log.fine("Full sync request from ${full ? "user" : "native"}"); return await fullSync(); @@ -69,7 +93,6 @@ class LocalSyncService { await updateAlbum(dbAlbum, album); } } - await _nativeSyncApi.checkpointSync(); } catch (e, s) { _log.severe("Error performing device sync", e, s); @@ -273,6 +296,48 @@ class LocalSyncService { bool _albumsEqual(LocalAlbum a, LocalAlbum b) { return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt); } + + Future _syncTrashedAssets() async { + final trashedAssetMap = await _nativeSyncApi.getTrashedAssets(); + await processTrashedAssets(trashedAssetMap); + } + + @visibleForTesting + Future processTrashedAssets(Map> trashedAssetMap) async { + if (trashedAssetMap.isEmpty) { + _log.info("syncTrashedAssets, No trashed assets found"); + } + final trashedAssets = trashedAssetMap.cast>().entries.expand( + (entry) => entry.value.cast().toTrashedAssets(entry.key), + ); + + _log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}"); + await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets); + + final assetsToRestore = await _trashedLocalAssetRepository.getToRestore(); + if (assetsToRestore.isNotEmpty) { + final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore); + await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds); + } else { + _log.info("syncTrashedAssets, No remote assets found for restoration"); + } + + final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash(); + if (localAssetsToTrash.isNotEmpty) { + final mediaUrls = await Future.wait( + localAssetsToTrash.values + .expand((e) => e) + .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())), + ); + _log.info("Moving to trash ${mediaUrls.join(", ")} assets"); + final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList()); + if (result) { + await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash); + } + } else { + _log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash"); + } + } } extension on Iterable { @@ -290,20 +355,26 @@ extension on Iterable { extension on Iterable { List toLocalAssets() { - return map( - (e) => LocalAsset( - id: e.id, - name: e.name, - checksum: null, - type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other, - createdAt: tryFromSecondsSinceEpoch(e.createdAt, isUtc: true) ?? DateTime.timestamp(), - updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(), - width: e.width, - height: e.height, - durationInSeconds: e.durationInSeconds, - orientation: e.orientation, - isFavorite: e.isFavorite, - ), - ).toList(); + return map((e) => e.toLocalAsset()).toList(); + } + + Iterable toTrashedAssets(String albumId) { + return map((e) => (albumId: albumId, asset: e.toLocalAsset())); } } + +extension on PlatformAsset { + LocalAsset toLocalAsset() => LocalAsset( + id: id, + name: name, + checksum: null, + type: AssetType.values.elementAtOrNull(type) ?? AssetType.other, + createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(), + updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(), + width: width, + height: height, + durationInSeconds: durationInSeconds, + isFavorite: isFavorite, + orientation: orientation, + ); +} diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 9af541e3a4..2ff0f18fcf 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -1,8 +1,15 @@ import 'dart:async'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -11,14 +18,26 @@ class SyncStreamService { final SyncApiRepository _syncApiRepository; final SyncStreamRepository _syncStreamRepository; + final DriftLocalAssetRepository _localAssetRepository; + final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; + final LocalFilesManagerRepository _localFilesManager; + final StorageRepository _storageRepository; final bool Function()? _cancelChecker; SyncStreamService({ required SyncApiRepository syncApiRepository, required SyncStreamRepository syncStreamRepository, + required DriftLocalAssetRepository localAssetRepository, + required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, + required LocalFilesManagerRepository localFilesManager, + required StorageRepository storageRepository, bool Function()? cancelChecker, }) : _syncApiRepository = syncApiRepository, _syncStreamRepository = syncStreamRepository, + _localAssetRepository = localAssetRepository, + _trashedLocalAssetRepository = trashedLocalAssetRepository, + _localFilesManager = localFilesManager, + _storageRepository = storageRepository, _cancelChecker = cancelChecker; bool get isCancelled => _cancelChecker?.call() ?? false; @@ -83,7 +102,18 @@ class SyncStreamService { case SyncEntityType.partnerDeleteV1: return _syncStreamRepository.deletePartnerV1(data.cast()); case SyncEntityType.assetV1: - return _syncStreamRepository.updateAssetsV1(data.cast()); + final remoteSyncAssets = data.cast(); + await _syncStreamRepository.updateAssetsV1(remoteSyncAssets); + if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + final hasPermission = await _localFilesManager.hasManageMediaPermission(); + if (hasPermission) { + await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum)); + await _applyRemoteRestoreToLocal(); + } else { + _logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing"); + } + } + return; case SyncEntityType.assetDeleteV1: return _syncStreamRepository.deleteAssetsV1(data.cast()); case SyncEntityType.assetExifV1: @@ -212,4 +242,36 @@ class SyncStreamService { _logger.severe("Error processing AssetUploadReadyV1 websocket batch events", error, stackTrace); } } + + Future _handleRemoteTrashed(Iterable checksums) async { + if (checksums.isEmpty) { + return Future.value(); + } else { + final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums); + if (localAssetsToTrash.isNotEmpty) { + final mediaUrls = await Future.wait( + localAssetsToTrash.values + .expand((e) => e) + .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())), + ); + _logger.info("Moving to trash ${mediaUrls.join(", ")} assets"); + final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList()); + if (result) { + await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash); + } + } else { + _logger.info("No assets found in backup-enabled albums for assets: $checksums"); + } + } + } + + Future _applyRemoteRestoreToLocal() async { + final assetsToRestore = await _trashedLocalAssetRepository.getToRestore(); + if (assetsToRestore.isNotEmpty) { + final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore); + await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds); + } else { + _logger.info("No remote assets found for restoration"); + } + } } diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart new file mode 100644 index 0000000000..308130b9ea --- /dev/null +++ b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart @@ -0,0 +1,40 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)') +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)') +class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { + const TrashedLocalAssetEntity(); + + TextColumn get id => text()(); + + TextColumn get albumId => text()(); + + TextColumn get checksum => text().nullable()(); + + BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); + + IntColumn get orientation => integer().withDefault(const Constant(0))(); + + @override + Set get primaryKey => {id, albumId}; +} + +extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityData { + LocalAsset toLocalAsset() => LocalAsset( + id: id, + name: name, + checksum: checksum, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + durationInSeconds: durationInSeconds, + isFavorite: isFavorite, + height: height, + width: width, + orientation: orientation, + ); +} diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart new file mode 100644 index 0000000000..aab226c3a2 --- /dev/null +++ b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.drift.dart @@ -0,0 +1,1080 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart' + as i1; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2; +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart' + as i3; +import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4; + +typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder = + i1.TrashedLocalAssetEntityCompanion Function({ + required String name, + required i2.AssetType type, + i0.Value createdAt, + i0.Value updatedAt, + i0.Value width, + i0.Value height, + i0.Value durationInSeconds, + required String id, + required String albumId, + i0.Value checksum, + i0.Value isFavorite, + i0.Value orientation, + }); +typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder = + i1.TrashedLocalAssetEntityCompanion Function({ + i0.Value name, + i0.Value type, + i0.Value createdAt, + i0.Value updatedAt, + i0.Value width, + i0.Value height, + i0.Value durationInSeconds, + i0.Value id, + i0.Value albumId, + i0.Value checksum, + i0.Value isFavorite, + i0.Value orientation, + }); + +class $$TrashedLocalAssetEntityTableFilterComposer + extends + i0.Composer { + $$TrashedLocalAssetEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters get type => + $composableBuilder( + column: $table.type, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get width => $composableBuilder( + column: $table.width, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get height => $composableBuilder( + column: $table.height, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get durationInSeconds => $composableBuilder( + column: $table.durationInSeconds, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get albumId => $composableBuilder( + column: $table.albumId, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get checksum => $composableBuilder( + column: $table.checksum, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get isFavorite => $composableBuilder( + column: $table.isFavorite, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get orientation => $composableBuilder( + column: $table.orientation, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $$TrashedLocalAssetEntityTableOrderingComposer + extends + i0.Composer { + $$TrashedLocalAssetEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get type => $composableBuilder( + column: $table.type, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get width => $composableBuilder( + column: $table.width, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get height => $composableBuilder( + column: $table.height, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get durationInSeconds => $composableBuilder( + column: $table.durationInSeconds, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get albumId => $composableBuilder( + column: $table.albumId, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get checksum => $composableBuilder( + column: $table.checksum, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get isFavorite => $composableBuilder( + column: $table.isFavorite, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get orientation => $composableBuilder( + column: $table.orientation, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $$TrashedLocalAssetEntityTableAnnotationComposer + extends + i0.Composer { + $$TrashedLocalAssetEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get type => + $composableBuilder(column: $table.type, builder: (column) => column); + + i0.GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + i0.GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + i0.GeneratedColumn get width => + $composableBuilder(column: $table.width, builder: (column) => column); + + i0.GeneratedColumn get height => + $composableBuilder(column: $table.height, builder: (column) => column); + + i0.GeneratedColumn get durationInSeconds => $composableBuilder( + column: $table.durationInSeconds, + builder: (column) => column, + ); + + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumn get albumId => + $composableBuilder(column: $table.albumId, builder: (column) => column); + + i0.GeneratedColumn get checksum => + $composableBuilder(column: $table.checksum, builder: (column) => column); + + i0.GeneratedColumn get isFavorite => $composableBuilder( + column: $table.isFavorite, + builder: (column) => column, + ); + + i0.GeneratedColumn get orientation => $composableBuilder( + column: $table.orientation, + builder: (column) => column, + ); +} + +class $$TrashedLocalAssetEntityTableTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.$TrashedLocalAssetEntityTable, + i1.TrashedLocalAssetEntityData, + i1.$$TrashedLocalAssetEntityTableFilterComposer, + i1.$$TrashedLocalAssetEntityTableOrderingComposer, + i1.$$TrashedLocalAssetEntityTableAnnotationComposer, + $$TrashedLocalAssetEntityTableCreateCompanionBuilder, + $$TrashedLocalAssetEntityTableUpdateCompanionBuilder, + ( + i1.TrashedLocalAssetEntityData, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.$TrashedLocalAssetEntityTable, + i1.TrashedLocalAssetEntityData + >, + ), + i1.TrashedLocalAssetEntityData, + i0.PrefetchHooks Function() + > { + $$TrashedLocalAssetEntityTableTableManager( + i0.GeneratedDatabase db, + i1.$TrashedLocalAssetEntityTable table, + ) : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$TrashedLocalAssetEntityTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + i1.$$TrashedLocalAssetEntityTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + i1.$$TrashedLocalAssetEntityTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + i0.Value name = const i0.Value.absent(), + i0.Value type = const i0.Value.absent(), + i0.Value createdAt = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + i0.Value width = const i0.Value.absent(), + i0.Value height = const i0.Value.absent(), + i0.Value durationInSeconds = const i0.Value.absent(), + i0.Value id = const i0.Value.absent(), + i0.Value albumId = const i0.Value.absent(), + i0.Value checksum = const i0.Value.absent(), + i0.Value isFavorite = const i0.Value.absent(), + i0.Value orientation = const i0.Value.absent(), + }) => i1.TrashedLocalAssetEntityCompanion( + name: name, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + width: width, + height: height, + durationInSeconds: durationInSeconds, + id: id, + albumId: albumId, + checksum: checksum, + isFavorite: isFavorite, + orientation: orientation, + ), + createCompanionCallback: + ({ + required String name, + required i2.AssetType type, + i0.Value createdAt = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + i0.Value width = const i0.Value.absent(), + i0.Value height = const i0.Value.absent(), + i0.Value durationInSeconds = const i0.Value.absent(), + required String id, + required String albumId, + i0.Value checksum = const i0.Value.absent(), + i0.Value isFavorite = const i0.Value.absent(), + i0.Value orientation = const i0.Value.absent(), + }) => i1.TrashedLocalAssetEntityCompanion.insert( + name: name, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + width: width, + height: height, + durationInSeconds: durationInSeconds, + id: id, + albumId: albumId, + checksum: checksum, + isFavorite: isFavorite, + orientation: orientation, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$TrashedLocalAssetEntityTableProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$TrashedLocalAssetEntityTable, + i1.TrashedLocalAssetEntityData, + i1.$$TrashedLocalAssetEntityTableFilterComposer, + i1.$$TrashedLocalAssetEntityTableOrderingComposer, + i1.$$TrashedLocalAssetEntityTableAnnotationComposer, + $$TrashedLocalAssetEntityTableCreateCompanionBuilder, + $$TrashedLocalAssetEntityTableUpdateCompanionBuilder, + ( + i1.TrashedLocalAssetEntityData, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.$TrashedLocalAssetEntityTable, + i1.TrashedLocalAssetEntityData + >, + ), + i1.TrashedLocalAssetEntityData, + i0.PrefetchHooks Function() + >; +i0.Index get idxTrashedLocalAssetChecksum => i0.Index( + 'idx_trashed_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', +); + +class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity + with + i0.TableInfo< + $TrashedLocalAssetEntityTable, + i1.TrashedLocalAssetEntityData + > { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $TrashedLocalAssetEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _nameMeta = const i0.VerificationMeta( + 'name', + ); + @override + late final i0.GeneratedColumn name = i0.GeneratedColumn( + 'name', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final i0.GeneratedColumnWithTypeConverter type = + i0.GeneratedColumn( + 'type', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + ).withConverter( + i1.$TrashedLocalAssetEntityTable.$convertertype, + ); + static const i0.VerificationMeta _createdAtMeta = const i0.VerificationMeta( + 'createdAt', + ); + @override + late final i0.GeneratedColumn createdAt = + i0.GeneratedColumn( + 'created_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: i4.currentDateAndTime, + ); + static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta( + 'updatedAt', + ); + @override + late final i0.GeneratedColumn updatedAt = + i0.GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: i4.currentDateAndTime, + ); + static const i0.VerificationMeta _widthMeta = const i0.VerificationMeta( + 'width', + ); + @override + late final i0.GeneratedColumn width = i0.GeneratedColumn( + 'width', + aliasedName, + true, + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + ); + static const i0.VerificationMeta _heightMeta = const i0.VerificationMeta( + 'height', + ); + @override + late final i0.GeneratedColumn height = i0.GeneratedColumn( + 'height', + aliasedName, + true, + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + ); + static const i0.VerificationMeta _durationInSecondsMeta = + const i0.VerificationMeta('durationInSeconds'); + @override + late final i0.GeneratedColumn durationInSeconds = + i0.GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + ); + static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id'); + @override + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + ); + static const i0.VerificationMeta _albumIdMeta = const i0.VerificationMeta( + 'albumId', + ); + @override + late final i0.GeneratedColumn albumId = i0.GeneratedColumn( + 'album_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + ); + static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta( + 'checksum', + ); + @override + late final i0.GeneratedColumn checksum = i0.GeneratedColumn( + 'checksum', + aliasedName, + true, + type: i0.DriftSqlType.string, + requiredDuringInsert: false, + ); + static const i0.VerificationMeta _isFavoriteMeta = const i0.VerificationMeta( + 'isFavorite', + ); + @override + late final i0.GeneratedColumn isFavorite = i0.GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const i4.Constant(false), + ); + static const i0.VerificationMeta _orientationMeta = const i0.VerificationMeta( + 'orientation', + ); + @override + late final i0.GeneratedColumn orientation = i0.GeneratedColumn( + 'orientation', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const i4.Constant(0), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'trashed_local_asset_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable instance, { + bool isInserting = false, + }) { + final context = i0.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } + if (data.containsKey('width')) { + context.handle( + _widthMeta, + width.isAcceptableOrUnknown(data['width']!, _widthMeta), + ); + } + if (data.containsKey('height')) { + context.handle( + _heightMeta, + height.isAcceptableOrUnknown(data['height']!, _heightMeta), + ); + } + if (data.containsKey('duration_in_seconds')) { + context.handle( + _durationInSecondsMeta, + durationInSeconds.isAcceptableOrUnknown( + data['duration_in_seconds']!, + _durationInSecondsMeta, + ), + ); + } + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('album_id')) { + context.handle( + _albumIdMeta, + albumId.isAcceptableOrUnknown(data['album_id']!, _albumIdMeta), + ); + } else if (isInserting) { + context.missing(_albumIdMeta); + } + if (data.containsKey('checksum')) { + context.handle( + _checksumMeta, + checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta), + ); + } + if (data.containsKey('is_favorite')) { + context.handle( + _isFavoriteMeta, + isFavorite.isAcceptableOrUnknown(data['is_favorite']!, _isFavoriteMeta), + ); + } + if (data.containsKey('orientation')) { + context.handle( + _orientationMeta, + orientation.isAcceptableOrUnknown( + data['orientation']!, + _orientationMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id, albumId}; + @override + i1.TrashedLocalAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.TrashedLocalAssetEntityData( + name: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: i1.$TrashedLocalAssetEntityTable.$convertertype.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + ), + createdAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + albumId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + checksum: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}checksum'], + ), + isFavorite: attachedDatabase.typeMapping.read( + i0.DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + orientation: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}orientation'], + )!, + ); + } + + @override + $TrashedLocalAssetEntityTable createAlias(String alias) { + return $TrashedLocalAssetEntityTable(attachedDatabase, alias); + } + + static i0.JsonTypeConverter2 $convertertype = + const i0.EnumIndexConverter(i2.AssetType.values); + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class TrashedLocalAssetEntityData extends i0.DataClass + implements i0.Insertable { + final String name; + final i2.AssetType type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String albumId; + final String? checksum; + final bool isFavorite; + final int orientation; + const TrashedLocalAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.albumId, + this.checksum, + required this.isFavorite, + required this.orientation, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = i0.Variable(name); + { + map['type'] = i0.Variable( + i1.$TrashedLocalAssetEntityTable.$convertertype.toSql(type), + ); + } + map['created_at'] = i0.Variable(createdAt); + map['updated_at'] = i0.Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = i0.Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = i0.Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = i0.Variable(durationInSeconds); + } + map['id'] = i0.Variable(id); + map['album_id'] = i0.Variable(albumId); + if (!nullToAbsent || checksum != null) { + map['checksum'] = i0.Variable(checksum); + } + map['is_favorite'] = i0.Variable(isFavorite); + map['orientation'] = i0.Variable(orientation); + return map; + } + + factory TrashedLocalAssetEntityData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return TrashedLocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: i1.$TrashedLocalAssetEntityTable.$convertertype.fromJson( + serializer.fromJson(json['type']), + ), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + albumId: serializer.fromJson(json['albumId']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson( + i1.$TrashedLocalAssetEntityTable.$convertertype.toJson(type), + ), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'albumId': serializer.toJson(albumId), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + }; + } + + i1.TrashedLocalAssetEntityData copyWith({ + String? name, + i2.AssetType? type, + DateTime? createdAt, + DateTime? updatedAt, + i0.Value width = const i0.Value.absent(), + i0.Value height = const i0.Value.absent(), + i0.Value durationInSeconds = const i0.Value.absent(), + String? id, + String? albumId, + i0.Value checksum = const i0.Value.absent(), + bool? isFavorite, + int? orientation, + }) => i1.TrashedLocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + albumId: albumId ?? this.albumId, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + TrashedLocalAssetEntityData copyWithCompanion( + i1.TrashedLocalAssetEntityCompanion data, + ) { + return TrashedLocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + ); + } + + @override + String toString() { + return (StringBuffer('TrashedLocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('albumId: $albumId, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.TrashedLocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.albumId == this.albumId && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation); +} + +class TrashedLocalAssetEntityCompanion + extends i0.UpdateCompanion { + final i0.Value name; + final i0.Value type; + final i0.Value createdAt; + final i0.Value updatedAt; + final i0.Value width; + final i0.Value height; + final i0.Value durationInSeconds; + final i0.Value id; + final i0.Value albumId; + final i0.Value checksum; + final i0.Value isFavorite; + final i0.Value orientation; + const TrashedLocalAssetEntityCompanion({ + this.name = const i0.Value.absent(), + this.type = const i0.Value.absent(), + this.createdAt = const i0.Value.absent(), + this.updatedAt = const i0.Value.absent(), + this.width = const i0.Value.absent(), + this.height = const i0.Value.absent(), + this.durationInSeconds = const i0.Value.absent(), + this.id = const i0.Value.absent(), + this.albumId = const i0.Value.absent(), + this.checksum = const i0.Value.absent(), + this.isFavorite = const i0.Value.absent(), + this.orientation = const i0.Value.absent(), + }); + TrashedLocalAssetEntityCompanion.insert({ + required String name, + required i2.AssetType type, + this.createdAt = const i0.Value.absent(), + this.updatedAt = const i0.Value.absent(), + this.width = const i0.Value.absent(), + this.height = const i0.Value.absent(), + this.durationInSeconds = const i0.Value.absent(), + required String id, + required String albumId, + this.checksum = const i0.Value.absent(), + this.isFavorite = const i0.Value.absent(), + this.orientation = const i0.Value.absent(), + }) : name = i0.Value(name), + type = i0.Value(type), + id = i0.Value(id), + albumId = i0.Value(albumId); + static i0.Insertable custom({ + i0.Expression? name, + i0.Expression? type, + i0.Expression? createdAt, + i0.Expression? updatedAt, + i0.Expression? width, + i0.Expression? height, + i0.Expression? durationInSeconds, + i0.Expression? id, + i0.Expression? albumId, + i0.Expression? checksum, + i0.Expression? isFavorite, + i0.Expression? orientation, + }) { + return i0.RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (albumId != null) 'album_id': albumId, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + }); + } + + i1.TrashedLocalAssetEntityCompanion copyWith({ + i0.Value? name, + i0.Value? type, + i0.Value? createdAt, + i0.Value? updatedAt, + i0.Value? width, + i0.Value? height, + i0.Value? durationInSeconds, + i0.Value? id, + i0.Value? albumId, + i0.Value? checksum, + i0.Value? isFavorite, + i0.Value? orientation, + }) { + return i1.TrashedLocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + albumId: albumId ?? this.albumId, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = i0.Variable(name.value); + } + if (type.present) { + map['type'] = i0.Variable( + i1.$TrashedLocalAssetEntityTable.$convertertype.toSql(type.value), + ); + } + if (createdAt.present) { + map['created_at'] = i0.Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = i0.Variable(updatedAt.value); + } + if (width.present) { + map['width'] = i0.Variable(width.value); + } + if (height.present) { + map['height'] = i0.Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = i0.Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (albumId.present) { + map['album_id'] = i0.Variable(albumId.value); + } + if (checksum.present) { + map['checksum'] = i0.Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = i0.Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = i0.Variable(orientation.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TrashedLocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('albumId: $albumId, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } +} + +i0.Index get idxTrashedLocalAssetAlbum => i0.Index( + 'idx_trashed_local_asset_album', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)', +); diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 7291c3a97b..548aa2e384 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/memory.entity.dart'; import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.dart'; @@ -62,6 +63,7 @@ class IsarDatabaseRepository implements IDatabaseRepository { PersonEntity, AssetFaceEntity, StoreEntity, + TrashedLocalAssetEntity, ], include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, ) @@ -93,7 +95,7 @@ class Drift extends $Drift implements IDatabaseRepository { } @override - int get schemaVersion => 12; + int get schemaVersion => 13; @override MigrationStrategy get migration => MigrationStrategy( @@ -178,6 +180,11 @@ class Drift extends $Drift implements IDatabaseRepository { ); } }, + from12To13: (m, v13) async { + await m.create(v13.trashedLocalAssetEntity); + await m.createIndex(v13.idxTrashedLocalAssetChecksum); + await m.createIndex(v13.idxTrashedLocalAssetAlbum); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index e39ed8a560..bd72da949c 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -37,9 +37,11 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da as i17; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart' as i18; -import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart' as i19; -import 'package:drift/internal/modular.dart' as i20; +import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' + as i20; +import 'package:drift/internal/modular.dart' as i21; abstract class $Drift extends i0.GeneratedDatabase { $Drift(i0.QueryExecutor e) : super(e); @@ -77,9 +79,11 @@ abstract class $Drift extends i0.GeneratedDatabase { late final i17.$AssetFaceEntityTable assetFaceEntity = i17 .$AssetFaceEntityTable(this); late final i18.$StoreEntityTable storeEntity = i18.$StoreEntityTable(this); - i19.MergedAssetDrift get mergedAssetDrift => i20.ReadDatabaseContainer( + late final i19.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i19 + .$TrashedLocalAssetEntityTable(this); + i20.MergedAssetDrift get mergedAssetDrift => i21.ReadDatabaseContainer( this, - ).accessor(i19.MergedAssetDrift.new); + ).accessor(i20.MergedAssetDrift.new); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -108,7 +112,10 @@ abstract class $Drift extends i0.GeneratedDatabase { personEntity, assetFaceEntity, storeEntity, + trashedLocalAssetEntity, i11.idxLatLng, + i19.idxTrashedLocalAssetChecksum, + i19.idxTrashedLocalAssetAlbum, ]; @override i0.StreamQueryUpdateRules @@ -336,4 +343,9 @@ class $DriftManager { i17.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity); i18.$$StoreEntityTableTableManager get storeEntity => i18.$$StoreEntityTableTableManager(_db, _db.storeEntity); + i19.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity => + i19.$$TrashedLocalAssetEntityTableTableManager( + _db, + _db.trashedLocalAssetEntity, + ); } diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index c973cd6f13..f2d87a7f83 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -5037,6 +5037,454 @@ final class Schema12 extends i0.VersionedSchema { ); } +final class Schema13 extends i0.VersionedSchema { + Schema13({required super.database}) : super(version: 13); + @override + late final List entities = [ + userEntity, + remoteAssetEntity, + stackEntity, + localAssetEntity, + remoteAlbumEntity, + localAlbumEntity, + localAlbumAssetEntity, + idxLocalAssetChecksum, + idxRemoteAssetOwnerChecksum, + uQRemoteAssetsOwnerChecksum, + uQRemoteAssetsOwnerLibraryChecksum, + idxRemoteAssetChecksum, + authUserEntity, + userMetadataEntity, + partnerEntity, + remoteExifEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + assetFaceEntity, + storeEntity, + trashedLocalAssetEntity, + idxLatLng, + idxTrashedLocalAssetChecksum, + idxTrashedLocalAssetAlbum, + ]; + late final Shape20 userEntity = Shape20( + source: i0.VersionedTable( + entityName: 'user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_3, + _column_84, + _column_85, + _column_91, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape17 remoteAssetEntity = Shape17( + source: i0.VersionedTable( + entityName: 'remote_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_13, + _column_14, + _column_15, + _column_16, + _column_17, + _column_18, + _column_19, + _column_20, + _column_21, + _column_86, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 stackEntity = Shape3( + source: i0.VersionedTable( + entityName: 'stack_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [_column_0, _column_9, _column_5, _column_15, _column_75], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape2 localAssetEntity = Shape2( + source: i0.VersionedTable( + entityName: 'local_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_22, + _column_14, + _column_23, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape9 remoteAlbumEntity = Shape9( + source: i0.VersionedTable( + entityName: 'remote_album_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_56, + _column_9, + _column_5, + _column_15, + _column_57, + _column_58, + _column_59, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape19 localAlbumEntity = Shape19( + source: i0.VersionedTable( + entityName: 'local_album_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_5, + _column_31, + _column_32, + _column_90, + _column_33, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape22 localAlbumAssetEntity = Shape22( + source: i0.VersionedTable( + entityName: 'local_album_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id, album_id)'], + columns: [_column_34, _column_35, _column_33], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxLocalAssetChecksum = i1.Index( + 'idx_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)', + ); + final i1.Index idxRemoteAssetOwnerChecksum = i1.Index( + 'idx_remote_asset_owner_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', + ); + final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index( + 'UQ_remote_assets_owner_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', + ); + final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index( + 'UQ_remote_assets_owner_library_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)', + ); + final i1.Index idxRemoteAssetChecksum = i1.Index( + 'idx_remote_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)', + ); + late final Shape21 authUserEntity = Shape21( + source: i0.VersionedTable( + entityName: 'auth_user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_1, + _column_3, + _column_2, + _column_84, + _column_85, + _column_92, + _column_93, + _column_7, + _column_94, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape4 userMetadataEntity = Shape4( + source: i0.VersionedTable( + entityName: 'user_metadata_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(user_id, "key")'], + columns: [_column_25, _column_26, _column_27], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape5 partnerEntity = Shape5( + source: i0.VersionedTable( + entityName: 'partner_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'], + columns: [_column_28, _column_29, _column_30], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape8 remoteExifEntity = Shape8( + source: i0.VersionedTable( + entityName: 'remote_exif_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id)'], + columns: [ + _column_36, + _column_37, + _column_38, + _column_39, + _column_40, + _column_41, + _column_11, + _column_10, + _column_42, + _column_43, + _column_44, + _column_45, + _column_46, + _column_47, + _column_48, + _column_49, + _column_50, + _column_51, + _column_52, + _column_53, + _column_54, + _column_55, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape7 remoteAlbumAssetEntity = Shape7( + source: i0.VersionedTable( + entityName: 'remote_album_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id, album_id)'], + columns: [_column_36, _column_60], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape10 remoteAlbumUserEntity = Shape10( + source: i0.VersionedTable( + entityName: 'remote_album_user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(album_id, user_id)'], + columns: [_column_60, _column_25, _column_61], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape11 memoryEntity = Shape11( + source: i0.VersionedTable( + entityName: 'memory_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_9, + _column_5, + _column_18, + _column_15, + _column_8, + _column_62, + _column_63, + _column_64, + _column_65, + _column_66, + _column_67, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape12 memoryAssetEntity = Shape12( + source: i0.VersionedTable( + entityName: 'memory_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'], + columns: [_column_36, _column_68], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape14 personEntity = Shape14( + source: i0.VersionedTable( + entityName: 'person_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_9, + _column_5, + _column_15, + _column_1, + _column_69, + _column_71, + _column_72, + _column_73, + _column_74, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape15 assetFaceEntity = Shape15( + source: i0.VersionedTable( + entityName: 'asset_face_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [ + _column_0, + _column_36, + _column_76, + _column_77, + _column_78, + _column_79, + _column_80, + _column_81, + _column_82, + _column_83, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape18 storeEntity = Shape18( + source: i0.VersionedTable( + entityName: 'store_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [_column_87, _column_88, _column_89], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape23 trashedLocalAssetEntity = Shape23( + source: i0.VersionedTable( + entityName: 'trashed_local_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id, album_id)'], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_95, + _column_22, + _column_14, + _column_23, + ], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxLatLng = i1.Index( + 'idx_lat_lng', + 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', + ); + final i1.Index idxTrashedLocalAssetChecksum = i1.Index( + 'idx_trashed_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', + ); + final i1.Index idxTrashedLocalAssetAlbum = i1.Index( + 'idx_trashed_local_asset_album', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)', + ); +} + +class Shape23 extends i0.VersionedTable { + Shape23({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get width => + columnsByName['width']! as i1.GeneratedColumn; + i1.GeneratedColumn get height => + columnsByName['height']! as i1.GeneratedColumn; + i1.GeneratedColumn get durationInSeconds => + columnsByName['duration_in_seconds']! as i1.GeneratedColumn; + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get albumId => + columnsByName['album_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get checksum => + columnsByName['checksum']! as i1.GeneratedColumn; + i1.GeneratedColumn get isFavorite => + columnsByName['is_favorite']! as i1.GeneratedColumn; + i1.GeneratedColumn get orientation => + columnsByName['orientation']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_95(String aliasedName) => + i1.GeneratedColumn( + 'album_id', + aliasedName, + false, + type: i1.DriftSqlType.string, + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, @@ -5049,6 +5497,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema10 schema) from9To10, required Future Function(i1.Migrator m, Schema11 schema) from10To11, required Future Function(i1.Migrator m, Schema12 schema) from11To12, + required Future Function(i1.Migrator m, Schema13 schema) from12To13, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -5107,6 +5556,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from11To12(migrator, schema); return 12; + case 12: + final schema = Schema13(database: database); + final migrator = i1.Migrator(database, schema); + await from12To13(migrator, schema); + return 13; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -5125,6 +5579,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema10 schema) from9To10, required Future Function(i1.Migrator m, Schema11 schema) from10To11, required Future Function(i1.Migrator m, Schema12 schema) from11To12, + required Future Function(i1.Migrator m, Schema13 schema) from12To13, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -5138,5 +5593,6 @@ i1.OnUpgrade stepByStep({ from9To10: from9To10, from10To11: from10To11, from11To12: from11To12, + from12To13: from12To13, ), ); diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 2b76472c9e..4d30e09716 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -1,4 +1,6 @@ +import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; @@ -8,6 +10,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; class DriftLocalAssetRepository extends DriftDatabaseRepository { final Drift _db; + const DriftLocalAssetRepository(this._db) : super(_db); SingleOrNullSelectable _assetSelectable(String id) { @@ -95,4 +98,32 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { } return query.map((localAlbum) => localAlbum.toDto()).get(); } + + Future>> getAssetsFromBackupAlbums(Iterable checksums) async { + if (checksums.isEmpty) { + return {}; + } + + final result = >{}; + + for (final slice in checksums.toSet().slices(kDriftMaxChunk)) { + final rows = + await (_db.select(_db.localAlbumAssetEntity).join([ + innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)), + innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)), + ])..where( + _db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) & + _db.localAssetEntity.checksum.isIn(slice), + )) + .get(); + + for (final row in rows) { + final albumId = row.readTable(_db.localAlbumAssetEntity).albumId; + final assetData = row.readTable(_db.localAssetEntity); + final asset = assetData.toDto(); + (result[albumId] ??= []).add(asset); + } + } + return result; + } } diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index be55c21afc..96c204ea0e 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -12,6 +12,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; class RemoteAssetRepository extends DriftDatabaseRepository { final Drift _db; + const RemoteAssetRepository(this._db) : super(_db); /// For testing purposes diff --git a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart new file mode 100644 index 0000000000..498e4227b7 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart @@ -0,0 +1,252 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +typedef TrashedAsset = ({String albumId, LocalAsset asset}); + +class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { + final Drift _db; + + const DriftTrashedLocalAssetRepository(this._db) : super(_db); + + Future updateHashes(Map hashes) { + if (hashes.isEmpty) { + return Future.value(); + } + return _db.batch((batch) async { + for (final entry in hashes.entries) { + batch.update( + _db.trashedLocalAssetEntity, + TrashedLocalAssetEntityCompanion(checksum: Value(entry.value)), + where: (e) => e.id.equals(entry.key), + ); + } + }); + } + + Future> getAssetsToHash(Iterable albumIds) { + final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull()); + return query.map((row) => row.toLocalAsset()).get(); + } + + Future> getToRestore() async { + final selectedAlbumIds = (_db.selectOnly(_db.localAlbumEntity) + ..addColumns([_db.localAlbumEntity.id]) + ..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected))); + + final rows = + await (_db.select(_db.trashedLocalAssetEntity).join([ + innerJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.checksum.equalsExp(_db.trashedLocalAssetEntity.checksum), + ), + ])..where( + _db.trashedLocalAssetEntity.albumId.isInQuery(selectedAlbumIds) & + _db.remoteAssetEntity.deletedAt.isNull(), + )) + .get(); + + return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toLocalAsset()); + } + + /// Applies resulted snapshot of trashed assets: + /// - upserts incoming rows + /// - deletes rows that are not present in the snapshot + Future processTrashSnapshot(Iterable trashedAssets) async { + if (trashedAssets.isEmpty) { + await _db.delete(_db.trashedLocalAssetEntity).go(); + return; + } + final assetIds = trashedAssets.map((e) => e.asset.id).toSet(); + Map localChecksumById = await _getCachedChecksums(assetIds); + + return _db.transaction(() async { + await _db.batch((batch) { + for (final item in trashedAssets) { + final effectiveChecksum = localChecksumById[item.asset.id] ?? item.asset.checksum; + final companion = TrashedLocalAssetEntityCompanion.insert( + id: item.asset.id, + albumId: item.albumId, + checksum: Value(effectiveChecksum), + name: item.asset.name, + type: item.asset.type, + createdAt: Value(item.asset.createdAt), + updatedAt: Value(item.asset.updatedAt), + width: Value(item.asset.width), + height: Value(item.asset.height), + durationInSeconds: Value(item.asset.durationInSeconds), + isFavorite: Value(item.asset.isFavorite), + orientation: Value(item.asset.orientation), + ); + + batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>( + _db.trashedLocalAssetEntity, + companion, + onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(item.asset.updatedAt)), + ); + } + }); + + if (assetIds.length <= kDriftMaxChunk) { + await (_db.delete(_db.trashedLocalAssetEntity)..where((row) => row.id.isNotIn(assetIds))).go(); + } else { + final existingIds = await (_db.selectOnly( + _db.trashedLocalAssetEntity, + )..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get(); + final idToDelete = existingIds.where((id) => !assetIds.contains(id)); + for (final slice in idToDelete.slices(kDriftMaxChunk)) { + await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go(); + } + } + }); + } + + Stream watchCount() { + return (_db.selectOnly(_db.trashedLocalAssetEntity)..addColumns([_db.trashedLocalAssetEntity.id.count()])) + .watchSingle() + .map((row) => row.read(_db.trashedLocalAssetEntity.id.count()) ?? 0); + } + + Stream watchHashedCount() { + return (_db.selectOnly(_db.trashedLocalAssetEntity) + ..addColumns([_db.trashedLocalAssetEntity.id.count()]) + ..where(_db.trashedLocalAssetEntity.checksum.isNotNull())) + .watchSingle() + .map((row) => row.read(_db.trashedLocalAssetEntity.id.count()) ?? 0); + } + + Future trashLocalAsset(Map> assetsByAlbums) async { + if (assetsByAlbums.isEmpty) { + return; + } + + final companions = []; + final idToDelete = {}; + + for (final entry in assetsByAlbums.entries) { + for (final asset in entry.value) { + idToDelete.add(asset.id); + companions.add( + TrashedLocalAssetEntityCompanion( + id: Value(asset.id), + name: Value(asset.name), + albumId: Value(entry.key), + checksum: Value(asset.checksum), + type: Value(asset.type), + width: Value(asset.width), + height: Value(asset.height), + durationInSeconds: Value(asset.durationInSeconds), + isFavorite: Value(asset.isFavorite), + orientation: Value(asset.orientation), + createdAt: Value(asset.createdAt), + updatedAt: Value(asset.updatedAt), + ), + ); + } + } + + await _db.transaction(() async { + for (final companion in companions) { + await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion); + } + + for (final slice in idToDelete.slices(kDriftMaxChunk)) { + await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go(); + } + }); + } + + Future applyRestoredAssets(List idList) async { + if (idList.isEmpty) { + return; + } + + final trashedAssets = []; + + for (final slice in idList.slices(kDriftMaxChunk)) { + final q = _db.select(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice)); + trashedAssets.addAll(await q.get()); + } + + if (trashedAssets.isEmpty) { + return; + } + + final companions = trashedAssets.map((e) { + return LocalAssetEntityCompanion.insert( + id: e.id, + name: e.name, + type: e.type, + createdAt: Value(e.createdAt), + updatedAt: Value(e.updatedAt), + width: Value(e.width), + height: Value(e.height), + durationInSeconds: Value(e.durationInSeconds), + checksum: Value(e.checksum), + isFavorite: Value(e.isFavorite), + orientation: Value(e.orientation), + ); + }); + + await _db.transaction(() async { + for (final companion in companions) { + await _db.into(_db.localAssetEntity).insertOnConflictUpdate(companion); + } + for (final slice in idList.slices(kDriftMaxChunk)) { + await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go(); + } + }); + } + + Future>> getToTrash() async { + final result = >{}; + + final rows = + await (_db.select(_db.localAlbumAssetEntity).join([ + innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)), + innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)), + leftOuterJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum), + ), + ])..where( + _db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) & + _db.remoteAssetEntity.deletedAt.isNotNull(), + )) + .get(); + + for (final row in rows) { + final albumId = row.readTable(_db.localAlbumAssetEntity).albumId; + final asset = row.readTable(_db.localAssetEntity).toDto(); + (result[albumId] ??= []).add(asset); + } + + return result; + } + + //attempt to reuse existing checksums + Future> _getCachedChecksums(Set assetIds) async { + final localChecksumById = {}; + + for (final slice in assetIds.slices(kDriftMaxChunk)) { + final rows = + await (_db.selectOnly(_db.localAssetEntity) + ..where(_db.localAssetEntity.id.isIn(slice) & _db.localAssetEntity.checksum.isNotNull()) + ..addColumns([_db.localAssetEntity.id, _db.localAssetEntity.checksum])) + .get(); + + for (final r in rows) { + localChecksumById[r.read(_db.localAssetEntity.id)!] = r.read(_db.localAssetEntity.checksum)!; + } + } + + return localChecksumById; + } +} diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index c1d621f474..79db33104d 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -65,7 +65,7 @@ class SplashScreenPageState extends ConsumerState { if (Store.isBetaTimelineEnabled) { bool syncSuccess = false; await Future.wait([ - backgroundManager.syncLocal(), + backgroundManager.syncLocal(full: true), backgroundManager.syncRemote().then((success) => syncSuccess = success), ]); diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index 8e4b900292..34ed7a5e2b 100644 --- a/mobile/lib/platform/native_sync_api.g.dart +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -562,4 +562,32 @@ class NativeSyncApi { return; } } + + Future>> getTrashedAssets() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as Map?)!.cast>(); + } + } } diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index 30e7dd497a..b92d429aa1 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -3,21 +3,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/datetime_extensions.dart'; -import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/widgets/activities/comment_bubble.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/activity_service.provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; -import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; @RoutePage() class DriftActivitiesPage extends HookConsumerWidget { @@ -27,10 +19,8 @@ class DriftActivitiesPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.read(currentAssetNotifier) as RemoteAsset?; - - final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); - final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); + final activityNotifier = ref.read(albumActivityProvider(album.id).notifier); + final activities = ref.watch(albumActivityProvider(album.id)); final listViewScrollController = useScrollController(); void scrollToBottom() { @@ -46,7 +36,7 @@ class DriftActivitiesPage extends HookConsumerWidget { overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], child: Scaffold( appBar: AppBar( - title: asset == null ? Text(album.name) : null, + title: Text(album.name), actions: [const LikeActivityActionButton(menuItem: true)], actionsPadding: const EdgeInsets.only(right: 8), ), @@ -57,7 +47,7 @@ class DriftActivitiesPage extends HookConsumerWidget { activityWidgets.add( Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: _CommentBubble(activity: activity), + child: CommentBubble(activity: activity), ), ); } @@ -91,139 +81,3 @@ class DriftActivitiesPage extends HookConsumerWidget { ); } } - -class _CommentBubble extends ConsumerWidget { - final Activity activity; - - const _CommentBubble({required this.activity}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final user = ref.watch(currentUserProvider); - final album = ref.watch(currentRemoteAlbumProvider)!; - final isOwn = activity.user.id == user?.id; - final canDelete = isOwn || album.ownerId == user?.id; - final hasAsset = activity.assetId != null && activity.assetId!.isNotEmpty; - final isLike = activity.type == ActivityType.like; - final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer; - - final activityNotifier = ref.read(albumActivityProvider(album.id, activity.assetId).notifier); - - Future openAssetViewer() async { - final activityService = ref.read(activityServiceProvider); - final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref); - if (route != null) await context.pushRoute(route); - } - - Widget avatar() { - if (isOwn) { - return const SizedBox.shrink(); - } - - return UserCircleAvatar(user: activity.user, size: 28, radius: 14); - } - - Widget? thumbnail() { - if (!hasAsset) { - return null; - } - - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 150, maxHeight: 150), - child: Stack( - children: [ - GestureDetector( - onTap: openAssetViewer, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(10)), - child: Image( - image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!), - fit: BoxFit.cover, - ), - ), - ), - if (isLike) - Positioned( - right: 6, - bottom: 6, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle), - child: Icon(Icons.favorite, color: Colors.red[600], size: 18), - ), - ), - ], - ), - ); - } - - // Likes Album widget (for likes without asset) - Widget? likesToAlbum() { - if (!isLike || hasAsset) { - return null; - } - - return Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle), - child: Icon(Icons.favorite, color: Colors.red[600], size: 18), - ); - } - - Widget? commentBubble() { - if (activity.comment == null || activity.comment!.isEmpty) { - return null; - } - - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(12))), - child: Text( - activity.comment ?? '', - style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurface), - ), - ), - ); - } - - // Combined content widgets - final List contentChildren = [thumbnail(), likesToAlbum(), commentBubble()].whereType().toList(); - - return DismissibleActivity( - onDismiss: canDelete ? (id) async => await activityNotifier.removeActivity(id) : null, - activity.id, - Align( - alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.86), - child: Container( - margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOwn) ...[avatar(), const SizedBox(width: 8)], - // Content column - Column( - crossAxisAlignment: isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - ...contentChildren.map((w) => Padding(padding: const EdgeInsets.only(bottom: 8.0), child: w)), - Text( - '${activity.user.name} • ${activity.createdAt.timeAgo()}', - style: context.textTheme.labelMedium?.copyWith( - color: context.colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - if (isOwn) const SizedBox(width: 8), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 5ded685e21..58ca892f5f 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; @@ -54,6 +55,7 @@ class DriftSearchPage extends HookConsumerWidget { ); final previousFilter = useState(null); + final dateInputFilter = useState(null); final peopleCurrentFilterWidget = useState(null); final dateRangeCurrentFilterWidget = useState(null); @@ -245,19 +247,54 @@ class DriftSearchPage extends HookConsumerWidget { ); } + datePicked(DateFilterInputModel? selectedDate) { + dateInputFilter.value = selectedDate; + if (selectedDate == null) { + filter.value = filter.value.copyWith(date: SearchDateFilter()); + + dateRangeCurrentFilterWidget.value = null; + unawaited(search()); + return; + } + + final date = selectedDate.asDateTimeRange(); + + filter.value = filter.value.copyWith( + date: SearchDateFilter( + takenAfter: date.start, + takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)), + ), + ); + + dateRangeCurrentFilterWidget.value = Text( + selectedDate.asHumanReadable(context), + style: context.textTheme.labelLarge, + ); + + unawaited(search()); + } + showDatePicker() async { final firstDate = DateTime(1900); final lastDate = DateTime.now(); + var dateRange = DateTimeRange( + start: filter.value.date.takenAfter ?? lastDate, + end: filter.value.date.takenBefore ?? lastDate, + ); + + // datePicked() may increase the date, this will make the date picker fail an assertion + // Fixup the end date to be at most now. + if (dateRange.end.isAfter(lastDate)) { + dateRange = DateTimeRange(start: dateRange.start, end: lastDate); + } + final date = await showDateRangePicker( context: context, firstDate: firstDate, lastDate: lastDate, currentDate: DateTime.now(), - initialDateRange: DateTimeRange( - start: filter.value.date.takenAfter ?? lastDate, - end: filter.value.date.takenBefore ?? lastDate, - ), + initialDateRange: dateRange, helpText: 'search_filter_date_title'.t(context: context), cancelText: 'cancel'.t(context: context), confirmText: 'select'.t(context: context), @@ -271,40 +308,32 @@ class DriftSearchPage extends HookConsumerWidget { ); if (date == null) { - filter.value = filter.value.copyWith(date: SearchDateFilter()); - - dateRangeCurrentFilterWidget.value = null; - unawaited(search()); - return; - } - - filter.value = filter.value.copyWith( - date: SearchDateFilter( - takenAfter: date.start, - takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)), - ), - ); - - // If date range is less than 24 hours, set the end date to the end of the day - if (date.end.difference(date.start).inHours < 24) { - dateRangeCurrentFilterWidget.value = Text( - DateFormat.yMMMd().format(date.start.toLocal()), - style: context.textTheme.labelLarge, - ); + datePicked(null); } else { - dateRangeCurrentFilterWidget.value = Text( - 'search_filter_date_interval'.t( - context: context, - args: { - "start": DateFormat.yMMMd().format(date.start.toLocal()), - "end": DateFormat.yMMMd().format(date.end.toLocal()), + datePicked(CustomDateFilter.fromRange(date)); + } + } + + showQuickDatePicker() { + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: "pick_date_range".tr(), + expanded: true, + onClear: () => datePicked(null), + child: QuickDatePicker( + currentInput: dateInputFilter.value, + onRequestPicker: () { + context.pop(); + showDatePicker(); + }, + onSelect: (date) { + context.pop(); + datePicked(date); }, ), - style: context.textTheme.labelLarge, - ); - } - - unawaited(search()); + ), + ); } // MEDIA PICKER @@ -589,7 +618,7 @@ class DriftSearchPage extends HookConsumerWidget { ), SearchFilterChip( icon: Icons.date_range_outlined, - onTap: showDatePicker, + onTap: showQuickDatePicker, label: 'search_filter_date'.t(context: context), currentFilter: dateRangeCurrentFilterWidget.value, ), diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart new file mode 100644 index 0000000000..9155d82753 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -0,0 +1,191 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; + +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; + +enum AddToMenuItem { album, archive, unarchive, lockedFolder } + +class AddActionButton extends ConsumerWidget { + const AddActionButton({super.key}); + + Future _showAddOptions(BuildContext context, WidgetRef ref) async { + final asset = ref.read(currentAssetNotifier); + if (asset == null) return; + + final user = ref.read(currentUserProvider); + final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; + final isInLockedView = ref.watch(inLockedViewProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; + final hasRemote = asset is RemoteAsset; + final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived; + final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived; + final menuItemHeight = 30.0; + + final List> items = [ + PopupMenuItem( + enabled: false, + textStyle: context.textTheme.labelMedium, + height: 40, + child: Text("add_to_bottom_bar".tr()), + ), + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.album, + child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())), + ), + const PopupMenuDivider(), + PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())), + if (isOwner) ...[ + if (showArchive) + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.archive, + child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())), + ), + if (showUnarchive) + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.unarchive, + child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())), + ), + PopupMenuItem( + height: menuItemHeight, + value: AddToMenuItem.lockedFolder, + child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())), + ), + ], + ]; + + final AddToMenuItem? selected = await showMenu( + context: context, + color: context.themeData.scaffoldBackgroundColor, + position: _menuPosition(context), + items: items, + ); + + if (selected == null) { + return; + } + + switch (selected) { + case AddToMenuItem.album: + _openAlbumSelector(context, ref); + break; + case AddToMenuItem.archive: + await performArchiveAction(context, ref, source: ActionSource.viewer); + break; + case AddToMenuItem.unarchive: + await performUnArchiveAction(context, ref, source: ActionSource.viewer); + break; + case AddToMenuItem.lockedFolder: + await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer); + break; + } + } + + RelativeRect _menuPosition(BuildContext context) { + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox) { + return RelativeRect.fill; + } + + final size = renderObject.size; + final position = renderObject.localToGlobal(Offset.zero); + + return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy); + } + + void _openAlbumSelector(BuildContext context, WidgetRef ref) { + final currentAsset = ref.read(currentAssetNotifier); + if (currentAsset == null) { + ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); + return; + } + + final List slivers = [ + AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)), + ]; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) { + return BaseBottomSheet( + actions: const [], + slivers: slivers, + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.95, + expand: false, + backgroundColor: context.isDarkTheme ? Colors.black : Colors.white, + ); + }, + ); + } + + Future _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async { + final latest = ref.read(currentAssetNotifier); + + if (latest == null) { + ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); + return; + } + + final addedCount = await ref.read(remoteAlbumProvider.notifier).addAssets(album.id, [latest.remoteId!]); + + if (!context.mounted) { + return; + } + + if (addedCount == 0) { + ImmichToast.show( + context: context, + msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}), + ); + } else { + ImmichToast.show( + context: context, + msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + ); + } + + if (!context.mounted) { + return; + } + await Navigator.of(context).maybePop(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + return Builder( + builder: (buttonContext) { + return BaseActionButton( + iconData: Icons.add, + label: "add_to_bottom_bar".tr(), + onPressed: () => _showAddOptions(buttonContext, ref), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart index d30ba07d0c..290a19f584 100644 --- a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart @@ -10,33 +10,36 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +// used to allow performing archive action from different sources (without duplicating code) +Future performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { + if (!context.mounted) return; + + final result = await ref.read(actionProvider.notifier).archive(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } +} + class ArchiveActionButton extends ConsumerWidget { final ActionSource source; const ArchiveActionButton({super.key, required this.source}); - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final result = await ref.read(actionProvider.notifier).archive(source); - ref.read(multiSelectProvider.notifier).reset(); - - if (source == ActionSource.viewer) { - EventStream.shared.emit(const ViewerReloadAssetEvent()); - } - - final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); - - if (context.mounted) { - ImmichToast.show( - context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, - ); - } + Future _onTap(BuildContext context, WidgetRef ref) async { + await performArchiveAction(context, ref, source: source); } @override diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart index 78b9e3cde6..ddc83cb383 100644 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart @@ -10,36 +10,39 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +// Reusable helper: move to locked folder from any source (e.g called from menu) +Future performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { + if (!context.mounted) return; + + final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'move_to_lock_folder_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } +} + class MoveToLockFolderActionButton extends ConsumerWidget { final ActionSource source; const MoveToLockFolderActionButton({super.key, required this.source}); - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); - ref.read(multiSelectProvider.notifier).reset(); - - if (source == ActionSource.viewer) { - EventStream.shared.emit(const ViewerReloadAssetEvent()); - } - - final successMessage = 'move_to_lock_folder_action_prompt'.t( - context: context, - args: {'count': result.count.toString()}, - ); - - if (context.mounted) { - ImmichToast.show( - context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, - ); - } + Future _onTap(BuildContext context, WidgetRef ref) async { + await performMoveToLockFolderAction(context, ref, source: source); } @override diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart index b457a1b4ca..8b04a1b05d 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart @@ -1,3 +1,5 @@ +// dart +// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart` import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -7,30 +9,39 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; + +// used to allow performing unarchive action from different sources (without duplicating code) +Future performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { + if (!context.mounted) return; + + final result = await ref.read(actionProvider.notifier).unArchive(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } +} class UnArchiveActionButton extends ConsumerWidget { final ActionSource source; const UnArchiveActionButton({super.key, required this.source}); - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final result = await ref.read(actionProvider.notifier).unArchive(source); - ref.read(multiSelectProvider.notifier).reset(); - - final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); - - if (context.mounted) { - ImmichToast.show( - context: context, - msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), - gravity: ToastGravity.BOTTOM, - toastType: result.success ? ToastType.success : ToastType.error, - ); - } + Future _onTap(BuildContext context, WidgetRef ref) async { + await performUnArchiveAction(context, ref, source: source); } @override diff --git a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart index 81e64bed89..63669495b9 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart @@ -3,14 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/widgets/activities/comment_bubble.dart'; import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/widgets/activities/activity_tile.dart'; -import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; class ActivitiesBottomSheet extends HookConsumerWidget { final DraggableScrollableController controller; @@ -28,7 +26,6 @@ class ActivitiesBottomSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final album = ref.watch(currentRemoteAlbumProvider)!; final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; - final user = ref.watch(currentUserProvider); final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); @@ -47,16 +44,9 @@ class ActivitiesBottomSheet extends HookConsumerWidget { return const SizedBox.shrink(); } final activity = data[data.length - 1 - index]; - final canDelete = activity.user.id == user?.id || album.ownerId == user?.id; return Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: DismissibleActivity( - activity.id, - ActivityTile(activity, isBottomSheet: true), - onDismiss: canDelete - ? (activityId) async => await activityNotifier.removeActivity(activity.id) - : null, - ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: CommentBubble(activity: activity, isAssetActivity: true), ); }, childCount: data.length + 1), ); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index f8a2c37ccd..50c4347301 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -627,10 +627,10 @@ class _AssetViewerState extends ConsumerState { // Rebuild the widget when the asset viewer state changes // Using multiple selectors to avoid unnecessary rebuilds for other state changes ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - ref.watch(assetViewerProvider.select((s) => s.showingControls)); ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); ref.watch(assetViewerProvider.select((s) => s.stackIndex)); ref.watch(isPlayingMotionVideoProvider); + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); // Listen for casting changes and send initial asset to the cast provider ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async { @@ -663,7 +663,14 @@ class _AssetViewerState extends ConsumerState { appBar: const ViewerTopAppBar(), extendBody: true, extendBodyBehindAppBar: true, - floatingActionButton: const DownloadStatusFloatingButton(), + floatingActionButton: IgnorePointer( + ignoring: !showingControls, + child: AnimatedOpacity( + opacity: showingControls ? 1.0 : 0.0, + duration: Durations.short2, + child: const DownloadStatusFloatingButton(), + ), + ), body: Stack( children: [ PhotoViewGallery.builder( diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 9271c99ae9..d29e09a247 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -127,13 +127,18 @@ class _AssetDetailBottomSheet extends ConsumerWidget { if (exifInfo == null) { return null; } - - final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; - final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; + return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } - return [fNumber, exposureTime, focalLength, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + String? _getLensInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; + final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; + return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); } Future _editDateTime(BuildContext context, WidgetRef ref) async { @@ -141,20 +146,20 @@ class _AssetDetailBottomSheet extends ConsumerWidget { } Widget _buildAppearsInList(WidgetRef ref, BuildContext context) { - final aseet = ref.watch(currentAssetNotifier); - if (aseet == null) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { return const SizedBox.shrink(); } - if (!aseet.hasRemote) { + if (!asset.hasRemote) { return const SizedBox.shrink(); } String? remoteAssetId; - if (aseet is RemoteAsset) { - remoteAssetId = aseet.id; - } else if (aseet is LocalAsset) { - remoteAssetId = aseet.remoteAssetId; + if (asset is RemoteAsset) { + remoteAssetId = asset.id; + } else if (asset is LocalAsset) { + remoteAssetId = asset.remoteAssetId; } if (remoteAssetId == null) { @@ -217,6 +222,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final cameraTitle = _getCameraInfoTitle(exifInfo); + final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); // Build file info tile based on asset type @@ -287,12 +293,23 @@ class _AssetDetailBottomSheet extends ConsumerWidget { _SheetTile( title: cameraTitle, titleStyle: context.textTheme.labelLarge, - leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), + leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), subtitle: _getCameraInfoSubtitle(exifInfo), subtitleStyle: context.textTheme.bodyMedium?.copyWith( color: context.textTheme.bodyMedium?.color?.withAlpha(155), ), ), + // Lens info + if (lensTitle != null) + _SheetTile( + title: lensTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getLensInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith( + color: context.textTheme.bodyMedium?.color?.withAlpha(155), + ), + ), // Appears in (Albums) _buildAppearsInList(ref, context), // padding at the bottom to avoid cut-off diff --git a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart index 7205dad941..2f2847543f 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart @@ -86,13 +86,9 @@ class _BaseDraggableScrollableSheetState extends ConsumerState SliverToBoxAdapter( child: Column( children: [ - SizedBox( - height: 115, - child: ListView( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - children: widget.actions, - ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: widget.actions), ), const Divider(indent: 16, endIndent: 16), const SizedBox(height: 16), diff --git a/mobile/lib/presentation/widgets/search/quick_date_picker.dart b/mobile/lib/presentation/widgets/search/quick_date_picker.dart new file mode 100644 index 0000000000..09b1cee700 --- /dev/null +++ b/mobile/lib/presentation/widgets/search/quick_date_picker.dart @@ -0,0 +1,208 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; + +sealed class DateFilterInputModel { + DateTimeRange asDateTimeRange(); + + String asHumanReadable(BuildContext context) { + // General implementation for arbitrary date and time ranges + // If date range is less than 24 hours, set the end date to the end of the day + final date = asDateTimeRange(); + if (date.end.difference(date.start).inHours < 24) { + return DateFormat.yMMMd().format(date.start.toLocal()); + } else { + return 'search_filter_date_interval'.t( + context: context, + args: { + "start": DateFormat.yMMMd().format(date.start.toLocal()), + "end": DateFormat.yMMMd().format(date.end.toLocal()), + }, + ); + } + } +} + +class RecentMonthRangeFilter extends DateFilterInputModel { + final int monthDelta; + RecentMonthRangeFilter(this.monthDelta); + + @override + DateTimeRange asDateTimeRange() { + final now = DateTime.now(); + // Note that DateTime's constructor properly handles month overflow. + final from = DateTime(now.year, now.month - monthDelta, 1); + return DateTimeRange(start: from, end: now); + } + + @override + String asHumanReadable(BuildContext context) { + return 'last_months'.t(context: context, args: {"count": monthDelta.toString()}); + } +} + +class YearFilter extends DateFilterInputModel { + final int year; + YearFilter(this.year); + + @override + DateTimeRange asDateTimeRange() { + final now = DateTime.now(); + final from = DateTime(year, 1, 1); + + if (now.year == year) { + // To not go beyond today if the user picks the current year + return DateTimeRange(start: from, end: now); + } + + final to = DateTime(year, 12, 31, 23, 59, 59); + return DateTimeRange(start: from, end: to); + } + + @override + String asHumanReadable(BuildContext context) { + return 'in_year'.tr(namedArgs: {"year": year.toString()}); + } +} + +class CustomDateFilter extends DateFilterInputModel { + final DateTime start; + final DateTime end; + + CustomDateFilter(this.start, this.end); + + factory CustomDateFilter.fromRange(DateTimeRange range) { + return CustomDateFilter(range.start, range.end); + } + + @override + DateTimeRange asDateTimeRange() { + return DateTimeRange(start: start, end: end); + } +} + +enum _QuickPickerType { last1Month, last3Months, last9Months, year, custom } + +class QuickDatePicker extends HookWidget { + QuickDatePicker({super.key, required this.currentInput, required this.onSelect, required this.onRequestPicker}) + : _selection = _selectionFromModel(currentInput), + _initialYear = _initialYearFromModel(currentInput); + + final Function() onRequestPicker; + final Function(DateFilterInputModel range) onSelect; + + final DateFilterInputModel? currentInput; + final _QuickPickerType? _selection; + final int _initialYear; + + // Generate a list of recent years from 2000 to the current year (including the current one) + final List _recentYears = List.generate(1 + DateTime.now().year - 2000, (index) { + return index + 2000; + }); + + static int _initialYearFromModel(DateFilterInputModel? model) { + return model?.asDateTimeRange().start.year ?? DateTime.now().year; + } + + static _QuickPickerType? _selectionFromModel(DateFilterInputModel? model) { + if (model is RecentMonthRangeFilter) { + return switch (model.monthDelta) { + 1 => _QuickPickerType.last1Month, + 3 => _QuickPickerType.last3Months, + 9 => _QuickPickerType.last9Months, + _ => _QuickPickerType.custom, + }; + } else if (model is YearFilter) { + return _QuickPickerType.year; + } else if (model is CustomDateFilter) { + return _QuickPickerType.custom; + } + return null; + } + + Text _monthLabel(BuildContext context, int monthsFromNow) => + const Text('last_months').t(context: context, args: {"count": monthsFromNow.toString()}); + + Widget _yearPicker(BuildContext context) { + final size = MediaQuery.of(context).size; + return Row( + children: [ + const Text("in_year_selector").tr(), + const SizedBox(width: 15), + Expanded( + child: DropdownMenu( + initialSelection: _initialYear, + menuStyle: MenuStyle(maximumSize: WidgetStateProperty.all(Size(size.width, size.height * 0.5))), + dropdownMenuEntries: _recentYears.map((e) => DropdownMenuEntry(value: e, label: e.toString())).toList(), + onSelected: (year) { + if (year == null) return; + onSelect(YearFilter(year)); + }, + ), + ), + ], + ); + } + + // We want the exact date picker to always be selectable. + // Even if it's already toggled it should always open the full date picker, RadioListTiles don't do that by default + // so we wrap it in a InkWell + Widget _exactPicker(BuildContext context) { + final hasPreviousInput = currentInput != null && currentInput is CustomDateFilter; + + return InkWell( + onTap: onRequestPicker, + child: IgnorePointer( + ignoring: true, + child: RadioListTile( + title: const Text('pick_custom_range').tr(), + subtitle: hasPreviousInput ? Text(currentInput!.asHumanReadable(context)) : null, + secondary: hasPreviousInput ? const Icon(Icons.edit) : null, + value: _QuickPickerType.custom, + toggleable: true, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Scrollbar( + // Depending on the screen size the last option might get cut off + // Add a clear visual cue that there are more options when scrolling + // When the screen size is large enough the scrollbar is hidden automatically + trackVisibility: true, + thumbVisibility: true, + child: SingleChildScrollView( + child: RadioGroup( + onChanged: (value) { + if (value == null) return; + final _ = switch (value) { + _QuickPickerType.custom => onRequestPicker(), + _QuickPickerType.last1Month => onSelect(RecentMonthRangeFilter(1)), + _QuickPickerType.last3Months => onSelect(RecentMonthRangeFilter(3)), + _QuickPickerType.last9Months => onSelect(RecentMonthRangeFilter(9)), + // When a year is selected the combobox triggers onSelect() on its own. + // Here we handle the radio button being selected which can only ever be the initial year + _QuickPickerType.year => onSelect(YearFilter(_initialYear)), + }; + }, + groupValue: _selection, + child: Column( + children: [ + RadioListTile(title: _monthLabel(context, 1), value: _QuickPickerType.last1Month, toggleable: true), + RadioListTile(title: _monthLabel(context, 3), value: _QuickPickerType.last3Months, toggleable: true), + RadioListTile(title: _monthLabel(context, 9), value: _QuickPickerType.last9Months, toggleable: true), + RadioListTile(title: _yearPicker(context), value: _QuickPickerType.year, toggleable: true), + _exactPicker(context), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/providers/activity.provider.dart b/mobile/lib/providers/activity.provider.dart index a867a5a281..5e0e71d85d 100644 --- a/mobile/lib/providers/activity.provider.dart +++ b/mobile/lib/providers/activity.provider.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/activity_service.provider.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; @@ -16,13 +17,20 @@ class AlbumActivity extends _$AlbumActivity { Future removeActivity(String id) async { if (await ref.watch(activityServiceProvider).removeActivity(id)) { - final activities = state.valueOrNull ?? []; - final removedActivity = activities.firstWhere((a) => a.id == id); - activities.remove(removedActivity); - state = AsyncData(activities); - // Decrement activity count only for comments + final removedActivity = _removeFromState(id); + if (removedActivity == null) { + return; + } + + if (assetId != null) { + ref.read(albumActivityProvider(albumId).notifier)._removeFromState(id); + } + if (removedActivity.type == ActivityType.comment) { ref.watch(activityStatisticsProvider(albumId, assetId).notifier).removeActivity(); + if (assetId != null) { + ref.watch(activityStatisticsProvider(albumId).notifier).removeActivity(); + } } } } @@ -30,8 +38,10 @@ class AlbumActivity extends _$AlbumActivity { Future addLike() async { final activity = await ref.watch(activityServiceProvider).addActivity(albumId, ActivityType.like, assetId: assetId); if (activity.hasValue) { - final activities = state.asData?.value ?? []; - state = AsyncData([...activities, activity.requireValue]); + _addToState(activity.requireValue); + if (assetId != null) { + ref.read(albumActivityProvider(albumId).notifier)._addToState(activity.requireValue); + } } } @@ -41,8 +51,10 @@ class AlbumActivity extends _$AlbumActivity { .addActivity(albumId, ActivityType.comment, assetId: assetId, comment: comment); if (activity.hasValue) { - final activities = state.valueOrNull ?? []; - state = AsyncData([...activities, activity.requireValue]); + _addToState(activity.requireValue); + if (assetId != null) { + ref.read(albumActivityProvider(albumId).notifier)._addToState(activity.requireValue); + } ref.watch(activityStatisticsProvider(albumId, assetId).notifier).addActivity(); // The previous addActivity call would increase the count of an asset if assetId != null // To also increase the activity count of the album, calling it once again with assetId set to null @@ -51,6 +63,29 @@ class AlbumActivity extends _$AlbumActivity { } } } + + void _addToState(Activity activity) { + final activities = state.valueOrNull ?? []; + if (activities.any((a) => a.id == activity.id)) { + return; + } + state = AsyncData([...activities, activity]); + } + + Activity? _removeFromState(String id) { + final activities = state.valueOrNull; + if (activities == null) { + return null; + } + final activity = activities.firstWhereOrNull((a) => a.id == id); + if (activity == null) { + return null; + } + + final updated = [...activities]..remove(activity); + state = AsyncData(updated); + return activity; + } } /// Mock class for testing diff --git a/mobile/lib/providers/activity.provider.g.dart b/mobile/lib/providers/activity.provider.g.dart index dc927795f8..6ca99e4f72 100644 --- a/mobile/lib/providers/activity.provider.g.dart +++ b/mobile/lib/providers/activity.provider.g.dart @@ -6,7 +6,7 @@ part of 'activity.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$albumActivityHash() => r'3b0d7acee4d41c84b3f220784c3b904c83f836e6'; +String _$albumActivityHash() => r'154e8ae98da3efc142369eae46d4005468fd67da'; /// Copied from Dart SDK class _SystemHash { diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 4b51ce33bd..70cb200bf1 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -13,6 +14,10 @@ final remoteAssetRepositoryProvider = Provider( (ref) => RemoteAssetRepository(ref.watch(driftProvider)), ); +final trashedLocalAssetRepository = Provider( + (ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)), +); + final assetServiceProvider = Provider( (ref) => AssetService( remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider), diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index f03754505c..6ba9c4bb78 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -10,11 +10,17 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; final syncStreamServiceProvider = Provider( (ref) => SyncStreamService( syncApiRepository: ref.watch(syncApiRepositoryProvider), syncStreamRepository: ref.watch(syncStreamRepositoryProvider), + localAssetRepository: ref.watch(localAssetRepository), + trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), + localFilesManager: ref.watch(localFilesManagerRepositoryProvider), + storageRepository: ref.watch(storageRepositoryProvider), cancelChecker: ref.watch(cancellationProvider), ), ); @@ -26,6 +32,9 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref. final localSyncServiceProvider = Provider( (ref) => LocalSyncService( localAlbumRepository: ref.watch(localAlbumRepository), + trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), + localFilesManager: ref.watch(localFilesManagerRepositoryProvider), + storageRepository: ref.watch(storageRepositoryProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider), ), ); @@ -35,5 +44,6 @@ final hashServiceProvider = Provider( localAlbumRepository: ref.watch(localAlbumRepository), localAssetRepository: ref.watch(localAssetRepository), nativeSyncApi: ref.watch(nativeSyncApiProvider), + trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), ), ); diff --git a/mobile/lib/providers/infrastructure/trash_sync.provider.dart b/mobile/lib/providers/infrastructure/trash_sync.provider.dart new file mode 100644 index 0000000000..a783080f33 --- /dev/null +++ b/mobile/lib/providers/infrastructure/trash_sync.provider.dart @@ -0,0 +1,12 @@ +import 'package:async/async.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; + +typedef TrashedAssetsCount = ({int total, int hashed}); + +final trashedAssetsCountProvider = StreamProvider((ref) { + final repo = ref.watch(trashedLocalAssetRepository); + final total$ = repo.watchCount(); + final hashed$ = repo.watchHashedCount(); + return StreamZip([total$, hashed$]).map((values) => (total: values[0], hashed: values[1])); +}); diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 7a424c332d..9619ba86a1 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -67,7 +67,7 @@ class ServerInfoNotifier extends StateNotifier { return; } - if (clientVersion < serverVersion) { + if (clientVersion < serverVersion && clientVersion.differenceType(serverVersion) != SemVerType.patch) { state = state.copyWith(versionStatus: VersionStatus.clientOutOfDate); return; } diff --git a/mobile/lib/providers/trash.provider.dart b/mobile/lib/providers/trash.provider.dart index adf3b1027b..41b9160b9b 100644 --- a/mobile/lib/providers/trash.provider.dart +++ b/mobile/lib/providers/trash.provider.dart @@ -1,6 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/trash.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/trash.service.dart'; import 'package:logging/logging.dart'; class TrashNotifier extends StateNotifier { diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index e377ff22d6..2e4bdfd32c 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -89,9 +89,16 @@ class AssetMediaRepository { return null; } - // titleAsync gets the correct original filename for some assets on iOS - // otherwise using the `entity.title` would return a random GUID - return await entity.titleAsync; + try { + // titleAsync gets the correct original filename for some assets on iOS + // otherwise using the `entity.title` would return a random GUID + final originalFilename = await entity.titleAsync; + // treat empty filename as missing + return originalFilename.isNotEmpty ? originalFilename : null; + } catch (e) { + _log.warning("Failed to get original filename for asset: $id. Error: $e"); + return null; + } } // TODO: make this more efficient diff --git a/mobile/lib/repositories/folder_api.repository.dart b/mobile/lib/repositories/folder_api.repository.dart index dfda19e45e..d20ca8e0a9 100644 --- a/mobile/lib/repositories/folder_api.repository.dart +++ b/mobile/lib/repositories/folder_api.repository.dart @@ -8,7 +8,7 @@ import 'package:openapi/api.dart'; final folderApiRepositoryProvider = Provider((ref) => FolderApiRepository(ref.watch(apiServiceProvider).viewApi)); class FolderApiRepository extends ApiRepository { - final ViewApi _api; + final ViewsApi _api; final Logger _log = Logger("FolderApiRepository"); FolderApiRepository(this._api); diff --git a/mobile/lib/repositories/local_files_manager.repository.dart b/mobile/lib/repositories/local_files_manager.repository.dart index 519d79b49b..765c9a6f0e 100644 --- a/mobile/lib/repositories/local_files_manager.repository.dart +++ b/mobile/lib/repositories/local_files_manager.repository.dart @@ -1,13 +1,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/services/local_files_manager.service.dart'; +import 'package:logging/logging.dart'; final localFilesManagerRepositoryProvider = Provider( (ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)), ); class LocalFilesManagerRepository { - const LocalFilesManagerRepository(this._service); + LocalFilesManagerRepository(this._service); + final Logger _logger = Logger('SyncStreamService'); final LocalFilesManagerService _service; Future moveToTrash(List mediaUrls) async { @@ -21,4 +24,26 @@ class LocalFilesManagerRepository { Future requestManageMediaPermission() async { return await _service.requestManageMediaPermission(); } + + Future hasManageMediaPermission() async { + return await _service.hasManageMediaPermission(); + } + + Future manageMediaPermission() async { + return await _service.manageMediaPermission(); + } + + Future> restoreAssetsFromTrash(Iterable assets) async { + final restoredIds = []; + for (final asset in assets) { + _logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}"); + try { + await _service.restoreFromTrashById(asset.id, asset.type.index); + restoredIds.add(asset.id); + } catch (e) { + _logger.warning("Restoring failure: $e"); + } + } + return restoredIds; + } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 5c0299c414..abe7ac3fa2 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -313,6 +313,7 @@ class AppRouter extends RootStackRouter { settings: page, pageBuilder: (_, __, ___) => child, opaque: false, + transitionsBuilder: TransitionsBuilders.fadeIn, ), ), ), diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 698ac3a159..1a714b6f40 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -17,7 +17,7 @@ class ApiService implements Authentication { late UsersApi usersApi; late AuthenticationApi authenticationApi; - late OAuthApi oAuthApi; + late AuthenticationApi oAuthApi; late AlbumsApi albumsApi; late AssetsApi assetsApi; late SearchApi searchApi; @@ -32,7 +32,7 @@ class ApiService implements Authentication { late DownloadApi downloadApi; late TrashApi trashApi; late StacksApi stacksApi; - late ViewApi viewApi; + late ViewsApi viewApi; late MemoriesApi memoriesApi; late SessionsApi sessionsApi; @@ -56,7 +56,7 @@ class ApiService implements Authentication { } usersApi = UsersApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient); - oAuthApi = OAuthApi(_apiClient); + oAuthApi = AuthenticationApi(_apiClient); albumsApi = AlbumsApi(_apiClient); assetsApi = AssetsApi(_apiClient); serverInfoApi = ServerApi(_apiClient); @@ -71,7 +71,7 @@ class ApiService implements Authentication { downloadApi = DownloadApi(_apiClient); trashApi = TrashApi(_apiClient); stacksApi = StacksApi(_apiClient); - viewApi = ViewApi(_apiClient); + viewApi = ViewsApi(_apiClient); memoriesApi = MemoriesApi(_apiClient); sessionsApi = SessionsApi(_apiClient); } diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index d67362aac2..6ede7f6830 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -77,6 +77,7 @@ class DeepLinkService { "memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''), "asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref), "album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''), + "activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''), _ => null, }; @@ -185,4 +186,18 @@ class DeepLinkService { return AlbumViewerRoute(albumId: album.id); } } + + Future _buildActivityDeepLink(String albumId) async { + if (Store.isBetaTimelineEnabled == false) { + return null; + } + + final album = await _betaRemoteAlbumService.get(albumId); + + if (album == null || album.isActivityEnabled == false) { + return null; + } + + return DriftActivitiesRoute(album: album); + } } diff --git a/mobile/lib/services/local_files_manager.service.dart b/mobile/lib/services/local_files_manager.service.dart index 7cb3067342..0cc00f3e4b 100644 --- a/mobile/lib/services/local_files_manager.service.dart +++ b/mobile/lib/services/local_files_manager.service.dart @@ -6,6 +6,7 @@ final localFileManagerServiceProvider = Provider((ref) class LocalFilesManagerService { const LocalFilesManagerService(); + static final Logger _logger = Logger('LocalFilesManager'); static const MethodChannel _channel = MethodChannel('file_trash'); @@ -27,6 +28,15 @@ class LocalFilesManagerService { } } + Future restoreFromTrashById(String mediaId, int type) async { + try { + return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type}); + } catch (e, s) { + _logger.warning('Error restore file from trash by Id', e, s); + return false; + } + } + Future requestManageMediaPermission() async { try { return await _channel.invokeMethod('requestManageMediaPermission'); @@ -35,4 +45,22 @@ class LocalFilesManagerService { return false; } } + + Future hasManageMediaPermission() async { + try { + return await _channel.invokeMethod('hasManageMediaPermission'); + } catch (e, s) { + _logger.warning('Error requesting manage media permission state', e, s); + return false; + } + } + + Future manageMediaPermission() async { + try { + return await _channel.invokeMethod('manageMediaPermission'); + } catch (e, s) { + _logger.warning('Error requesting manage media permission settings', e, s); + return false; + } + } } diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 0a3fa7e91d..0c1f03086f 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -51,6 +51,11 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'ocr', false); } break; + case 'MemoriesResponse': + if (value is Map) { + addDefault(value, 'duration', 5); + } + break; } } diff --git a/mobile/lib/utils/semver.dart b/mobile/lib/utils/semver.dart index 0eb6726b65..aebfd2fe4c 100644 --- a/mobile/lib/utils/semver.dart +++ b/mobile/lib/utils/semver.dart @@ -1,3 +1,5 @@ +enum SemVerType { major, minor, patch } + class SemVer { final int major; final int minor; @@ -15,8 +17,20 @@ class SemVer { } factory SemVer.fromString(String version) { + if (version.toLowerCase().startsWith("v")) { + version = version.substring(1); + } + final parts = version.split("-")[0].split('.'); - return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2])); + if (parts.length != 3) { + throw FormatException('Invalid semantic version string: $version'); + } + + try { + return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2])); + } catch (e) { + throw FormatException('Invalid semantic version string: $version'); + } } bool operator >(SemVer other) { @@ -54,6 +68,20 @@ class SemVer { return other is SemVer && other.major == major && other.minor == minor && other.patch == patch; } + SemVerType? differenceType(SemVer other) { + if (major != other.major) { + return SemVerType.major; + } + if (minor != other.minor) { + return SemVerType.minor; + } + if (patch != other.patch) { + return SemVerType.patch; + } + + return null; + } + @override int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode; } diff --git a/mobile/lib/widgets/activities/comment_bubble.dart b/mobile/lib/widgets/activities/comment_bubble.dart new file mode 100644 index 0000000000..11d5c21cec --- /dev/null +++ b/mobile/lib/widgets/activities/comment_bubble.dart @@ -0,0 +1,143 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +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/models/activities/activity.model.dart'; +import 'package:immich_mobile/providers/activity.provider.dart'; +import 'package:immich_mobile/providers/activity_service.provider.dart'; +import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; + +class CommentBubble extends ConsumerWidget { + final Activity activity; + final bool isAssetActivity; + + const CommentBubble({super.key, required this.activity, this.isAssetActivity = false}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(currentUserProvider); + final album = ref.watch(currentRemoteAlbumProvider)!; + final isOwn = activity.user.id == user?.id; + final canDelete = isOwn || album.ownerId == user?.id; + final showThumbnail = !isAssetActivity && activity.assetId != null && activity.assetId!.isNotEmpty; + final isLike = activity.type == ActivityType.like; + final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer; + + final activityNotifier = ref.read( + albumActivityProvider(album.id, isAssetActivity ? activity.assetId : null).notifier, + ); + + Future openAssetViewer() async { + final activityService = ref.read(activityServiceProvider); + final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref); + if (route != null) await context.pushRoute(route); + } + + // avatar (hidden for own messages) + Widget avatar = const SizedBox.shrink(); + if (!isOwn) { + avatar = UserCircleAvatar(user: activity.user, size: 28, radius: 14); + } + + // Thumbnail with tappable behavior and optional heart overlay + Widget? thumbnail; + if (showThumbnail) { + thumbnail = ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150, maxHeight: 150), + child: Stack( + children: [ + GestureDetector( + onTap: openAssetViewer, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(10)), + child: Image( + image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!), + fit: BoxFit.cover, + ), + ), + ), + if (isLike) + Positioned( + right: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle), + child: Icon(Icons.favorite, color: Colors.red[600], size: 18), + ), + ), + ], + ), + ); + } + + // Likes widget + Widget? likes; + if (isLike && !showThumbnail) { + likes = Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle), + child: Icon(Icons.favorite, color: Colors.red[600], size: 18), + ); + } + + // Comment bubble, comment-only + Widget? commentBubble; + if (activity.comment != null && activity.comment!.isNotEmpty) { + commentBubble = ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(12))), + child: Text( + activity.comment ?? '', + style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurface), + ), + ), + ); + } + + // Combined content widgets + final List contentChildren = [thumbnail, likes, commentBubble].whereType().toList(); + + return DismissibleActivity( + onDismiss: canDelete ? (id) async => await activityNotifier.removeActivity(id) : null, + activity.id, + Align( + alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.86), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOwn) ...[avatar, const SizedBox(width: 8)], + // Content column + Column( + crossAxisAlignment: isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + ...contentChildren.map((w) => Padding(padding: const EdgeInsets.only(bottom: 8.0), child: w)), + Text( + '${activity.user.name} • ${activity.createdAt.timeAgo()}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + if (isOwn) const SizedBox(width: 8), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index bb987d5bc0..f810973298 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -21,6 +21,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/url_helper.dart'; @@ -177,6 +178,55 @@ class LoginForm extends HookConsumerWidget { } } + getManageMediaPermission() async { + final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission(); + if (!hasPermission) { + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + elevation: 5, + title: Text( + 'manage_media_access_title', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor), + ).tr(), + content: SingleChildScrollView( + child: ListBody( + children: [ + const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(), + const SizedBox(height: 4), + const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'cancel'.tr(), + style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor), + ), + ), + TextButton( + onPressed: () { + ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission(); + Navigator.of(context).pop(); + }, + child: Text( + 'manage_media_access_settings'.tr(), + style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor), + ), + ), + ], + ); + }, + ); + } + } + + bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false); + login() async { TextInput.finishAutofillContext(); @@ -194,6 +244,9 @@ class LoginForm extends HookConsumerWidget { final isBeta = Store.isBetaTimelineEnabled; if (isBeta) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); + if (isSyncRemoteDeletionsMode()) { + await getManageMediaPermission(); + } unawaited(handleSyncFlow()); ref.read(websocketProvider.notifier).connect(); unawaited(context.replaceRoute(const TabShellRoute())); @@ -293,6 +346,9 @@ class LoginForm extends HookConsumerWidget { } if (isBeta) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); + if (isSyncRemoteDeletionsMode()) { + await getManageMediaPermission(); + } unawaited(handleSyncFlow()); unawaited(context.replaceRoute(const TabShellRoute())); return; diff --git a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart index e8226b5b3a..dee42ec5a0 100644 --- a/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart +++ b/mobile/lib/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart @@ -6,7 +6,7 @@ class FilterBottomSheetScaffold extends StatelessWidget { const FilterBottomSheetScaffold({ super.key, required this.child, - required this.onSearch, + this.onSearch, required this.onClear, required this.title, this.expanded, @@ -15,7 +15,7 @@ class FilterBottomSheetScaffold extends StatelessWidget { final bool? expanded; final String title; final Widget child; - final Function() onSearch; + final Function()? onSearch; final Function() onClear; @override @@ -48,15 +48,16 @@ class FilterBottomSheetScaffold extends StatelessWidget { }, child: const Text('clear').tr(), ), - const SizedBox(width: 8), - ElevatedButton( - key: const Key('search_filter_apply'), - onPressed: () { - onSearch(); - context.pop(); - }, - child: const Text('search_filter_apply').tr(), - ), + if (onSearch != null) const SizedBox(width: 8), + if (onSearch != null) + ElevatedButton( + key: const Key('search_filter_apply'), + onPressed: () { + onSearch!(); + context.pop(); + }, + child: const Text('search_filter_apply').tr(), + ), ], ), ), diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index 9255a7ae52..aee28c9449 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -8,8 +8,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; @@ -17,6 +17,7 @@ import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; +import 'package:immich_mobile/widgets/settings/settings_action_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @@ -25,12 +26,15 @@ import 'package:logging/logging.dart'; class AdvancedSettings extends HookConsumerWidget { const AdvancedSettings({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { bool isLoggedIn = ref.read(currentUserProvider) != null; final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); + final isManageMediaSupported = useState(false); + final manageMediaAndroidPermission = useState(false); final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert); @@ -51,6 +55,18 @@ class AdvancedSettings extends HookConsumerWidget { return false; } + useEffect(() { + () async { + isManageMediaSupported.value = await checkAndroidVersion(); + if (isManageMediaSupported.value) { + manageMediaAndroidPermission.value = await ref + .read(localFilesManagerRepositoryProvider) + .hasManageMediaPermission(); + } + }(); + return null; + }, []); + final advancedSettings = [ SettingsSwitchListTile( enabled: true, @@ -58,11 +74,10 @@ class AdvancedSettings extends HookConsumerWidget { title: "advanced_settings_troubleshooting_title".tr(), subtitle: "advanced_settings_troubleshooting_subtitle".tr(), ), - FutureBuilder( - future: checkAndroidVersion(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data == true) { - return SettingsSwitchListTile( + if (isManageMediaSupported.value) + Column( + children: [ + SettingsSwitchListTile( enabled: true, valueNotifier: manageLocalMediaAndroid, title: "advanced_settings_sync_remote_deletions_title".tr(), @@ -71,14 +86,24 @@ class AdvancedSettings extends HookConsumerWidget { if (value) { final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission(); manageLocalMediaAndroid.value = result; + manageMediaAndroidPermission.value = result; } }, - ); - } else { - return const SizedBox.shrink(); - } - }, - ), + ), + SettingsActionTile( + title: "manage_media_access_title".tr(), + statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(), + subtitle: "manage_media_access_rationale".tr(), + statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value + ? const Color.fromARGB(255, 243, 188, 106) + : null, + onActionTap: () async { + final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission(); + manageMediaAndroidPermission.value = result; + }, + ), + ], + ), SettingsSliderListTile( text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}), valueNotifier: levelId, diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index a5bca24f81..0296a6bd99 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -3,14 +3,18 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; @@ -229,6 +233,7 @@ class _SyncStatsCounts extends ConsumerWidget { final localAlbumService = ref.watch(localAlbumServiceProvider); final remoteAlbumService = ref.watch(remoteAlbumServiceProvider); final memoryService = ref.watch(driftMemoryServiceProvider); + final appSettingsService = ref.watch(appSettingsServiceProvider); Future> loadCounts() async { final assetCounts = assetService.getAssetCounts(); @@ -351,6 +356,44 @@ class _SyncStatsCounts extends ConsumerWidget { ], ), ), + // To be removed once the experimental feature is stable + if (CurrentPlatform.isAndroid && + appSettingsService.getSetting(AppSettingsEnum.manageLocalMediaAndroid)) ...[ + _SectionHeaderText(text: "trash".t(context: context)), + Consumer( + builder: (context, ref, _) { + final counts = ref.watch(trashedAssetsCountProvider); + return counts.when( + data: (c) => Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Flex( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8.0, + children: [ + Expanded( + child: EntitiyCountTile( + label: "local".t(context: context), + count: c.total, + icon: Icons.delete_outline, + ), + ), + Expanded( + child: EntitiyCountTile( + label: "hashed_assets".t(context: context), + count: c.hashed, + icon: Icons.tag, + ), + ), + ], + ), + ), + loading: () => const CircularProgressIndicator(), + error: (e, st) => Text('Error: $e'), + ); + }, + ), + ], ], ); }, diff --git a/mobile/lib/widgets/settings/settings_action_tile.dart b/mobile/lib/widgets/settings/settings_action_tile.dart new file mode 100644 index 0000000000..b2b5988fa5 --- /dev/null +++ b/mobile/lib/widgets/settings/settings_action_tile.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; + +class SettingsActionTile extends StatelessWidget { + const SettingsActionTile({ + super.key, + required this.title, + required this.subtitle, + required this.onActionTap, + this.statusText, + this.statusColor, + this.contentPadding, + this.titleStyle, + this.subtitleStyle, + }); + + final String title; + final String subtitle; + final String? statusText; + final Color? statusColor; + final VoidCallback onActionTap; + final EdgeInsets? contentPadding; + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ListTile( + isThreeLine: true, + onTap: onActionTap, + titleAlignment: ListTileTitleAlignment.center, + title: Row( + children: [ + Expanded( + child: Text( + title, + style: titleStyle ?? theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500, height: 1.5), + ), + ), + if (statusText != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Chip( + label: Text( + statusText!, + style: theme.textTheme.labelMedium?.copyWith( + color: statusColor ?? theme.colorScheme.onSurfaceVariant, + ), + ), + backgroundColor: theme.colorScheme.surface, + side: BorderSide(color: statusColor ?? theme.colorScheme.outlineVariant), + shape: StadiumBorder(side: BorderSide(color: statusColor ?? theme.colorScheme.outlineVariant)), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + ), + ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4.0, right: 18.0), + child: Text( + subtitle, + style: subtitleStyle ?? theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceSecondary), + ), + ), + trailing: Icon(Icons.arrow_forward_ios, size: 16, color: theme.colorScheme.onSurfaceVariant), + contentPadding: contentPadding ?? const EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0), + ); + } +} diff --git a/mobile/mise.toml b/mobile/mise.toml new file mode 100644 index 0000000000..cdafd1cc18 --- /dev/null +++ b/mobile/mise.toml @@ -0,0 +1,185 @@ +[tools] +flutter = "3.35.7" + +[tools."github:CQLabs/homebrew-dcm"] +version = "1.30.0" +bin = "dcm" +postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm" + +[tasks."codegen:dart"] +alias = "codegen" +description = "Execute build_runner to auto-generate dart code" +sources = [ + "pubspec.yaml", + "build.yaml", + "lib/**/*.dart", + "infrastructure/**/*.drift", +] +outputs = { auto = true } +run = "dart run build_runner build --delete-conflicting-outputs" + +[tasks."codegen:pigeon"] +alias = "pigeon" +description = "Generate pigeon platform code" +depends = [ + "pigeon:native-sync", + "pigeon:thumbnail", + "pigeon:background-worker", + "pigeon:background-worker-lock", + "pigeon:connectivity", +] + +[tasks."codegen:translation"] +alias = "translation" +description = "Generate translations from i18n JSONs" +run = [ + { task = "//i18n:format-fix" }, + { tasks = [ + "i18n:loader", + "i18n:keys", + ] }, +] + +[tasks."codegen:app-icon"] +description = "Generate app icons" +run = "flutter pub run flutter_launcher_icons:main" + +[tasks."codegen:splash"] +description = "Generate splash screen" +run = "flutter pub run flutter_native_splash:create" + +[tasks.test] +description = "Run mobile tests" +run = "flutter test" + +[tasks.lint] +description = "Analyze Dart code" +depends = ["analyze:dart", "analyze:dcm"] + +[tasks."lint-fix"] +description = "Auto-fix Dart code" +depends = ["analyze:fix:dart", "analyze:fix:dcm"] + +[tasks.format] +description = "Format Dart code" +run = "dart format --set-exit-if-changed $(find lib -name '*.dart' -not \\( -name '*.g.dart' -o -name '*.drift.dart' -o -name '*.gr.dart' \\))" + +[tasks."build:android"] +description = "Build Android release" +run = "flutter build appbundle" + +[tasks."drift:migration"] +alias = "migration" +description = "Generate database migrations" +run = "dart run drift_dev make-migrations" + + +# Internal tasks +[tasks."pigeon:native-sync"] +description = "Generate native sync API pigeon code" +hide = true +sources = ["pigeon/native_sync_api.dart"] +outputs = [ + "lib/platform/native_sync_api.g.dart", + "ios/Runner/Sync/Messages.g.swift", + "android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt", +] +run = [ + "dart run pigeon --input pigeon/native_sync_api.dart", + "dart format lib/platform/native_sync_api.g.dart", +] + +[tasks."pigeon:thumbnail"] +description = "Generate thumbnail API pigeon code" +hide = true +sources = ["pigeon/thumbnail_api.dart"] +outputs = [ + "lib/platform/thumbnail_api.g.dart", + "ios/Runner/Images/Thumbnails.g.swift", + "android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt", +] +run = [ + "dart run pigeon --input pigeon/thumbnail_api.dart", + "dart format lib/platform/thumbnail_api.g.dart", +] + +[tasks."pigeon:background-worker"] +description = "Generate background worker API pigeon code" +hide = true +sources = ["pigeon/background_worker_api.dart"] +outputs = [ + "lib/platform/background_worker_api.g.dart", + "ios/Runner/Background/BackgroundWorker.g.swift", + "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt", +] +run = [ + "dart run pigeon --input pigeon/background_worker_api.dart", + "dart format lib/platform/background_worker_api.g.dart", +] + +[tasks."pigeon:background-worker-lock"] +description = "Generate background worker lock API pigeon code" +hide = true +sources = ["pigeon/background_worker_lock_api.dart"] +outputs = [ + "lib/platform/background_worker_lock_api.g.dart", + "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt", +] +run = [ + "dart run pigeon --input pigeon/background_worker_lock_api.dart", + "dart format lib/platform/background_worker_lock_api.g.dart", +] + +[tasks."pigeon:connectivity"] +description = "Generate connectivity API pigeon code" +hide = true +sources = ["pigeon/connectivity_api.dart"] +outputs = [ + "lib/platform/connectivity_api.g.dart", + "ios/Runner/Connectivity/Connectivity.g.swift", + "android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt", +] +run = [ + "dart run pigeon --input pigeon/connectivity_api.dart", + "dart format lib/platform/connectivity_api.g.dart", +] + +[tasks."i18n:loader"] +description = "Generate i18n loader" +hide = true +sources = ["i18n/"] +outputs = "lib/generated/codegen_loader.g.dart" +run = [ + "dart run easy_localization:generate -S ../i18n", + "dart format lib/generated/codegen_loader.g.dart", +] + +[tasks."i18n:keys"] +description = "Generate i18n keys" +hide = true +sources = ["i18n/en.json"] +outputs = "lib/generated/intl_keys.g.dart" +run = [ + "dart run bin/generate_keys.dart", + "dart format lib/generated/intl_keys.g.dart", +] + +[tasks."analyze:dart"] +description = "Run Dart analysis" +hide = true +run = "dart analyze --fatal-infos" + +[tasks."analyze:dcm"] +description = "Run Dart Code Metrics" +hide = true +run = "dcm analyze lib --fatal-style --fatal-warnings" + +[tasks."analyze:fix:dart"] +description = "Auto-fix Dart analysis" +hide = true +run = "dart fix --apply" + +[tasks."analyze:fix:dcm"] +description = "Auto-fix Dart Code Metrics" +hide = true +run = "dcm fix lib" diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index efde847034..4e34f66a81 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 2.2.2 +- API version: 2.2.3 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen @@ -73,225 +73,235 @@ All URIs are relative to */api* Class | Method | HTTP request | Description ------------ | ------------- | ------------- | ------------- -*APIKeysApi* | [**createApiKey**](doc//APIKeysApi.md#createapikey) | **POST** /api-keys | -*APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} | -*APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} | -*APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys | -*APIKeysApi* | [**getMyApiKey**](doc//APIKeysApi.md#getmyapikey) | **GET** /api-keys/me | -*APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} | -*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities | -*ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} | -*ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities | -*ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics | -*AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets | -*AlbumsApi* | [**addAssetsToAlbums**](doc//AlbumsApi.md#addassetstoalbums) | **PUT** /albums/assets | -*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users | -*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums | -*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} | -*AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} | -*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | -*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | -*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | -*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | -*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | -*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | -*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | checkBulkUpload -*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | checkExistingAssets -*AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | -*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | -*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | -*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | -*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId -*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | -*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | -*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | -*AssetsApi* | [**getAssetOcr**](doc//AssetsApi.md#getassetocr) | **GET** /assets/{id}/ocr | -*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | -*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | -*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | -*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace the asset with new file, without changing its id -*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | -*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | -*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | -*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | -*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | -*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | -*AuthAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all | -*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | -*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | -*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | -*AuthenticationApi* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock | -*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | -*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | -*AuthenticationApi* | [**resetPinCode**](doc//AuthenticationApi.md#resetpincode) | **DELETE** /auth/pin-code | -*AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code | -*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | -*AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | -*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | -*DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | -*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | -*DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace the asset with new file, without changing its id -*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | -*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | -*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | -*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | -*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | -*FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces | -*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | -*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | -*FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} | -*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | -*JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | -*JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | -*LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | -*LibrariesApi* | [**deleteLibrary**](doc//LibrariesApi.md#deletelibrary) | **DELETE** /libraries/{id} | -*LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | -*LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} | -*LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics | -*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | -*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | -*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | -*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | -*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | -*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | -*MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | -*MemoriesApi* | [**deleteMemory**](doc//MemoriesApi.md#deletememory) | **DELETE** /memories/{id} | -*MemoriesApi* | [**getMemory**](doc//MemoriesApi.md#getmemory) | **GET** /memories/{id} | -*MemoriesApi* | [**memoriesStatistics**](doc//MemoriesApi.md#memoriesstatistics) | **GET** /memories/statistics | -*MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | -*MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | -*MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | -*NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} | -*NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications | -*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} | -*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications | -*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} | -*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications | -*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications | -*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} | -*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email | -*OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | -*OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | -*OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | -*OAuthApi* | [**startOAuth**](doc//OAuthApi.md#startoauth) | **POST** /oauth/authorize | -*OAuthApi* | [**unlinkOAuthAccount**](doc//OAuthApi.md#unlinkoauthaccount) | **POST** /oauth/unlink | -*PartnersApi* | [**createPartner**](doc//PartnersApi.md#createpartner) | **POST** /partners | -*PartnersApi* | [**createPartnerDeprecated**](doc//PartnersApi.md#createpartnerdeprecated) | **POST** /partners/{id} | -*PartnersApi* | [**getPartners**](doc//PartnersApi.md#getpartners) | **GET** /partners | -*PartnersApi* | [**removePartner**](doc//PartnersApi.md#removepartner) | **DELETE** /partners/{id} | -*PartnersApi* | [**updatePartner**](doc//PartnersApi.md#updatepartner) | **PUT** /partners/{id} | -*PeopleApi* | [**createPerson**](doc//PeopleApi.md#createperson) | **POST** /people | -*PeopleApi* | [**deletePeople**](doc//PeopleApi.md#deletepeople) | **DELETE** /people | -*PeopleApi* | [**deletePerson**](doc//PeopleApi.md#deleteperson) | **DELETE** /people/{id} | -*PeopleApi* | [**getAllPeople**](doc//PeopleApi.md#getallpeople) | **GET** /people | -*PeopleApi* | [**getPerson**](doc//PeopleApi.md#getperson) | **GET** /people/{id} | -*PeopleApi* | [**getPersonStatistics**](doc//PeopleApi.md#getpersonstatistics) | **GET** /people/{id}/statistics | -*PeopleApi* | [**getPersonThumbnail**](doc//PeopleApi.md#getpersonthumbnail) | **GET** /people/{id}/thumbnail | -*PeopleApi* | [**mergePerson**](doc//PeopleApi.md#mergeperson) | **POST** /people/{id}/merge | -*PeopleApi* | [**reassignFaces**](doc//PeopleApi.md#reassignfaces) | **PUT** /people/{id}/reassign | -*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | -*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | -*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | -*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | -*SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | -*SearchApi* | [**searchAssetStatistics**](doc//SearchApi.md#searchassetstatistics) | **POST** /search/statistics | -*SearchApi* | [**searchAssets**](doc//SearchApi.md#searchassets) | **POST** /search/metadata | -*SearchApi* | [**searchLargeAssets**](doc//SearchApi.md#searchlargeassets) | **POST** /search/large-assets | -*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | -*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | -*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random | -*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | -*ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | -*ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about | -*ServerApi* | [**getApkLinks**](doc//ServerApi.md#getapklinks) | **GET** /server/apk-links | -*ServerApi* | [**getServerConfig**](doc//ServerApi.md#getserverconfig) | **GET** /server/config | -*ServerApi* | [**getServerFeatures**](doc//ServerApi.md#getserverfeatures) | **GET** /server/features | -*ServerApi* | [**getServerLicense**](doc//ServerApi.md#getserverlicense) | **GET** /server/license | -*ServerApi* | [**getServerStatistics**](doc//ServerApi.md#getserverstatistics) | **GET** /server/statistics | -*ServerApi* | [**getServerVersion**](doc//ServerApi.md#getserverversion) | **GET** /server/version | -*ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage | -*ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types | -*ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme | -*ServerApi* | [**getVersionCheck**](doc//ServerApi.md#getversioncheck) | **GET** /server/version-check | -*ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history | -*ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping | -*ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | -*SessionsApi* | [**createSession**](doc//SessionsApi.md#createsession) | **POST** /sessions | -*SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | -*SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | -*SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | -*SessionsApi* | [**lockSession**](doc//SessionsApi.md#locksession) | **POST** /sessions/{id}/lock | -*SessionsApi* | [**updateSession**](doc//SessionsApi.md#updatesession) | **PUT** /sessions/{id} | -*SharedLinksApi* | [**addSharedLinkAssets**](doc//SharedLinksApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets | -*SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links | -*SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links | -*SharedLinksApi* | [**getMySharedLink**](doc//SharedLinksApi.md#getmysharedlink) | **GET** /shared-links/me | -*SharedLinksApi* | [**getSharedLinkById**](doc//SharedLinksApi.md#getsharedlinkbyid) | **GET** /shared-links/{id} | -*SharedLinksApi* | [**removeSharedLink**](doc//SharedLinksApi.md#removesharedlink) | **DELETE** /shared-links/{id} | -*SharedLinksApi* | [**removeSharedLinkAssets**](doc//SharedLinksApi.md#removesharedlinkassets) | **DELETE** /shared-links/{id}/assets | -*SharedLinksApi* | [**updateSharedLink**](doc//SharedLinksApi.md#updatesharedlink) | **PATCH** /shared-links/{id} | -*StacksApi* | [**createStack**](doc//StacksApi.md#createstack) | **POST** /stacks | -*StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} | -*StacksApi* | [**deleteStacks**](doc//StacksApi.md#deletestacks) | **DELETE** /stacks | -*StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} | -*StacksApi* | [**removeAssetFromStack**](doc//StacksApi.md#removeassetfromstack) | **DELETE** /stacks/{id}/assets/{assetId} | -*StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks | -*StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} | -*SyncApi* | [**deleteSyncAck**](doc//SyncApi.md#deletesyncack) | **DELETE** /sync/ack | -*SyncApi* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync | -*SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | -*SyncApi* | [**getSyncAck**](doc//SyncApi.md#getsyncack) | **GET** /sync/ack | -*SyncApi* | [**getSyncStream**](doc//SyncApi.md#getsyncstream) | **POST** /sync/stream | -*SyncApi* | [**sendSyncAck**](doc//SyncApi.md#sendsyncack) | **POST** /sync/ack | -*SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | -*SystemConfigApi* | [**getConfigDefaults**](doc//SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | -*SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | -*SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | -*SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | -*SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | -*SystemMetadataApi* | [**getVersionCheckState**](doc//SystemMetadataApi.md#getversioncheckstate) | **GET** /system-metadata/version-check-state | -*SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | -*TagsApi* | [**bulkTagAssets**](doc//TagsApi.md#bulktagassets) | **PUT** /tags/assets | -*TagsApi* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags | -*TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} | -*TagsApi* | [**getAllTags**](doc//TagsApi.md#getalltags) | **GET** /tags | -*TagsApi* | [**getTagById**](doc//TagsApi.md#gettagbyid) | **GET** /tags/{id} | -*TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets | -*TagsApi* | [**untagAssets**](doc//TagsApi.md#untagassets) | **DELETE** /tags/{id}/assets | -*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PUT** /tags/{id} | -*TagsApi* | [**upsertTags**](doc//TagsApi.md#upserttags) | **PUT** /tags | -*TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | -*TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | -*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | -*TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets | -*TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore | -*UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image | -*UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | -*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | -*UsersApi* | [**deleteUserOnboarding**](doc//UsersApi.md#deleteuseronboarding) | **DELETE** /users/me/onboarding | -*UsersApi* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences | -*UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me | -*UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image | -*UsersApi* | [**getUser**](doc//UsersApi.md#getuser) | **GET** /users/{id} | -*UsersApi* | [**getUserLicense**](doc//UsersApi.md#getuserlicense) | **GET** /users/me/license | -*UsersApi* | [**getUserOnboarding**](doc//UsersApi.md#getuseronboarding) | **GET** /users/me/onboarding | -*UsersApi* | [**searchUsers**](doc//UsersApi.md#searchusers) | **GET** /users | -*UsersApi* | [**setUserLicense**](doc//UsersApi.md#setuserlicense) | **PUT** /users/me/license | -*UsersApi* | [**setUserOnboarding**](doc//UsersApi.md#setuseronboarding) | **PUT** /users/me/onboarding | -*UsersApi* | [**updateMyPreferences**](doc//UsersApi.md#updatemypreferences) | **PUT** /users/me/preferences | -*UsersApi* | [**updateMyUser**](doc//UsersApi.md#updatemyuser) | **PUT** /users/me | -*UsersAdminApi* | [**createUserAdmin**](doc//UsersAdminApi.md#createuseradmin) | **POST** /admin/users | -*UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} | -*UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} | -*UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences | -*UsersAdminApi* | [**getUserSessionsAdmin**](doc//UsersAdminApi.md#getusersessionsadmin) | **GET** /admin/users/{id}/sessions | -*UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics | -*UsersAdminApi* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore | -*UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | -*UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} | -*UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | -*ViewApi* | [**getAssetsByOriginalPath**](doc//ViewApi.md#getassetsbyoriginalpath) | **GET** /view/folder | -*ViewApi* | [**getUniqueOriginalPaths**](doc//ViewApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths | +*APIKeysApi* | [**createApiKey**](doc//APIKeysApi.md#createapikey) | **POST** /api-keys | Create an API key +*APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} | Delete an API key +*APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} | Retrieve an API key +*APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys | List all API keys +*APIKeysApi* | [**getMyApiKey**](doc//APIKeysApi.md#getmyapikey) | **GET** /api-keys/me | Retrieve the current API key +*APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} | Update an API key +*ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities | Create an activity +*ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} | Delete an activity +*ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities | List all activities +*ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics | Retrieve activity statistics +*AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets | Add assets to an album +*AlbumsApi* | [**addAssetsToAlbums**](doc//AlbumsApi.md#addassetstoalbums) | **PUT** /albums/assets | Add assets to albums +*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users | Share album with users +*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums | Create an album +*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} | Delete an album +*AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} | Retrieve an album +*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | Retrieve album statistics +*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | List all albums +*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | Remove assets from an album +*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | Remove user from album +*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | Update an album +*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | Update user role +*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Check bulk upload +*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | Check existing assets +*AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset +*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key +*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | Delete assets +*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset +*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID +*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset +*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | Get asset metadata +*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | Retrieve asset metadata by key +*AssetsApi* | [**getAssetOcr**](doc//AssetsApi.md#getassetocr) | **GET** /assets/{id}/ocr | Retrieve asset OCR data +*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | Get asset statistics +*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | Get random assets +*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video +*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset +*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job +*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset +*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | Update asset metadata +*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | Update assets +*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset +*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail +*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password +*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code +*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth +*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | Retrieve auth status +*AuthenticationApi* | [**linkOAuthAccount**](doc//AuthenticationApi.md#linkoauthaccount) | **POST** /oauth/link | Link OAuth account +*AuthenticationApi* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock | Lock auth session +*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | Login +*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | Logout +*AuthenticationApi* | [**redirectOAuthToMobile**](doc//AuthenticationApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | Redirect OAuth to mobile +*AuthenticationApi* | [**resetPinCode**](doc//AuthenticationApi.md#resetpincode) | **DELETE** /auth/pin-code | Reset pin code +*AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code | Setup pin code +*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | Register admin +*AuthenticationApi* | [**startOAuth**](doc//AuthenticationApi.md#startoauth) | **POST** /oauth/authorize | Start OAuth +*AuthenticationApi* | [**unlinkOAuthAccount**](doc//AuthenticationApi.md#unlinkoauthaccount) | **POST** /oauth/unlink | Unlink OAuth account +*AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | Unlock auth session +*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | Validate access token +*AuthenticationAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthenticationAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all | Unlink all OAuth accounts +*DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner +*DeprecatedApi* | [**getAllUserAssetsByDeviceId**](doc//DeprecatedApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID +*DeprecatedApi* | [**getDeltaSync**](doc//DeprecatedApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user +*DeprecatedApi* | [**getFullSyncForUser**](doc//DeprecatedApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user +*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | Get random assets +*DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset +*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive +*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information +*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate +*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates +*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates +*FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces | Create a face +*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | Delete a face +*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | Retrieve faces for asset +*FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} | Re-assign a face to another person +*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | Create a manual job +*JobsApi* | [**getQueuesLegacy**](doc//JobsApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status +*JobsApi* | [**runQueueCommandLegacy**](doc//JobsApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs +*LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | Create a library +*LibrariesApi* | [**deleteLibrary**](doc//LibrariesApi.md#deletelibrary) | **DELETE** /libraries/{id} | Delete a library +*LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | Retrieve libraries +*LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} | Retrieve a library +*LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics | Retrieve library statistics +*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library +*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library +*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings +*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers +*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | Reverse geocode coordinates +*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | Add assets to a memory +*MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | Create a memory +*MemoriesApi* | [**deleteMemory**](doc//MemoriesApi.md#deletememory) | **DELETE** /memories/{id} | Delete a memory +*MemoriesApi* | [**getMemory**](doc//MemoriesApi.md#getmemory) | **GET** /memories/{id} | Retrieve a memory +*MemoriesApi* | [**memoriesStatistics**](doc//MemoriesApi.md#memoriesstatistics) | **GET** /memories/statistics | Retrieve memories statistics +*MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | Remove assets from a memory +*MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | Retrieve memories +*MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | Update a memory +*NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} | Delete a notification +*NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications | Delete notifications +*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} | Get a notification +*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications | Retrieve notifications +*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} | Update a notification +*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications | Update notifications +*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications | Create a notification +*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} | Render email template +*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email | Send test email +*PartnersApi* | [**createPartner**](doc//PartnersApi.md#createpartner) | **POST** /partners | Create a partner +*PartnersApi* | [**createPartnerDeprecated**](doc//PartnersApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner +*PartnersApi* | [**getPartners**](doc//PartnersApi.md#getpartners) | **GET** /partners | Retrieve partners +*PartnersApi* | [**removePartner**](doc//PartnersApi.md#removepartner) | **DELETE** /partners/{id} | Remove a partner +*PartnersApi* | [**updatePartner**](doc//PartnersApi.md#updatepartner) | **PUT** /partners/{id} | Update a partner +*PeopleApi* | [**createPerson**](doc//PeopleApi.md#createperson) | **POST** /people | Create a person +*PeopleApi* | [**deletePeople**](doc//PeopleApi.md#deletepeople) | **DELETE** /people | Delete people +*PeopleApi* | [**deletePerson**](doc//PeopleApi.md#deleteperson) | **DELETE** /people/{id} | Delete person +*PeopleApi* | [**getAllPeople**](doc//PeopleApi.md#getallpeople) | **GET** /people | Get all people +*PeopleApi* | [**getPerson**](doc//PeopleApi.md#getperson) | **GET** /people/{id} | Get a person +*PeopleApi* | [**getPersonStatistics**](doc//PeopleApi.md#getpersonstatistics) | **GET** /people/{id}/statistics | Get person statistics +*PeopleApi* | [**getPersonThumbnail**](doc//PeopleApi.md#getpersonthumbnail) | **GET** /people/{id}/thumbnail | Get person thumbnail +*PeopleApi* | [**mergePerson**](doc//PeopleApi.md#mergeperson) | **POST** /people/{id}/merge | Merge people +*PeopleApi* | [**reassignFaces**](doc//PeopleApi.md#reassignfaces) | **PUT** /people/{id}/reassign | Reassign faces +*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people +*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person +*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin +*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins +*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city +*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data +*SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | Retrieve search suggestions +*SearchApi* | [**searchAssetStatistics**](doc//SearchApi.md#searchassetstatistics) | **POST** /search/statistics | Search asset statistics +*SearchApi* | [**searchAssets**](doc//SearchApi.md#searchassets) | **POST** /search/metadata | Search assets by metadata +*SearchApi* | [**searchLargeAssets**](doc//SearchApi.md#searchlargeassets) | **POST** /search/large-assets | Search large assets +*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | Search people +*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | Search places +*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random | Search random assets +*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | Smart asset search +*ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | Delete server product key +*ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about | Get server information +*ServerApi* | [**getApkLinks**](doc//ServerApi.md#getapklinks) | **GET** /server/apk-links | Get APK links +*ServerApi* | [**getServerConfig**](doc//ServerApi.md#getserverconfig) | **GET** /server/config | Get config +*ServerApi* | [**getServerFeatures**](doc//ServerApi.md#getserverfeatures) | **GET** /server/features | Get features +*ServerApi* | [**getServerLicense**](doc//ServerApi.md#getserverlicense) | **GET** /server/license | Get product key +*ServerApi* | [**getServerStatistics**](doc//ServerApi.md#getserverstatistics) | **GET** /server/statistics | Get statistics +*ServerApi* | [**getServerVersion**](doc//ServerApi.md#getserverversion) | **GET** /server/version | Get server version +*ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage | Get storage +*ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types | Get supported media types +*ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme | Get theme +*ServerApi* | [**getVersionCheck**](doc//ServerApi.md#getversioncheck) | **GET** /server/version-check | Get version check status +*ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history | Get version history +*ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping | Ping +*ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | Set server product key +*SessionsApi* | [**createSession**](doc//SessionsApi.md#createsession) | **POST** /sessions | Create a session +*SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | Delete all sessions +*SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | Delete a session +*SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | Retrieve sessions +*SessionsApi* | [**lockSession**](doc//SessionsApi.md#locksession) | **POST** /sessions/{id}/lock | Lock a session +*SessionsApi* | [**updateSession**](doc//SessionsApi.md#updatesession) | **PUT** /sessions/{id} | Update a session +*SharedLinksApi* | [**addSharedLinkAssets**](doc//SharedLinksApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets | Add assets to a shared link +*SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links | Create a shared link +*SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links | Retrieve all shared links +*SharedLinksApi* | [**getMySharedLink**](doc//SharedLinksApi.md#getmysharedlink) | **GET** /shared-links/me | Retrieve current shared link +*SharedLinksApi* | [**getSharedLinkById**](doc//SharedLinksApi.md#getsharedlinkbyid) | **GET** /shared-links/{id} | Retrieve a shared link +*SharedLinksApi* | [**removeSharedLink**](doc//SharedLinksApi.md#removesharedlink) | **DELETE** /shared-links/{id} | Delete a shared link +*SharedLinksApi* | [**removeSharedLinkAssets**](doc//SharedLinksApi.md#removesharedlinkassets) | **DELETE** /shared-links/{id}/assets | Remove assets from a shared link +*SharedLinksApi* | [**updateSharedLink**](doc//SharedLinksApi.md#updatesharedlink) | **PATCH** /shared-links/{id} | Update a shared link +*StacksApi* | [**createStack**](doc//StacksApi.md#createstack) | **POST** /stacks | Create a stack +*StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} | Delete a stack +*StacksApi* | [**deleteStacks**](doc//StacksApi.md#deletestacks) | **DELETE** /stacks | Delete stacks +*StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} | Retrieve a stack +*StacksApi* | [**removeAssetFromStack**](doc//StacksApi.md#removeassetfromstack) | **DELETE** /stacks/{id}/assets/{assetId} | Remove an asset from a stack +*StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks | Retrieve stacks +*StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} | Update a stack +*SyncApi* | [**deleteSyncAck**](doc//SyncApi.md#deletesyncack) | **DELETE** /sync/ack | Delete acknowledgements +*SyncApi* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user +*SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user +*SyncApi* | [**getSyncAck**](doc//SyncApi.md#getsyncack) | **GET** /sync/ack | Retrieve acknowledgements +*SyncApi* | [**getSyncStream**](doc//SyncApi.md#getsyncstream) | **POST** /sync/stream | Stream sync changes +*SyncApi* | [**sendSyncAck**](doc//SyncApi.md#sendsyncack) | **POST** /sync/ack | Acknowledge changes +*SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | Get system configuration +*SystemConfigApi* | [**getConfigDefaults**](doc//SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | Get system configuration defaults +*SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | Get storage template options +*SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | Update system configuration +*SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | Retrieve admin onboarding +*SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | Retrieve reverse geocoding state +*SystemMetadataApi* | [**getVersionCheckState**](doc//SystemMetadataApi.md#getversioncheckstate) | **GET** /system-metadata/version-check-state | Retrieve version check state +*SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | Update admin onboarding +*TagsApi* | [**bulkTagAssets**](doc//TagsApi.md#bulktagassets) | **PUT** /tags/assets | Tag assets +*TagsApi* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags | Create a tag +*TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} | Delete a tag +*TagsApi* | [**getAllTags**](doc//TagsApi.md#getalltags) | **GET** /tags | Retrieve tags +*TagsApi* | [**getTagById**](doc//TagsApi.md#gettagbyid) | **GET** /tags/{id} | Retrieve a tag +*TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets | Tag assets +*TagsApi* | [**untagAssets**](doc//TagsApi.md#untagassets) | **DELETE** /tags/{id}/assets | Untag assets +*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PUT** /tags/{id} | Update a tag +*TagsApi* | [**upsertTags**](doc//TagsApi.md#upserttags) | **PUT** /tags | Upsert tags +*TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | Get time bucket +*TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | Get time buckets +*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | Empty trash +*TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets | Restore assets +*TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore | Restore trash +*UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image | Create user profile image +*UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | Delete user profile image +*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | Delete user product key +*UsersApi* | [**deleteUserOnboarding**](doc//UsersApi.md#deleteuseronboarding) | **DELETE** /users/me/onboarding | Delete user onboarding +*UsersApi* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences | Get my preferences +*UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me | Get current user +*UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image | Retrieve user profile image +*UsersApi* | [**getUser**](doc//UsersApi.md#getuser) | **GET** /users/{id} | Retrieve a user +*UsersApi* | [**getUserLicense**](doc//UsersApi.md#getuserlicense) | **GET** /users/me/license | Retrieve user product key +*UsersApi* | [**getUserOnboarding**](doc//UsersApi.md#getuseronboarding) | **GET** /users/me/onboarding | Retrieve user onboarding +*UsersApi* | [**searchUsers**](doc//UsersApi.md#searchusers) | **GET** /users | Get all users +*UsersApi* | [**setUserLicense**](doc//UsersApi.md#setuserlicense) | **PUT** /users/me/license | Set user product key +*UsersApi* | [**setUserOnboarding**](doc//UsersApi.md#setuseronboarding) | **PUT** /users/me/onboarding | Update user onboarding +*UsersApi* | [**updateMyPreferences**](doc//UsersApi.md#updatemypreferences) | **PUT** /users/me/preferences | Update my preferences +*UsersApi* | [**updateMyUser**](doc//UsersApi.md#updatemyuser) | **PUT** /users/me | Update current user +*UsersAdminApi* | [**createUserAdmin**](doc//UsersAdminApi.md#createuseradmin) | **POST** /admin/users | Create a user +*UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} | Delete a user +*UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} | Retrieve a user +*UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences | Retrieve user preferences +*UsersAdminApi* | [**getUserSessionsAdmin**](doc//UsersAdminApi.md#getusersessionsadmin) | **GET** /admin/users/{id}/sessions | Retrieve user sessions +*UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics | Retrieve user statistics +*UsersAdminApi* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore | Restore a deleted user +*UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | Search users +*UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} | Update a user +*UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | Update user preferences +*ViewsApi* | [**getAssetsByOriginalPath**](doc//ViewsApi.md#getassetsbyoriginalpath) | **GET** /view/folder | Retrieve assets by original path +*ViewsApi* | [**getUniqueOriginalPaths**](doc//ViewsApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths | Retrieve unique paths +*WorkflowsApi* | [**createWorkflow**](doc//WorkflowsApi.md#createworkflow) | **POST** /workflows | Create a workflow +*WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow +*WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow +*WorkflowsApi* | [**getWorkflows**](doc//WorkflowsApi.md#getworkflows) | **GET** /workflows | List all workflows +*WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow ## Documentation For Models @@ -315,7 +325,6 @@ Class | Method | HTTP request | Description - [AlbumsAddAssetsResponseDto](doc//AlbumsAddAssetsResponseDto.md) - [AlbumsResponse](doc//AlbumsResponse.md) - [AlbumsUpdate](doc//AlbumsUpdate.md) - - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md) - [AssetBulkDeleteDto](doc//AssetBulkDeleteDto.md) - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md) - [AssetBulkUploadCheckDto](doc//AssetBulkUploadCheckDto.md) @@ -384,13 +393,8 @@ Class | Method | HTTP request | Description - [FoldersResponse](doc//FoldersResponse.md) - [FoldersUpdate](doc//FoldersUpdate.md) - [ImageFormat](doc//ImageFormat.md) - - [JobCommand](doc//JobCommand.md) - - [JobCommandDto](doc//JobCommandDto.md) - - [JobCountsDto](doc//JobCountsDto.md) - [JobCreateDto](doc//JobCreateDto.md) - - [JobName](doc//JobName.md) - [JobSettingsDto](doc//JobSettingsDto.md) - - [JobStatusDto](doc//JobStatusDto.md) - [LibraryResponseDto](doc//LibraryResponseDto.md) - [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md) - [LicenseKeyDto](doc//LicenseKeyDto.md) @@ -407,6 +411,7 @@ Class | Method | HTTP request | Description - [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryResponseDto](doc//MemoryResponseDto.md) + - [MemorySearchOrder](doc//MemorySearchOrder.md) - [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md) - [MemoryType](doc//MemoryType.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md) @@ -446,9 +451,20 @@ Class | Method | HTTP request | Description - [PinCodeResetDto](doc//PinCodeResetDto.md) - [PinCodeSetupDto](doc//PinCodeSetupDto.md) - [PlacesResponseDto](doc//PlacesResponseDto.md) + - [PluginActionResponseDto](doc//PluginActionResponseDto.md) + - [PluginContext](doc//PluginContext.md) + - [PluginFilterResponseDto](doc//PluginFilterResponseDto.md) + - [PluginResponseDto](doc//PluginResponseDto.md) + - [PluginTriggerType](doc//PluginTriggerType.md) - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) + - [QueueCommand](doc//QueueCommand.md) + - [QueueCommandDto](doc//QueueCommandDto.md) + - [QueueName](doc//QueueName.md) + - [QueueResponseDto](doc//QueueResponseDto.md) + - [QueueStatisticsDto](doc//QueueStatisticsDto.md) - [QueueStatusDto](doc//QueueStatusDto.md) + - [QueuesResponseDto](doc//QueuesResponseDto.md) - [RandomSearchDto](doc//RandomSearchDto.md) - [RatingsResponse](doc//RatingsResponse.md) - [RatingsUpdate](doc//RatingsUpdate.md) @@ -599,6 +615,13 @@ Class | Method | HTTP request | Description - [VersionCheckStateResponseDto](doc//VersionCheckStateResponseDto.md) - [VideoCodec](doc//VideoCodec.md) - [VideoContainer](doc//VideoContainer.md) + - [WorkflowActionItemDto](doc//WorkflowActionItemDto.md) + - [WorkflowActionResponseDto](doc//WorkflowActionResponseDto.md) + - [WorkflowCreateDto](doc//WorkflowCreateDto.md) + - [WorkflowFilterItemDto](doc//WorkflowFilterItemDto.md) + - [WorkflowFilterResponseDto](doc//WorkflowFilterResponseDto.md) + - [WorkflowResponseDto](doc//WorkflowResponseDto.md) + - [WorkflowUpdateDto](doc//WorkflowUpdateDto.md) ## Documentation For Authorization diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ab88670bcd..f3db370c92 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -34,8 +34,8 @@ part 'api/api_keys_api.dart'; part 'api/activities_api.dart'; part 'api/albums_api.dart'; part 'api/assets_api.dart'; -part 'api/auth_admin_api.dart'; part 'api/authentication_api.dart'; +part 'api/authentication_admin_api.dart'; part 'api/deprecated_api.dart'; part 'api/download_api.dart'; part 'api/duplicates_api.dart'; @@ -46,9 +46,9 @@ part 'api/map_api.dart'; part 'api/memories_api.dart'; part 'api/notifications_api.dart'; part 'api/notifications_admin_api.dart'; -part 'api/o_auth_api.dart'; part 'api/partners_api.dart'; part 'api/people_api.dart'; +part 'api/plugins_api.dart'; part 'api/search_api.dart'; part 'api/server_api.dart'; part 'api/sessions_api.dart'; @@ -62,7 +62,8 @@ part 'api/timeline_api.dart'; part 'api/trash_api.dart'; part 'api/users_api.dart'; part 'api/users_admin_api.dart'; -part 'api/view_api.dart'; +part 'api/views_api.dart'; +part 'api/workflows_api.dart'; part 'model/api_key_create_dto.dart'; part 'model/api_key_create_response_dto.dart'; @@ -83,7 +84,6 @@ part 'model/albums_add_assets_dto.dart'; part 'model/albums_add_assets_response_dto.dart'; part 'model/albums_response.dart'; part 'model/albums_update.dart'; -part 'model/all_job_status_response_dto.dart'; part 'model/asset_bulk_delete_dto.dart'; part 'model/asset_bulk_update_dto.dart'; part 'model/asset_bulk_upload_check_dto.dart'; @@ -152,13 +152,8 @@ part 'model/facial_recognition_config.dart'; part 'model/folders_response.dart'; part 'model/folders_update.dart'; part 'model/image_format.dart'; -part 'model/job_command.dart'; -part 'model/job_command_dto.dart'; -part 'model/job_counts_dto.dart'; part 'model/job_create_dto.dart'; -part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; -part 'model/job_status_dto.dart'; part 'model/library_response_dto.dart'; part 'model/library_stats_response_dto.dart'; part 'model/license_key_dto.dart'; @@ -175,6 +170,7 @@ part 'model/memories_response.dart'; part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; part 'model/memory_response_dto.dart'; +part 'model/memory_search_order.dart'; part 'model/memory_statistics_response_dto.dart'; part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; @@ -214,9 +210,20 @@ part 'model/pin_code_change_dto.dart'; part 'model/pin_code_reset_dto.dart'; part 'model/pin_code_setup_dto.dart'; part 'model/places_response_dto.dart'; +part 'model/plugin_action_response_dto.dart'; +part 'model/plugin_context.dart'; +part 'model/plugin_filter_response_dto.dart'; +part 'model/plugin_response_dto.dart'; +part 'model/plugin_trigger_type.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; +part 'model/queue_command.dart'; +part 'model/queue_command_dto.dart'; +part 'model/queue_name.dart'; +part 'model/queue_response_dto.dart'; +part 'model/queue_statistics_dto.dart'; part 'model/queue_status_dto.dart'; +part 'model/queues_response_dto.dart'; part 'model/random_search_dto.dart'; part 'model/ratings_response.dart'; part 'model/ratings_update.dart'; @@ -367,6 +374,13 @@ part 'model/validate_library_response_dto.dart'; part 'model/version_check_state_response_dto.dart'; part 'model/video_codec.dart'; part 'model/video_container.dart'; +part 'model/workflow_action_item_dto.dart'; +part 'model/workflow_action_response_dto.dart'; +part 'model/workflow_create_dto.dart'; +part 'model/workflow_filter_item_dto.dart'; +part 'model/workflow_filter_response_dto.dart'; +part 'model/workflow_response_dto.dart'; +part 'model/workflow_update_dto.dart'; /// An [ApiClient] instance that uses the default values obtained from diff --git a/mobile/openapi/lib/api/activities_api.dart b/mobile/openapi/lib/api/activities_api.dart index 67015499fa..b92f95be72 100644 --- a/mobile/openapi/lib/api/activities_api.dart +++ b/mobile/openapi/lib/api/activities_api.dart @@ -16,7 +16,9 @@ class ActivitiesApi { final ApiClient apiClient; - /// This endpoint requires the `activity.create` permission. + /// Create an activity + /// + /// Create a like or a comment for an album, or an asset in an album. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class ActivitiesApi { ); } - /// This endpoint requires the `activity.create` permission. + /// Create an activity + /// + /// Create a like or a comment for an album, or an asset in an album. /// /// Parameters: /// @@ -68,7 +72,9 @@ class ActivitiesApi { return null; } - /// This endpoint requires the `activity.delete` permission. + /// Delete an activity + /// + /// Removes a like or comment from a given album or asset in an album. /// /// Note: This method returns the HTTP [Response]. /// @@ -101,7 +107,9 @@ class ActivitiesApi { ); } - /// This endpoint requires the `activity.delete` permission. + /// Delete an activity + /// + /// Removes a like or comment from a given album or asset in an album. /// /// Parameters: /// @@ -113,7 +121,9 @@ class ActivitiesApi { } } - /// This endpoint requires the `activity.read` permission. + /// List all activities + /// + /// Returns a list of activities for the selected asset or album. The activities are returned in sorted order, with the oldest activities appearing first. /// /// Note: This method returns the HTTP [Response]. /// @@ -167,7 +177,9 @@ class ActivitiesApi { ); } - /// This endpoint requires the `activity.read` permission. + /// List all activities + /// + /// Returns a list of activities for the selected asset or album. The activities are returned in sorted order, with the oldest activities appearing first. /// /// Parameters: /// @@ -198,7 +210,9 @@ class ActivitiesApi { return null; } - /// This endpoint requires the `activity.statistics` permission. + /// Retrieve activity statistics + /// + /// Returns the number of likes and comments for a given album or asset in an album. /// /// Note: This method returns the HTTP [Response]. /// @@ -237,7 +251,9 @@ class ActivitiesApi { ); } - /// This endpoint requires the `activity.statistics` permission. + /// Retrieve activity statistics + /// + /// Returns the number of likes and comments for a given album or asset in an album. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index a45083669c..1042a2850f 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -16,7 +16,9 @@ class AlbumsApi { final ApiClient apiClient; - /// This endpoint requires the `albumAsset.create` permission. + /// Add assets to an album + /// + /// Add multiple assets to a specific album by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -62,7 +64,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `albumAsset.create` permission. + /// Add assets to an album + /// + /// Add multiple assets to a specific album by its ID. /// /// Parameters: /// @@ -91,7 +95,9 @@ class AlbumsApi { return null; } - /// This endpoint requires the `albumAsset.create` permission. + /// Add assets to albums + /// + /// Send a list of asset IDs and album IDs to add each asset to each album. /// /// Note: This method returns the HTTP [Response]. /// @@ -134,7 +140,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `albumAsset.create` permission. + /// Add assets to albums + /// + /// Send a list of asset IDs and album IDs to add each asset to each album. /// /// Parameters: /// @@ -158,7 +166,9 @@ class AlbumsApi { return null; } - /// This endpoint requires the `albumUser.create` permission. + /// Share album with users + /// + /// Share an album with multiple users. Each user can be given a specific role in the album. /// /// Note: This method returns the HTTP [Response]. /// @@ -193,7 +203,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `albumUser.create` permission. + /// Share album with users + /// + /// Share an album with multiple users. Each user can be given a specific role in the album. /// /// Parameters: /// @@ -215,7 +227,9 @@ class AlbumsApi { return null; } - /// This endpoint requires the `album.create` permission. + /// Create an album + /// + /// Create a new album. The album can also be created with initial users and assets. /// /// Note: This method returns the HTTP [Response]. /// @@ -247,7 +261,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `album.create` permission. + /// Create an album + /// + /// Create a new album. The album can also be created with initial users and assets. /// /// Parameters: /// @@ -267,7 +283,9 @@ class AlbumsApi { return null; } - /// This endpoint requires the `album.delete` permission. + /// Delete an album + /// + /// Delete a specific album by its ID. Note the album is initially trashed and then immediately scheduled for deletion, but relies on a background job to complete the process. /// /// Note: This method returns the HTTP [Response]. /// @@ -300,7 +318,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `album.delete` permission. + /// Delete an album + /// + /// Delete a specific album by its ID. Note the album is initially trashed and then immediately scheduled for deletion, but relies on a background job to complete the process. /// /// Parameters: /// @@ -312,7 +332,9 @@ class AlbumsApi { } } - /// This endpoint requires the `album.read` permission. + /// Retrieve an album + /// + /// Retrieve information about a specific album by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -361,7 +383,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `album.read` permission. + /// Retrieve an album + /// + /// Retrieve information about a specific album by its ID. /// /// Parameters: /// @@ -387,7 +411,9 @@ class AlbumsApi { return null; } - /// This endpoint requires the `album.statistics` permission. + /// Retrieve album statistics + /// + /// Returns statistics about the albums available to the authenticated user. /// /// Note: This method returns the HTTP [Response]. Future getAlbumStatisticsWithHttpInfo() async { @@ -415,7 +441,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `album.statistics` permission. + /// Retrieve album statistics + /// + /// Returns statistics about the albums available to the authenticated user. Future getAlbumStatistics() async { final response = await getAlbumStatisticsWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -431,7 +459,9 @@ class AlbumsApi { return null; } - /// This endpoint requires the `album.read` permission. + /// List all albums + /// + /// Retrieve a list of albums available to the authenticated user. /// /// Note: This method returns the HTTP [Response]. /// @@ -473,7 +503,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `album.read` permission. + /// List all albums + /// + /// Retrieve a list of albums available to the authenticated user. /// /// Parameters: /// @@ -499,7 +531,9 @@ class AlbumsApi { return null; } - /// This endpoint requires the `albumAsset.delete` permission. + /// Remove assets from an album + /// + /// Remove multiple assets from a specific album by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -534,7 +568,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `albumAsset.delete` permission. + /// Remove assets from an album + /// + /// Remove multiple assets from a specific album by its ID. /// /// Parameters: /// @@ -559,7 +595,9 @@ class AlbumsApi { return null; } - /// This endpoint requires the `albumUser.delete` permission. + /// Remove user from album + /// + /// Remove a user from an album. Use an ID of \"me\" to leave a shared album. /// /// Note: This method returns the HTTP [Response]. /// @@ -595,7 +633,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `albumUser.delete` permission. + /// Remove user from album + /// + /// Remove a user from an album. Use an ID of \"me\" to leave a shared album. /// /// Parameters: /// @@ -609,7 +649,9 @@ class AlbumsApi { } } - /// This endpoint requires the `album.update` permission. + /// Update an album + /// + /// Update the information of a specific album by its ID. This endpoint can be used to update the album name, description, sort order, etc. However, it is not used to add or remove assets or users from the album. /// /// Note: This method returns the HTTP [Response]. /// @@ -644,7 +686,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `album.update` permission. + /// Update an album + /// + /// Update the information of a specific album by its ID. This endpoint can be used to update the album name, description, sort order, etc. However, it is not used to add or remove assets or users from the album. /// /// Parameters: /// @@ -666,7 +710,9 @@ class AlbumsApi { return null; } - /// This endpoint requires the `albumUser.update` permission. + /// Update user role + /// + /// Change the role for a specific user in a specific album. /// /// Note: This method returns the HTTP [Response]. /// @@ -704,7 +750,9 @@ class AlbumsApi { ); } - /// This endpoint requires the `albumUser.update` permission. + /// Update user role + /// + /// Change the role for a specific user in a specific album. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/api_keys_api.dart b/mobile/openapi/lib/api/api_keys_api.dart index 3ac829c30c..0bd26575c6 100644 --- a/mobile/openapi/lib/api/api_keys_api.dart +++ b/mobile/openapi/lib/api/api_keys_api.dart @@ -16,7 +16,9 @@ class APIKeysApi { final ApiClient apiClient; - /// This endpoint requires the `apiKey.create` permission. + /// Create an API key + /// + /// Creates a new API key. It will be limited to the permissions specified. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class APIKeysApi { ); } - /// This endpoint requires the `apiKey.create` permission. + /// Create an API key + /// + /// Creates a new API key. It will be limited to the permissions specified. /// /// Parameters: /// @@ -68,7 +72,9 @@ class APIKeysApi { return null; } - /// This endpoint requires the `apiKey.delete` permission. + /// Delete an API key + /// + /// Deletes an API key identified by its ID. The current user must own this API key. /// /// Note: This method returns the HTTP [Response]. /// @@ -101,7 +107,9 @@ class APIKeysApi { ); } - /// This endpoint requires the `apiKey.delete` permission. + /// Delete an API key + /// + /// Deletes an API key identified by its ID. The current user must own this API key. /// /// Parameters: /// @@ -113,7 +121,9 @@ class APIKeysApi { } } - /// This endpoint requires the `apiKey.read` permission. + /// Retrieve an API key + /// + /// Retrieve an API key by its ID. The current user must own this API key. /// /// Note: This method returns the HTTP [Response]. /// @@ -146,7 +156,9 @@ class APIKeysApi { ); } - /// This endpoint requires the `apiKey.read` permission. + /// Retrieve an API key + /// + /// Retrieve an API key by its ID. The current user must own this API key. /// /// Parameters: /// @@ -166,7 +178,9 @@ class APIKeysApi { return null; } - /// This endpoint requires the `apiKey.read` permission. + /// List all API keys + /// + /// Retrieve all API keys of the current user. /// /// Note: This method returns the HTTP [Response]. Future getApiKeysWithHttpInfo() async { @@ -194,7 +208,9 @@ class APIKeysApi { ); } - /// This endpoint requires the `apiKey.read` permission. + /// List all API keys + /// + /// Retrieve all API keys of the current user. Future?> getApiKeys() async { final response = await getApiKeysWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -213,7 +229,11 @@ class APIKeysApi { return null; } - /// Performs an HTTP 'GET /api-keys/me' operation and returns the [Response]. + /// Retrieve the current API key + /// + /// Retrieve the API key that is used to access this endpoint. + /// + /// Note: This method returns the HTTP [Response]. Future getMyApiKeyWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/api-keys/me'; @@ -239,6 +259,9 @@ class APIKeysApi { ); } + /// Retrieve the current API key + /// + /// Retrieve the API key that is used to access this endpoint. Future getMyApiKey() async { final response = await getMyApiKeyWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -254,7 +277,9 @@ class APIKeysApi { return null; } - /// This endpoint requires the `apiKey.update` permission. + /// Update an API key + /// + /// Updates the name and permissions of an API key by its ID. The current user must own this API key. /// /// Note: This method returns the HTTP [Response]. /// @@ -289,7 +314,9 @@ class APIKeysApi { ); } - /// This endpoint requires the `apiKey.update` permission. + /// Update an API key + /// + /// Updates the name and permissions of an API key by its ID. The current user must own this API key. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 7bae14bb58..5020afc4b2 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -16,9 +16,9 @@ class AssetsApi { final ApiClient apiClient; - /// checkBulkUpload + /// Check bulk upload /// - /// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission. + /// Determine which assets have already been uploaded to the server based on their SHA1 checksums. /// /// Note: This method returns the HTTP [Response]. /// @@ -50,9 +50,9 @@ class AssetsApi { ); } - /// checkBulkUpload + /// Check bulk upload /// - /// Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission. + /// Determine which assets have already been uploaded to the server based on their SHA1 checksums. /// /// Parameters: /// @@ -72,7 +72,7 @@ class AssetsApi { return null; } - /// checkExistingAssets + /// Check existing assets /// /// Checks if multiple assets exist on the server and returns all existing - used by background backup /// @@ -106,7 +106,7 @@ class AssetsApi { ); } - /// checkExistingAssets + /// Check existing assets /// /// Checks if multiple assets exist on the server and returns all existing - used by background backup /// @@ -128,7 +128,9 @@ class AssetsApi { return null; } - /// This endpoint requires the `asset.copy` permission. + /// Copy asset + /// + /// Copy asset information like albums, tags, etc. from one asset to another. /// /// Note: This method returns the HTTP [Response]. /// @@ -160,7 +162,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.copy` permission. + /// Copy asset + /// + /// Copy asset information like albums, tags, etc. from one asset to another. /// /// Parameters: /// @@ -172,7 +176,9 @@ class AssetsApi { } } - /// This endpoint requires the `asset.update` permission. + /// Delete asset metadata by key + /// + /// Delete a specific metadata key-value pair associated with the specified asset. /// /// Note: This method returns the HTTP [Response]. /// @@ -208,7 +214,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.update` permission. + /// Delete asset metadata by key + /// + /// Delete a specific metadata key-value pair associated with the specified asset. /// /// Parameters: /// @@ -222,7 +230,9 @@ class AssetsApi { } } - /// This endpoint requires the `asset.delete` permission. + /// Delete assets + /// + /// Deletes multiple assets at the same time. /// /// Note: This method returns the HTTP [Response]. /// @@ -254,7 +264,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.delete` permission. + /// Delete assets + /// + /// Deletes multiple assets at the same time. /// /// Parameters: /// @@ -266,7 +278,9 @@ class AssetsApi { } } - /// This endpoint requires the `asset.download` permission. + /// Download original asset + /// + /// Downloads the original file of the specified asset. /// /// Note: This method returns the HTTP [Response]. /// @@ -310,7 +324,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.download` permission. + /// Download original asset + /// + /// Downloads the original file of the specified asset. /// /// Parameters: /// @@ -334,7 +350,7 @@ class AssetsApi { return null; } - /// getAllUserAssetsByDeviceId + /// Retrieve assets by device ID /// /// Get all asset of a device that are in the database, ID only. /// @@ -369,7 +385,7 @@ class AssetsApi { ); } - /// getAllUserAssetsByDeviceId + /// Retrieve assets by device ID /// /// Get all asset of a device that are in the database, ID only. /// @@ -394,7 +410,9 @@ class AssetsApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Retrieve an asset + /// + /// Retrieve detailed information about a specific asset. /// /// Note: This method returns the HTTP [Response]. /// @@ -438,7 +456,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Retrieve an asset + /// + /// Retrieve detailed information about a specific asset. /// /// Parameters: /// @@ -462,7 +482,9 @@ class AssetsApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Get asset metadata + /// + /// Retrieve all metadata key-value pairs associated with the specified asset. /// /// Note: This method returns the HTTP [Response]. /// @@ -495,7 +517,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Get asset metadata + /// + /// Retrieve all metadata key-value pairs associated with the specified asset. /// /// Parameters: /// @@ -518,7 +542,9 @@ class AssetsApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Retrieve asset metadata by key + /// + /// Retrieve the value of a specific metadata key associated with the specified asset. /// /// Note: This method returns the HTTP [Response]. /// @@ -554,7 +580,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Retrieve asset metadata by key + /// + /// Retrieve the value of a specific metadata key associated with the specified asset. /// /// Parameters: /// @@ -576,7 +604,9 @@ class AssetsApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Retrieve asset OCR data + /// + /// Retrieve all OCR (Optical Character Recognition) data associated with the specified asset. /// /// Note: This method returns the HTTP [Response]. /// @@ -609,7 +639,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Retrieve asset OCR data + /// + /// Retrieve all OCR (Optical Character Recognition) data associated with the specified asset. /// /// Parameters: /// @@ -632,7 +664,9 @@ class AssetsApi { return null; } - /// This endpoint requires the `asset.statistics` permission. + /// Get asset statistics + /// + /// Retrieve various statistics about the assets owned by the authenticated user. /// /// Note: This method returns the HTTP [Response]. /// @@ -678,7 +712,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.statistics` permission. + /// Get asset statistics + /// + /// Retrieve various statistics about the assets owned by the authenticated user. /// /// Parameters: /// @@ -702,7 +738,9 @@ class AssetsApi { return null; } - /// This property was deprecated in v1.116.0. This endpoint requires the `asset.read` permission. + /// Get random assets + /// + /// Retrieve a specified number of random assets for the authenticated user. /// /// Note: This method returns the HTTP [Response]. /// @@ -738,7 +776,9 @@ class AssetsApi { ); } - /// This property was deprecated in v1.116.0. This endpoint requires the `asset.read` permission. + /// Get random assets + /// + /// Retrieve a specified number of random assets for the authenticated user. /// /// Parameters: /// @@ -761,7 +801,9 @@ class AssetsApi { return null; } - /// This endpoint requires the `asset.view` permission. + /// Play asset video + /// + /// Streams the video file for the specified asset. This endpoint also supports byte range requests. /// /// Note: This method returns the HTTP [Response]. /// @@ -805,7 +847,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.view` permission. + /// Play asset video + /// + /// Streams the video file for the specified asset. This endpoint also supports byte range requests. /// /// Parameters: /// @@ -829,9 +873,9 @@ class AssetsApi { return null; } - /// Replace the asset with new file, without changing its id + /// Replace asset /// - /// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission. + /// Replace the asset with new file, without changing its id. /// /// Note: This method returns the HTTP [Response]. /// @@ -923,9 +967,9 @@ class AssetsApi { ); } - /// Replace the asset with new file, without changing its id + /// Replace asset /// - /// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission. + /// Replace the asset with new file, without changing its id. /// /// Parameters: /// @@ -963,7 +1007,12 @@ class AssetsApi { return null; } - /// Performs an HTTP 'POST /assets/jobs' operation and returns the [Response]. + /// Run an asset job + /// + /// Run a specific job on a set of assets. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [AssetJobsDto] assetJobsDto (required): @@ -992,6 +1041,10 @@ class AssetsApi { ); } + /// Run an asset job + /// + /// Run a specific job on a set of assets. + /// /// Parameters: /// /// * [AssetJobsDto] assetJobsDto (required): @@ -1002,7 +1055,9 @@ class AssetsApi { } } - /// This endpoint requires the `asset.update` permission. + /// Update an asset + /// + /// Update information of a specific asset. /// /// Note: This method returns the HTTP [Response]. /// @@ -1037,7 +1092,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.update` permission. + /// Update an asset + /// + /// Update information of a specific asset. /// /// Parameters: /// @@ -1059,7 +1116,9 @@ class AssetsApi { return null; } - /// This endpoint requires the `asset.update` permission. + /// Update asset metadata + /// + /// Update or add metadata key-value pairs for the specified asset. /// /// Note: This method returns the HTTP [Response]. /// @@ -1094,7 +1153,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.update` permission. + /// Update asset metadata + /// + /// Update or add metadata key-value pairs for the specified asset. /// /// Parameters: /// @@ -1119,7 +1180,9 @@ class AssetsApi { return null; } - /// This endpoint requires the `asset.update` permission. + /// Update assets + /// + /// Updates multiple assets at the same time. /// /// Note: This method returns the HTTP [Response]. /// @@ -1151,7 +1214,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.update` permission. + /// Update assets + /// + /// Updates multiple assets at the same time. /// /// Parameters: /// @@ -1163,7 +1228,9 @@ class AssetsApi { } } - /// This endpoint requires the `asset.upload` permission. + /// Upload asset + /// + /// Uploads a new asset to the server. /// /// Note: This method returns the HTTP [Response]. /// @@ -1290,7 +1357,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.upload` permission. + /// Upload asset + /// + /// Uploads a new asset to the server. /// /// Parameters: /// @@ -1339,7 +1408,9 @@ class AssetsApi { return null; } - /// This endpoint requires the `asset.view` permission. + /// View asset thumbnail + /// + /// Retrieve the thumbnail image for the specified asset. /// /// Note: This method returns the HTTP [Response]. /// @@ -1388,7 +1459,9 @@ class AssetsApi { ); } - /// This endpoint requires the `asset.view` permission. + /// View asset thumbnail + /// + /// Retrieve the thumbnail image for the specified asset. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/auth_admin_api.dart b/mobile/openapi/lib/api/authentication_admin_api.dart similarity index 77% rename from mobile/openapi/lib/api/auth_admin_api.dart rename to mobile/openapi/lib/api/authentication_admin_api.dart index d22b449aab..0a4b91ebc3 100644 --- a/mobile/openapi/lib/api/auth_admin_api.dart +++ b/mobile/openapi/lib/api/authentication_admin_api.dart @@ -11,12 +11,14 @@ part of openapi.api; -class AuthAdminApi { - AuthAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; +class AuthenticationAdminApi { + AuthenticationAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; final ApiClient apiClient; - /// This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission. + /// Unlink all OAuth accounts + /// + /// Unlinks all OAuth accounts associated with user accounts in the system. /// /// Note: This method returns the HTTP [Response]. Future unlinkAllOAuthAccountsAdminWithHttpInfo() async { @@ -44,7 +46,9 @@ class AuthAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission. + /// Unlink all OAuth accounts + /// + /// Unlinks all OAuth accounts associated with user accounts in the system. Future unlinkAllOAuthAccountsAdmin() async { final response = await unlinkAllOAuthAccountsAdminWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index a74af33a43..52d46a525b 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -16,7 +16,9 @@ class AuthenticationApi { final ApiClient apiClient; - /// This endpoint requires the `auth.changePassword` permission. + /// Change password + /// + /// Change the password of the current user. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class AuthenticationApi { ); } - /// This endpoint requires the `auth.changePassword` permission. + /// Change password + /// + /// Change the password of the current user. /// /// Parameters: /// @@ -68,7 +72,9 @@ class AuthenticationApi { return null; } - /// This endpoint requires the `pinCode.update` permission. + /// Change pin code + /// + /// Change the pin code for the current user. /// /// Note: This method returns the HTTP [Response]. /// @@ -100,7 +106,9 @@ class AuthenticationApi { ); } - /// This endpoint requires the `pinCode.update` permission. + /// Change pin code + /// + /// Change the pin code for the current user. /// /// Parameters: /// @@ -112,7 +120,67 @@ class AuthenticationApi { } } - /// Performs an HTTP 'GET /auth/status' operation and returns the [Response]. + /// Finish OAuth + /// + /// Complete the OAuth authorization process by exchanging the authorization code for a session token. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [OAuthCallbackDto] oAuthCallbackDto (required): + Future finishOAuthWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/oauth/callback'; + + // ignore: prefer_final_locals + Object? postBody = oAuthCallbackDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Finish OAuth + /// + /// Complete the OAuth authorization process by exchanging the authorization code for a session token. + /// + /// Parameters: + /// + /// * [OAuthCallbackDto] oAuthCallbackDto (required): + Future finishOAuth(OAuthCallbackDto oAuthCallbackDto,) async { + final response = await finishOAuthWithHttpInfo(oAuthCallbackDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LoginResponseDto',) as LoginResponseDto; + + } + return null; + } + + /// Retrieve auth status + /// + /// Get information about the current session, including whether the user has a password, and if the session can access locked assets. + /// + /// Note: This method returns the HTTP [Response]. Future getAuthStatusWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/auth/status'; @@ -138,6 +206,9 @@ class AuthenticationApi { ); } + /// Retrieve auth status + /// + /// Get information about the current session, including whether the user has a password, and if the session can access locked assets. Future getAuthStatus() async { final response = await getAuthStatusWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -153,7 +224,67 @@ class AuthenticationApi { return null; } - /// Performs an HTTP 'POST /auth/session/lock' operation and returns the [Response]. + /// Link OAuth account + /// + /// Link an OAuth account to the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [OAuthCallbackDto] oAuthCallbackDto (required): + Future linkOAuthAccountWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/oauth/link'; + + // ignore: prefer_final_locals + Object? postBody = oAuthCallbackDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Link OAuth account + /// + /// Link an OAuth account to the authenticated user. + /// + /// Parameters: + /// + /// * [OAuthCallbackDto] oAuthCallbackDto (required): + Future linkOAuthAccount(OAuthCallbackDto oAuthCallbackDto,) async { + final response = await linkOAuthAccountWithHttpInfo(oAuthCallbackDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; + + } + return null; + } + + /// Lock auth session + /// + /// Remove elevated access to locked assets from the current session. + /// + /// Note: This method returns the HTTP [Response]. Future lockAuthSessionWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/auth/session/lock'; @@ -179,6 +310,9 @@ class AuthenticationApi { ); } + /// Lock auth session + /// + /// Remove elevated access to locked assets from the current session. Future lockAuthSession() async { final response = await lockAuthSessionWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -186,7 +320,12 @@ class AuthenticationApi { } } - /// Performs an HTTP 'POST /auth/login' operation and returns the [Response]. + /// Login + /// + /// Login with username and password and receive a session token. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [LoginCredentialDto] loginCredentialDto (required): @@ -215,6 +354,10 @@ class AuthenticationApi { ); } + /// Login + /// + /// Login with username and password and receive a session token. + /// /// Parameters: /// /// * [LoginCredentialDto] loginCredentialDto (required): @@ -233,7 +376,11 @@ class AuthenticationApi { return null; } - /// Performs an HTTP 'POST /auth/logout' operation and returns the [Response]. + /// Logout + /// + /// Logout the current user and invalidate the session token. + /// + /// Note: This method returns the HTTP [Response]. Future logoutWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/auth/logout'; @@ -259,6 +406,9 @@ class AuthenticationApi { ); } + /// Logout + /// + /// Logout the current user and invalidate the session token. Future logout() async { final response = await logoutWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -274,7 +424,49 @@ class AuthenticationApi { return null; } - /// This endpoint requires the `pinCode.delete` permission. + /// Redirect OAuth to mobile + /// + /// Requests to this URL are automatically forwarded to the mobile app, and is used in some cases for OAuth redirecting. + /// + /// Note: This method returns the HTTP [Response]. + Future redirectOAuthToMobileWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/oauth/mobile-redirect'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Redirect OAuth to mobile + /// + /// Requests to this URL are automatically forwarded to the mobile app, and is used in some cases for OAuth redirecting. + Future redirectOAuthToMobile() async { + final response = await redirectOAuthToMobileWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Reset pin code + /// + /// Reset the pin code for the current user by providing the account password /// /// Note: This method returns the HTTP [Response]. /// @@ -306,7 +498,9 @@ class AuthenticationApi { ); } - /// This endpoint requires the `pinCode.delete` permission. + /// Reset pin code + /// + /// Reset the pin code for the current user by providing the account password /// /// Parameters: /// @@ -318,7 +512,9 @@ class AuthenticationApi { } } - /// This endpoint requires the `pinCode.create` permission. + /// Setup pin code + /// + /// Setup a new pin code for the current user. /// /// Note: This method returns the HTTP [Response]. /// @@ -350,7 +546,9 @@ class AuthenticationApi { ); } - /// This endpoint requires the `pinCode.create` permission. + /// Setup pin code + /// + /// Setup a new pin code for the current user. /// /// Parameters: /// @@ -362,7 +560,12 @@ class AuthenticationApi { } } - /// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response]. + /// Register admin + /// + /// Create the first admin user in the system. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [SignUpDto] signUpDto (required): @@ -391,6 +594,10 @@ class AuthenticationApi { ); } + /// Register admin + /// + /// Create the first admin user in the system. + /// /// Parameters: /// /// * [SignUpDto] signUpDto (required): @@ -409,7 +616,116 @@ class AuthenticationApi { return null; } - /// Performs an HTTP 'POST /auth/session/unlock' operation and returns the [Response]. + /// Start OAuth + /// + /// Initiate the OAuth authorization process. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [OAuthConfigDto] oAuthConfigDto (required): + Future startOAuthWithHttpInfo(OAuthConfigDto oAuthConfigDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/oauth/authorize'; + + // ignore: prefer_final_locals + Object? postBody = oAuthConfigDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Start OAuth + /// + /// Initiate the OAuth authorization process. + /// + /// Parameters: + /// + /// * [OAuthConfigDto] oAuthConfigDto (required): + Future startOAuth(OAuthConfigDto oAuthConfigDto,) async { + final response = await startOAuthWithHttpInfo(oAuthConfigDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'OAuthAuthorizeResponseDto',) as OAuthAuthorizeResponseDto; + + } + return null; + } + + /// Unlink OAuth account + /// + /// Unlink the OAuth account from the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + Future unlinkOAuthAccountWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/oauth/unlink'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Unlink OAuth account + /// + /// Unlink the OAuth account from the authenticated user. + Future unlinkOAuthAccount() async { + final response = await unlinkOAuthAccountWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; + + } + return null; + } + + /// Unlock auth session + /// + /// Temporarily grant the session elevated access to locked assets by providing the correct PIN code. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [SessionUnlockDto] sessionUnlockDto (required): @@ -438,6 +754,10 @@ class AuthenticationApi { ); } + /// Unlock auth session + /// + /// Temporarily grant the session elevated access to locked assets by providing the correct PIN code. + /// /// Parameters: /// /// * [SessionUnlockDto] sessionUnlockDto (required): @@ -448,7 +768,11 @@ class AuthenticationApi { } } - /// Performs an HTTP 'POST /auth/validateToken' operation and returns the [Response]. + /// Validate access token + /// + /// Validate the current authorization method is still valid. + /// + /// Note: This method returns the HTTP [Response]. Future validateAccessTokenWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/auth/validateToken'; @@ -474,6 +798,9 @@ class AuthenticationApi { ); } + /// Validate access token + /// + /// Validate the current authorization method is still valid. Future validateAccessToken() async { final response = await validateAccessTokenWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index 9246998ca2..aaf7c074b9 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -16,7 +16,9 @@ class DeprecatedApi { final ApiClient apiClient; - /// This property was deprecated in v1.141.0. This endpoint requires the `partner.create` permission. + /// Create a partner + /// + /// Create a new partner to share assets with. /// /// Note: This method returns the HTTP [Response]. /// @@ -49,7 +51,9 @@ class DeprecatedApi { ); } - /// This property was deprecated in v1.141.0. This endpoint requires the `partner.create` permission. + /// Create a partner + /// + /// Create a new partner to share assets with. /// /// Parameters: /// @@ -69,7 +73,184 @@ class DeprecatedApi { return null; } - /// This property was deprecated in v1.116.0. This endpoint requires the `asset.read` permission. + /// Retrieve assets by device ID + /// + /// Get all asset of a device that are in the database, ID only. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] deviceId (required): + Future getAllUserAssetsByDeviceIdWithHttpInfo(String deviceId,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/device/{deviceId}' + .replaceAll('{deviceId}', deviceId); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve assets by device ID + /// + /// Get all asset of a device that are in the database, ID only. + /// + /// Parameters: + /// + /// * [String] deviceId (required): + Future?> getAllUserAssetsByDeviceId(String deviceId,) async { + final response = await getAllUserAssetsByDeviceIdWithHttpInfo(deviceId,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Get delta sync for user + /// + /// Retrieve changed assets since the last sync for the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetDeltaSyncDto] assetDeltaSyncDto (required): + Future getDeltaSyncWithHttpInfo(AssetDeltaSyncDto assetDeltaSyncDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sync/delta-sync'; + + // ignore: prefer_final_locals + Object? postBody = assetDeltaSyncDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Get delta sync for user + /// + /// Retrieve changed assets since the last sync for the authenticated user. + /// + /// Parameters: + /// + /// * [AssetDeltaSyncDto] assetDeltaSyncDto (required): + Future getDeltaSync(AssetDeltaSyncDto assetDeltaSyncDto,) async { + final response = await getDeltaSyncWithHttpInfo(assetDeltaSyncDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetDeltaSyncResponseDto',) as AssetDeltaSyncResponseDto; + + } + return null; + } + + /// Get full sync for user + /// + /// Retrieve all assets for a full synchronization for the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetFullSyncDto] assetFullSyncDto (required): + Future getFullSyncForUserWithHttpInfo(AssetFullSyncDto assetFullSyncDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sync/full-sync'; + + // ignore: prefer_final_locals + Object? postBody = assetFullSyncDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Get full sync for user + /// + /// Retrieve all assets for a full synchronization for the authenticated user. + /// + /// Parameters: + /// + /// * [AssetFullSyncDto] assetFullSyncDto (required): + Future?> getFullSyncForUser(AssetFullSyncDto assetFullSyncDto,) async { + final response = await getFullSyncForUserWithHttpInfo(assetFullSyncDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Get random assets + /// + /// Retrieve a specified number of random assets for the authenticated user. /// /// Note: This method returns the HTTP [Response]. /// @@ -105,7 +286,9 @@ class DeprecatedApi { ); } - /// This property was deprecated in v1.116.0. This endpoint requires the `asset.read` permission. + /// Get random assets + /// + /// Retrieve a specified number of random assets for the authenticated user. /// /// Parameters: /// @@ -128,9 +311,9 @@ class DeprecatedApi { return null; } - /// Replace the asset with new file, without changing its id + /// Replace asset /// - /// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission. + /// Replace the asset with new file, without changing its id. /// /// Note: This method returns the HTTP [Response]. /// @@ -222,9 +405,9 @@ class DeprecatedApi { ); } - /// Replace the asset with new file, without changing its id + /// Replace asset /// - /// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission. + /// Replace the asset with new file, without changing its id. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/download_api.dart b/mobile/openapi/lib/api/download_api.dart index 62c97bfc9c..5245622753 100644 --- a/mobile/openapi/lib/api/download_api.dart +++ b/mobile/openapi/lib/api/download_api.dart @@ -16,7 +16,9 @@ class DownloadApi { final ApiClient apiClient; - /// This endpoint requires the `asset.download` permission. + /// Download asset archive + /// + /// Download a ZIP archive containing the specified assets. The assets must have been previously requested via the \"getDownloadInfo\" endpoint. /// /// Note: This method returns the HTTP [Response]. /// @@ -59,7 +61,9 @@ class DownloadApi { ); } - /// This endpoint requires the `asset.download` permission. + /// Download asset archive + /// + /// Download a ZIP archive containing the specified assets. The assets must have been previously requested via the \"getDownloadInfo\" endpoint. /// /// Parameters: /// @@ -83,7 +87,9 @@ class DownloadApi { return null; } - /// This endpoint requires the `asset.download` permission. + /// Retrieve download information + /// + /// Retrieve information about how to request a download for the specified assets or album. The response includes groups of assets that can be downloaded together. /// /// Note: This method returns the HTTP [Response]. /// @@ -126,7 +132,9 @@ class DownloadApi { ); } - /// This endpoint requires the `asset.download` permission. + /// Retrieve download information + /// + /// Retrieve information about how to request a download for the specified assets or album. The response includes groups of assets that can be downloaded together. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/duplicates_api.dart b/mobile/openapi/lib/api/duplicates_api.dart index 9df6e46586..7fa7b368b5 100644 --- a/mobile/openapi/lib/api/duplicates_api.dart +++ b/mobile/openapi/lib/api/duplicates_api.dart @@ -16,7 +16,9 @@ class DuplicatesApi { final ApiClient apiClient; - /// This endpoint requires the `duplicate.delete` permission. + /// Delete a duplicate + /// + /// Delete a single duplicate asset specified by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -49,7 +51,9 @@ class DuplicatesApi { ); } - /// This endpoint requires the `duplicate.delete` permission. + /// Delete a duplicate + /// + /// Delete a single duplicate asset specified by its ID. /// /// Parameters: /// @@ -61,7 +65,9 @@ class DuplicatesApi { } } - /// This endpoint requires the `duplicate.delete` permission. + /// Delete duplicates + /// + /// Delete multiple duplicate assets specified by their IDs. /// /// Note: This method returns the HTTP [Response]. /// @@ -93,7 +99,9 @@ class DuplicatesApi { ); } - /// This endpoint requires the `duplicate.delete` permission. + /// Delete duplicates + /// + /// Delete multiple duplicate assets specified by their IDs. /// /// Parameters: /// @@ -105,7 +113,9 @@ class DuplicatesApi { } } - /// This endpoint requires the `duplicate.read` permission. + /// Retrieve duplicates + /// + /// Retrieve a list of duplicate assets available to the authenticated user. /// /// Note: This method returns the HTTP [Response]. Future getAssetDuplicatesWithHttpInfo() async { @@ -133,7 +143,9 @@ class DuplicatesApi { ); } - /// This endpoint requires the `duplicate.read` permission. + /// Retrieve duplicates + /// + /// Retrieve a list of duplicate assets available to the authenticated user. Future?> getAssetDuplicates() async { final response = await getAssetDuplicatesWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { diff --git a/mobile/openapi/lib/api/faces_api.dart b/mobile/openapi/lib/api/faces_api.dart index 2f8e6be60d..1d2e7401e8 100644 --- a/mobile/openapi/lib/api/faces_api.dart +++ b/mobile/openapi/lib/api/faces_api.dart @@ -16,7 +16,9 @@ class FacesApi { final ApiClient apiClient; - /// This endpoint requires the `face.create` permission. + /// Create a face + /// + /// Create a new face that has not been discovered by facial recognition. The content of the bounding box is considered a face. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class FacesApi { ); } - /// This endpoint requires the `face.create` permission. + /// Create a face + /// + /// Create a new face that has not been discovered by facial recognition. The content of the bounding box is considered a face. /// /// Parameters: /// @@ -60,7 +64,9 @@ class FacesApi { } } - /// This endpoint requires the `face.delete` permission. + /// Delete a face + /// + /// Delete a face identified by the id. Optionally can be force deleted. /// /// Note: This method returns the HTTP [Response]. /// @@ -95,7 +101,9 @@ class FacesApi { ); } - /// This endpoint requires the `face.delete` permission. + /// Delete a face + /// + /// Delete a face identified by the id. Optionally can be force deleted. /// /// Parameters: /// @@ -109,7 +117,9 @@ class FacesApi { } } - /// This endpoint requires the `face.read` permission. + /// Retrieve faces for asset + /// + /// Retrieve all faces belonging to an asset. /// /// Note: This method returns the HTTP [Response]. /// @@ -143,7 +153,9 @@ class FacesApi { ); } - /// This endpoint requires the `face.read` permission. + /// Retrieve faces for asset + /// + /// Retrieve all faces belonging to an asset. /// /// Parameters: /// @@ -166,7 +178,9 @@ class FacesApi { return null; } - /// This endpoint requires the `face.update` permission. + /// Re-assign a face to another person + /// + /// Re-assign the face provided in the body to the person identified by the id in the path parameter. /// /// Note: This method returns the HTTP [Response]. /// @@ -201,7 +215,9 @@ class FacesApi { ); } - /// This endpoint requires the `face.update` permission. + /// Re-assign a face to another person + /// + /// Re-assign the face provided in the body to the person identified by the id in the path parameter. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 4c935828a0..906dce6d37 100644 --- a/mobile/openapi/lib/api/jobs_api.dart +++ b/mobile/openapi/lib/api/jobs_api.dart @@ -16,7 +16,9 @@ class JobsApi { final ApiClient apiClient; - /// This endpoint is an admin-only route, and requires the `job.create` permission. + /// Create a manual job + /// + /// Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class JobsApi { ); } - /// This endpoint is an admin-only route, and requires the `job.create` permission. + /// Create a manual job + /// + /// Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup. /// /// Parameters: /// @@ -60,10 +64,12 @@ class JobsApi { } } - /// This endpoint is an admin-only route, and requires the `job.read` permission. + /// Retrieve queue counts and status + /// + /// Retrieve the counts of the current queue, as well as the current status. /// /// Note: This method returns the HTTP [Response]. - Future getAllJobsStatusWithHttpInfo() async { + Future getQueuesLegacyWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/jobs'; @@ -88,9 +94,11 @@ class JobsApi { ); } - /// This endpoint is an admin-only route, and requires the `job.read` permission. - Future getAllJobsStatus() async { - final response = await getAllJobsStatusWithHttpInfo(); + /// Retrieve queue counts and status + /// + /// Retrieve the counts of the current queue, as well as the current status. + Future getQueuesLegacy() async { + final response = await getQueuesLegacyWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -98,28 +106,30 @@ class JobsApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AllJobStatusResponseDto',) as AllJobStatusResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseDto',) as QueuesResponseDto; } return null; } - /// This endpoint is an admin-only route, and requires the `job.create` permission. + /// Run jobs + /// + /// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets. /// /// Note: This method returns the HTTP [Response]. /// /// Parameters: /// - /// * [JobName] id (required): + /// * [QueueName] name (required): /// - /// * [JobCommandDto] jobCommandDto (required): - Future sendJobCommandWithHttpInfo(JobName id, JobCommandDto jobCommandDto,) async { + /// * [QueueCommandDto] queueCommandDto (required): + Future runQueueCommandLegacyWithHttpInfo(QueueName name, QueueCommandDto queueCommandDto,) async { // ignore: prefer_const_declarations - final apiPath = r'/jobs/{id}' - .replaceAll('{id}', id.toString()); + final apiPath = r'/jobs/{name}' + .replaceAll('{name}', name.toString()); // ignore: prefer_final_locals - Object? postBody = jobCommandDto; + Object? postBody = queueCommandDto; final queryParams = []; final headerParams = {}; @@ -139,15 +149,17 @@ class JobsApi { ); } - /// This endpoint is an admin-only route, and requires the `job.create` permission. + /// Run jobs + /// + /// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets. /// /// Parameters: /// - /// * [JobName] id (required): + /// * [QueueName] name (required): /// - /// * [JobCommandDto] jobCommandDto (required): - Future sendJobCommand(JobName id, JobCommandDto jobCommandDto,) async { - final response = await sendJobCommandWithHttpInfo(id, jobCommandDto,); + /// * [QueueCommandDto] queueCommandDto (required): + Future runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async { + final response = await runQueueCommandLegacyWithHttpInfo(name, queueCommandDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -155,7 +167,7 @@ class JobsApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'JobStatusDto',) as JobStatusDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto; } return null; diff --git a/mobile/openapi/lib/api/libraries_api.dart b/mobile/openapi/lib/api/libraries_api.dart index 9258f8e3eb..ca59f823fe 100644 --- a/mobile/openapi/lib/api/libraries_api.dart +++ b/mobile/openapi/lib/api/libraries_api.dart @@ -16,7 +16,9 @@ class LibrariesApi { final ApiClient apiClient; - /// This endpoint is an admin-only route, and requires the `library.create` permission. + /// Create a library + /// + /// Create a new external library. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class LibrariesApi { ); } - /// This endpoint is an admin-only route, and requires the `library.create` permission. + /// Create a library + /// + /// Create a new external library. /// /// Parameters: /// @@ -68,7 +72,9 @@ class LibrariesApi { return null; } - /// This endpoint is an admin-only route, and requires the `library.delete` permission. + /// Delete a library + /// + /// Delete an external library by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -101,7 +107,9 @@ class LibrariesApi { ); } - /// This endpoint is an admin-only route, and requires the `library.delete` permission. + /// Delete a library + /// + /// Delete an external library by its ID. /// /// Parameters: /// @@ -113,7 +121,9 @@ class LibrariesApi { } } - /// This endpoint is an admin-only route, and requires the `library.read` permission. + /// Retrieve libraries + /// + /// Retrieve a list of external libraries. /// /// Note: This method returns the HTTP [Response]. Future getAllLibrariesWithHttpInfo() async { @@ -141,7 +151,9 @@ class LibrariesApi { ); } - /// This endpoint is an admin-only route, and requires the `library.read` permission. + /// Retrieve libraries + /// + /// Retrieve a list of external libraries. Future?> getAllLibraries() async { final response = await getAllLibrariesWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -160,7 +172,9 @@ class LibrariesApi { return null; } - /// This endpoint is an admin-only route, and requires the `library.read` permission. + /// Retrieve a library + /// + /// Retrieve an external library by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -193,7 +207,9 @@ class LibrariesApi { ); } - /// This endpoint is an admin-only route, and requires the `library.read` permission. + /// Retrieve a library + /// + /// Retrieve an external library by its ID. /// /// Parameters: /// @@ -213,7 +229,9 @@ class LibrariesApi { return null; } - /// This endpoint is an admin-only route, and requires the `library.statistics` permission. + /// Retrieve library statistics + /// + /// Retrieve statistics for a specific external library, including number of videos, images, and storage usage. /// /// Note: This method returns the HTTP [Response]. /// @@ -246,7 +264,9 @@ class LibrariesApi { ); } - /// This endpoint is an admin-only route, and requires the `library.statistics` permission. + /// Retrieve library statistics + /// + /// Retrieve statistics for a specific external library, including number of videos, images, and storage usage. /// /// Parameters: /// @@ -266,7 +286,9 @@ class LibrariesApi { return null; } - /// This endpoint is an admin-only route, and requires the `library.update` permission. + /// Scan a library + /// + /// Queue a scan for the external library to find and import new assets. /// /// Note: This method returns the HTTP [Response]. /// @@ -299,7 +321,9 @@ class LibrariesApi { ); } - /// This endpoint is an admin-only route, and requires the `library.update` permission. + /// Scan a library + /// + /// Queue a scan for the external library to find and import new assets. /// /// Parameters: /// @@ -311,7 +335,9 @@ class LibrariesApi { } } - /// This endpoint is an admin-only route, and requires the `library.update` permission. + /// Update a library + /// + /// Update an existing external library. /// /// Note: This method returns the HTTP [Response]. /// @@ -346,7 +372,9 @@ class LibrariesApi { ); } - /// This endpoint is an admin-only route, and requires the `library.update` permission. + /// Update a library + /// + /// Update an existing external library. /// /// Parameters: /// @@ -368,7 +396,12 @@ class LibrariesApi { return null; } - /// Performs an HTTP 'POST /libraries/{id}/validate' operation and returns the [Response]. + /// Validate library settings + /// + /// Validate the settings of an external library. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [String] id (required): @@ -400,6 +433,10 @@ class LibrariesApi { ); } + /// Validate library settings + /// + /// Validate the settings of an external library. + /// /// Parameters: /// /// * [String] id (required): diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart index da4f3dffcc..6302ac304e 100644 --- a/mobile/openapi/lib/api/map_api.dart +++ b/mobile/openapi/lib/api/map_api.dart @@ -16,21 +16,26 @@ class MapApi { final ApiClient apiClient; - /// Performs an HTTP 'GET /map/markers' operation and returns the [Response]. + /// Retrieve map markers + /// + /// Retrieve a list of latitude and longitude coordinates for every asset with location data. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// - /// * [bool] isArchived: - /// - /// * [bool] isFavorite: - /// /// * [DateTime] fileCreatedAfter: /// /// * [DateTime] fileCreatedBefore: /// + /// * [bool] isArchived: + /// + /// * [bool] isFavorite: + /// /// * [bool] withPartners: /// /// * [bool] withSharedAlbums: - Future getMapMarkersWithHttpInfo({ bool? isArchived, bool? isFavorite, DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? withPartners, bool? withSharedAlbums, }) async { + Future getMapMarkersWithHttpInfo({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, bool? withSharedAlbums, }) async { // ignore: prefer_const_declarations final apiPath = r'/map/markers'; @@ -41,18 +46,18 @@ class MapApi { final headerParams = {}; final formParams = {}; - if (isArchived != null) { - queryParams.addAll(_queryParams('', 'isArchived', isArchived)); - } - if (isFavorite != null) { - queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); - } if (fileCreatedAfter != null) { queryParams.addAll(_queryParams('', 'fileCreatedAfter', fileCreatedAfter)); } if (fileCreatedBefore != null) { queryParams.addAll(_queryParams('', 'fileCreatedBefore', fileCreatedBefore)); } + if (isArchived != null) { + queryParams.addAll(_queryParams('', 'isArchived', isArchived)); + } + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } if (withPartners != null) { queryParams.addAll(_queryParams('', 'withPartners', withPartners)); } @@ -74,21 +79,25 @@ class MapApi { ); } + /// Retrieve map markers + /// + /// Retrieve a list of latitude and longitude coordinates for every asset with location data. + /// /// Parameters: /// - /// * [bool] isArchived: - /// - /// * [bool] isFavorite: - /// /// * [DateTime] fileCreatedAfter: /// /// * [DateTime] fileCreatedBefore: /// + /// * [bool] isArchived: + /// + /// * [bool] isFavorite: + /// /// * [bool] withPartners: /// /// * [bool] withSharedAlbums: - Future?> getMapMarkers({ bool? isArchived, bool? isFavorite, DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? withPartners, bool? withSharedAlbums, }) async { - final response = await getMapMarkersWithHttpInfo( isArchived: isArchived, isFavorite: isFavorite, fileCreatedAfter: fileCreatedAfter, fileCreatedBefore: fileCreatedBefore, withPartners: withPartners, withSharedAlbums: withSharedAlbums, ); + Future?> getMapMarkers({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, bool? withSharedAlbums, }) async { + final response = await getMapMarkersWithHttpInfo( fileCreatedAfter: fileCreatedAfter, fileCreatedBefore: fileCreatedBefore, isArchived: isArchived, isFavorite: isFavorite, withPartners: withPartners, withSharedAlbums: withSharedAlbums, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -105,7 +114,12 @@ class MapApi { return null; } - /// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response]. + /// Reverse geocode coordinates + /// + /// Retrieve location information (e.g., city, country) for given latitude and longitude coordinates. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [double] lat (required): @@ -139,6 +153,10 @@ class MapApi { ); } + /// Reverse geocode coordinates + /// + /// Retrieve location information (e.g., city, country) for given latitude and longitude coordinates. + /// /// Parameters: /// /// * [double] lat (required): diff --git a/mobile/openapi/lib/api/memories_api.dart b/mobile/openapi/lib/api/memories_api.dart index f9280101e6..314595e84e 100644 --- a/mobile/openapi/lib/api/memories_api.dart +++ b/mobile/openapi/lib/api/memories_api.dart @@ -16,7 +16,9 @@ class MemoriesApi { final ApiClient apiClient; - /// This endpoint requires the `memoryAsset.create` permission. + /// Add assets to a memory + /// + /// Add a list of asset IDs to a specific memory. /// /// Note: This method returns the HTTP [Response]. /// @@ -51,7 +53,9 @@ class MemoriesApi { ); } - /// This endpoint requires the `memoryAsset.create` permission. + /// Add assets to a memory + /// + /// Add a list of asset IDs to a specific memory. /// /// Parameters: /// @@ -76,7 +80,9 @@ class MemoriesApi { return null; } - /// This endpoint requires the `memory.create` permission. + /// Create a memory + /// + /// Create a new memory by providing a name, description, and a list of asset IDs to include in the memory. /// /// Note: This method returns the HTTP [Response]. /// @@ -108,7 +114,9 @@ class MemoriesApi { ); } - /// This endpoint requires the `memory.create` permission. + /// Create a memory + /// + /// Create a new memory by providing a name, description, and a list of asset IDs to include in the memory. /// /// Parameters: /// @@ -128,7 +136,9 @@ class MemoriesApi { return null; } - /// This endpoint requires the `memory.delete` permission. + /// Delete a memory + /// + /// Delete a specific memory by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -161,7 +171,9 @@ class MemoriesApi { ); } - /// This endpoint requires the `memory.delete` permission. + /// Delete a memory + /// + /// Delete a specific memory by its ID. /// /// Parameters: /// @@ -173,7 +185,9 @@ class MemoriesApi { } } - /// This endpoint requires the `memory.read` permission. + /// Retrieve a memory + /// + /// Retrieve a specific memory by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -206,7 +220,9 @@ class MemoriesApi { ); } - /// This endpoint requires the `memory.read` permission. + /// Retrieve a memory + /// + /// Retrieve a specific memory by its ID. /// /// Parameters: /// @@ -226,7 +242,9 @@ class MemoriesApi { return null; } - /// This endpoint requires the `memory.statistics` permission. + /// Retrieve memories statistics + /// + /// Retrieve statistics about memories, such as total count and other relevant metrics. /// /// Note: This method returns the HTTP [Response]. /// @@ -238,8 +256,13 @@ class MemoriesApi { /// /// * [bool] isTrashed: /// + /// * [MemorySearchOrder] order: + /// + /// * [int] size: + /// Number of memories to return + /// /// * [MemoryType] type: - Future memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { + Future memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { // ignore: prefer_const_declarations final apiPath = r'/memories/statistics'; @@ -259,6 +282,12 @@ class MemoriesApi { if (isTrashed != null) { queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); } + if (order != null) { + queryParams.addAll(_queryParams('', 'order', order)); + } + if (size != null) { + queryParams.addAll(_queryParams('', 'size', size)); + } if (type != null) { queryParams.addAll(_queryParams('', 'type', type)); } @@ -277,7 +306,9 @@ class MemoriesApi { ); } - /// This endpoint requires the `memory.statistics` permission. + /// Retrieve memories statistics + /// + /// Retrieve statistics about memories, such as total count and other relevant metrics. /// /// Parameters: /// @@ -287,9 +318,14 @@ class MemoriesApi { /// /// * [bool] isTrashed: /// + /// * [MemorySearchOrder] order: + /// + /// * [int] size: + /// Number of memories to return + /// /// * [MemoryType] type: - Future memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { - final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, type: type, ); + Future memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { + final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -303,7 +339,9 @@ class MemoriesApi { return null; } - /// This endpoint requires the `memoryAsset.delete` permission. + /// Remove assets from a memory + /// + /// Remove a list of asset IDs from a specific memory. /// /// Note: This method returns the HTTP [Response]. /// @@ -338,7 +376,9 @@ class MemoriesApi { ); } - /// This endpoint requires the `memoryAsset.delete` permission. + /// Remove assets from a memory + /// + /// Remove a list of asset IDs from a specific memory. /// /// Parameters: /// @@ -363,7 +403,9 @@ class MemoriesApi { return null; } - /// This endpoint requires the `memory.read` permission. + /// Retrieve memories + /// + /// Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly. /// /// Note: This method returns the HTTP [Response]. /// @@ -375,8 +417,13 @@ class MemoriesApi { /// /// * [bool] isTrashed: /// + /// * [MemorySearchOrder] order: + /// + /// * [int] size: + /// Number of memories to return + /// /// * [MemoryType] type: - Future searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { + Future searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { // ignore: prefer_const_declarations final apiPath = r'/memories'; @@ -396,6 +443,12 @@ class MemoriesApi { if (isTrashed != null) { queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); } + if (order != null) { + queryParams.addAll(_queryParams('', 'order', order)); + } + if (size != null) { + queryParams.addAll(_queryParams('', 'size', size)); + } if (type != null) { queryParams.addAll(_queryParams('', 'type', type)); } @@ -414,7 +467,9 @@ class MemoriesApi { ); } - /// This endpoint requires the `memory.read` permission. + /// Retrieve memories + /// + /// Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly. /// /// Parameters: /// @@ -424,9 +479,14 @@ class MemoriesApi { /// /// * [bool] isTrashed: /// + /// * [MemorySearchOrder] order: + /// + /// * [int] size: + /// Number of memories to return + /// /// * [MemoryType] type: - Future?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { - final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, type: type, ); + Future?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { + final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -443,7 +503,9 @@ class MemoriesApi { return null; } - /// This endpoint requires the `memory.update` permission. + /// Update a memory + /// + /// Update an existing memory by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -478,7 +540,9 @@ class MemoriesApi { ); } - /// This endpoint requires the `memory.update` permission. + /// Update a memory + /// + /// Update an existing memory by its ID. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/notifications_admin_api.dart b/mobile/openapi/lib/api/notifications_admin_api.dart index 409683a950..7821553d30 100644 --- a/mobile/openapi/lib/api/notifications_admin_api.dart +++ b/mobile/openapi/lib/api/notifications_admin_api.dart @@ -16,7 +16,12 @@ class NotificationsAdminApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /admin/notifications' operation and returns the [Response]. + /// Create a notification + /// + /// Create a new notification for a specific user. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [NotificationCreateDto] notificationCreateDto (required): @@ -45,6 +50,10 @@ class NotificationsAdminApi { ); } + /// Create a notification + /// + /// Create a new notification for a specific user. + /// /// Parameters: /// /// * [NotificationCreateDto] notificationCreateDto (required): @@ -63,7 +72,12 @@ class NotificationsAdminApi { return null; } - /// Performs an HTTP 'POST /admin/notifications/templates/{name}' operation and returns the [Response]. + /// Render email template + /// + /// Retrieve a preview of the provided email template. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [String] name (required): @@ -95,6 +109,10 @@ class NotificationsAdminApi { ); } + /// Render email template + /// + /// Retrieve a preview of the provided email template. + /// /// Parameters: /// /// * [String] name (required): @@ -115,7 +133,12 @@ class NotificationsAdminApi { return null; } - /// Performs an HTTP 'POST /admin/notifications/test-email' operation and returns the [Response]. + /// Send test email + /// + /// Send a test email using the provided SMTP configuration. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): @@ -144,6 +167,10 @@ class NotificationsAdminApi { ); } + /// Send test email + /// + /// Send a test email using the provided SMTP configuration. + /// /// Parameters: /// /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart index 1d276efaaf..2de59a0a76 100644 --- a/mobile/openapi/lib/api/notifications_api.dart +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -16,7 +16,9 @@ class NotificationsApi { final ApiClient apiClient; - /// This endpoint requires the `notification.delete` permission. + /// Delete a notification + /// + /// Delete a specific notification. /// /// Note: This method returns the HTTP [Response]. /// @@ -49,7 +51,9 @@ class NotificationsApi { ); } - /// This endpoint requires the `notification.delete` permission. + /// Delete a notification + /// + /// Delete a specific notification. /// /// Parameters: /// @@ -61,7 +65,9 @@ class NotificationsApi { } } - /// This endpoint requires the `notification.delete` permission. + /// Delete notifications + /// + /// Delete a list of notifications at once. /// /// Note: This method returns the HTTP [Response]. /// @@ -93,7 +99,9 @@ class NotificationsApi { ); } - /// This endpoint requires the `notification.delete` permission. + /// Delete notifications + /// + /// Delete a list of notifications at once. /// /// Parameters: /// @@ -105,7 +113,9 @@ class NotificationsApi { } } - /// This endpoint requires the `notification.read` permission. + /// Get a notification + /// + /// Retrieve a specific notification identified by id. /// /// Note: This method returns the HTTP [Response]. /// @@ -138,7 +148,9 @@ class NotificationsApi { ); } - /// This endpoint requires the `notification.read` permission. + /// Get a notification + /// + /// Retrieve a specific notification identified by id. /// /// Parameters: /// @@ -158,7 +170,9 @@ class NotificationsApi { return null; } - /// This endpoint requires the `notification.read` permission. + /// Retrieve notifications + /// + /// Retrieve a list of notifications. /// /// Note: This method returns the HTTP [Response]. /// @@ -209,7 +223,9 @@ class NotificationsApi { ); } - /// This endpoint requires the `notification.read` permission. + /// Retrieve notifications + /// + /// Retrieve a list of notifications. /// /// Parameters: /// @@ -238,7 +254,9 @@ class NotificationsApi { return null; } - /// This endpoint requires the `notification.update` permission. + /// Update a notification + /// + /// Update a specific notification to set its read status. /// /// Note: This method returns the HTTP [Response]. /// @@ -273,7 +291,9 @@ class NotificationsApi { ); } - /// This endpoint requires the `notification.update` permission. + /// Update a notification + /// + /// Update a specific notification to set its read status. /// /// Parameters: /// @@ -295,7 +315,9 @@ class NotificationsApi { return null; } - /// This endpoint requires the `notification.update` permission. + /// Update notifications + /// + /// Update a list of notifications. Allows to bulk-set the read status of notifications. /// /// Note: This method returns the HTTP [Response]. /// @@ -327,7 +349,9 @@ class NotificationsApi { ); } - /// This endpoint requires the `notification.update` permission. + /// Update notifications + /// + /// Update a list of notifications. Allows to bulk-set the read status of notifications. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/partners_api.dart b/mobile/openapi/lib/api/partners_api.dart index a5fdf53ab5..7d18f6d867 100644 --- a/mobile/openapi/lib/api/partners_api.dart +++ b/mobile/openapi/lib/api/partners_api.dart @@ -16,7 +16,9 @@ class PartnersApi { final ApiClient apiClient; - /// This endpoint requires the `partner.create` permission. + /// Create a partner + /// + /// Create a new partner to share assets with. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class PartnersApi { ); } - /// This endpoint requires the `partner.create` permission. + /// Create a partner + /// + /// Create a new partner to share assets with. /// /// Parameters: /// @@ -68,7 +72,9 @@ class PartnersApi { return null; } - /// This property was deprecated in v1.141.0. This endpoint requires the `partner.create` permission. + /// Create a partner + /// + /// Create a new partner to share assets with. /// /// Note: This method returns the HTTP [Response]. /// @@ -101,7 +107,9 @@ class PartnersApi { ); } - /// This property was deprecated in v1.141.0. This endpoint requires the `partner.create` permission. + /// Create a partner + /// + /// Create a new partner to share assets with. /// /// Parameters: /// @@ -121,7 +129,9 @@ class PartnersApi { return null; } - /// This endpoint requires the `partner.read` permission. + /// Retrieve partners + /// + /// Retrieve a list of partners with whom assets are shared. /// /// Note: This method returns the HTTP [Response]. /// @@ -155,7 +165,9 @@ class PartnersApi { ); } - /// This endpoint requires the `partner.read` permission. + /// Retrieve partners + /// + /// Retrieve a list of partners with whom assets are shared. /// /// Parameters: /// @@ -178,7 +190,9 @@ class PartnersApi { return null; } - /// This endpoint requires the `partner.delete` permission. + /// Remove a partner + /// + /// Stop sharing assets with a partner. /// /// Note: This method returns the HTTP [Response]. /// @@ -211,7 +225,9 @@ class PartnersApi { ); } - /// This endpoint requires the `partner.delete` permission. + /// Remove a partner + /// + /// Stop sharing assets with a partner. /// /// Parameters: /// @@ -223,7 +239,9 @@ class PartnersApi { } } - /// This endpoint requires the `partner.update` permission. + /// Update a partner + /// + /// Specify whether a partner's assets should appear in the user's timeline. /// /// Note: This method returns the HTTP [Response]. /// @@ -258,7 +276,9 @@ class PartnersApi { ); } - /// This endpoint requires the `partner.update` permission. + /// Update a partner + /// + /// Specify whether a partner's assets should appear in the user's timeline. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/people_api.dart b/mobile/openapi/lib/api/people_api.dart index 68c16785cc..c38e61584e 100644 --- a/mobile/openapi/lib/api/people_api.dart +++ b/mobile/openapi/lib/api/people_api.dart @@ -16,7 +16,9 @@ class PeopleApi { final ApiClient apiClient; - /// This endpoint requires the `person.create` permission. + /// Create a person + /// + /// Create a new person that can have multiple faces assigned to them. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.create` permission. + /// Create a person + /// + /// Create a new person that can have multiple faces assigned to them. /// /// Parameters: /// @@ -68,7 +72,9 @@ class PeopleApi { return null; } - /// This endpoint requires the `person.delete` permission. + /// Delete people + /// + /// Bulk delete a list of people at once. /// /// Note: This method returns the HTTP [Response]. /// @@ -100,7 +106,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.delete` permission. + /// Delete people + /// + /// Bulk delete a list of people at once. /// /// Parameters: /// @@ -112,7 +120,9 @@ class PeopleApi { } } - /// This endpoint requires the `person.delete` permission. + /// Delete person + /// + /// Delete an individual person. /// /// Note: This method returns the HTTP [Response]. /// @@ -145,7 +155,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.delete` permission. + /// Delete person + /// + /// Delete an individual person. /// /// Parameters: /// @@ -157,7 +169,9 @@ class PeopleApi { } } - /// This endpoint requires the `person.read` permission. + /// Get all people + /// + /// Retrieve a list of all people. /// /// Note: This method returns the HTTP [Response]. /// @@ -215,7 +229,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.read` permission. + /// Get all people + /// + /// Retrieve a list of all people. /// /// Parameters: /// @@ -245,7 +261,9 @@ class PeopleApi { return null; } - /// This endpoint requires the `person.read` permission. + /// Get a person + /// + /// Retrieve a person by id. /// /// Note: This method returns the HTTP [Response]. /// @@ -278,7 +296,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.read` permission. + /// Get a person + /// + /// Retrieve a person by id. /// /// Parameters: /// @@ -298,7 +318,9 @@ class PeopleApi { return null; } - /// This endpoint requires the `person.statistics` permission. + /// Get person statistics + /// + /// Retrieve statistics about a specific person. /// /// Note: This method returns the HTTP [Response]. /// @@ -331,7 +353,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.statistics` permission. + /// Get person statistics + /// + /// Retrieve statistics about a specific person. /// /// Parameters: /// @@ -351,7 +375,9 @@ class PeopleApi { return null; } - /// This endpoint requires the `person.read` permission. + /// Get person thumbnail + /// + /// Retrieve the thumbnail file for a person. /// /// Note: This method returns the HTTP [Response]. /// @@ -384,7 +410,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.read` permission. + /// Get person thumbnail + /// + /// Retrieve the thumbnail file for a person. /// /// Parameters: /// @@ -404,7 +432,9 @@ class PeopleApi { return null; } - /// This endpoint requires the `person.merge` permission. + /// Merge people + /// + /// Merge a list of people into the person specified in the path parameter. /// /// Note: This method returns the HTTP [Response]. /// @@ -439,7 +469,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.merge` permission. + /// Merge people + /// + /// Merge a list of people into the person specified in the path parameter. /// /// Parameters: /// @@ -464,7 +496,9 @@ class PeopleApi { return null; } - /// This endpoint requires the `person.reassign` permission. + /// Reassign faces + /// + /// Bulk reassign a list of faces to a different person. /// /// Note: This method returns the HTTP [Response]. /// @@ -499,7 +533,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.reassign` permission. + /// Reassign faces + /// + /// Bulk reassign a list of faces to a different person. /// /// Parameters: /// @@ -524,7 +560,9 @@ class PeopleApi { return null; } - /// This endpoint requires the `person.update` permission. + /// Update people + /// + /// Bulk update multiple people at once. /// /// Note: This method returns the HTTP [Response]. /// @@ -556,7 +594,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.update` permission. + /// Update people + /// + /// Bulk update multiple people at once. /// /// Parameters: /// @@ -579,7 +619,9 @@ class PeopleApi { return null; } - /// This endpoint requires the `person.update` permission. + /// Update person + /// + /// Update an individual person. /// /// Note: This method returns the HTTP [Response]. /// @@ -614,7 +656,9 @@ class PeopleApi { ); } - /// This endpoint requires the `person.update` permission. + /// Update person + /// + /// Update an individual person. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/plugins_api.dart b/mobile/openapi/lib/api/plugins_api.dart new file mode 100644 index 0000000000..264d3049e8 --- /dev/null +++ b/mobile/openapi/lib/api/plugins_api.dart @@ -0,0 +1,126 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class PluginsApi { + PluginsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Retrieve a plugin + /// + /// Retrieve information about a specific plugin by its ID. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getPluginWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/plugins/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve a plugin + /// + /// Retrieve information about a specific plugin by its ID. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getPlugin(String id,) async { + final response = await getPluginWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PluginResponseDto',) as PluginResponseDto; + + } + return null; + } + + /// List all plugins + /// + /// Retrieve a list of plugins available to the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + Future getPluginsWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/plugins'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// List all plugins + /// + /// Retrieve a list of plugins available to the authenticated user. + Future?> getPlugins() async { + final response = await getPluginsWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } +} diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 6c279a3503..ee5f64753c 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -16,7 +16,9 @@ class SearchApi { final ApiClient apiClient; - /// This endpoint requires the `asset.read` permission. + /// Retrieve assets by city + /// + /// Retrieve a list of assets with each asset belonging to a different city. This endpoint is used on the places pages to show a single thumbnail for each city the user has assets in. /// /// Note: This method returns the HTTP [Response]. Future getAssetsByCityWithHttpInfo() async { @@ -44,7 +46,9 @@ class SearchApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Retrieve assets by city + /// + /// Retrieve a list of assets with each asset belonging to a different city. This endpoint is used on the places pages to show a single thumbnail for each city the user has assets in. Future?> getAssetsByCity() async { final response = await getAssetsByCityWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -63,7 +67,9 @@ class SearchApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Retrieve explore data + /// + /// Retrieve data for the explore section, such as popular people and places. /// /// Note: This method returns the HTTP [Response]. Future getExploreDataWithHttpInfo() async { @@ -91,7 +97,9 @@ class SearchApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Retrieve explore data + /// + /// Retrieve data for the explore section, such as popular people and places. Future?> getExploreData() async { final response = await getExploreDataWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -110,7 +118,9 @@ class SearchApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Retrieve search suggestions + /// + /// Retrieve search suggestions based on partial input. This endpoint is used for typeahead search features. /// /// Note: This method returns the HTTP [Response]. /// @@ -121,7 +131,6 @@ class SearchApi { /// * [String] country: /// /// * [bool] includeNull: - /// This property was added in v111.0.0 /// /// * [String] lensModel: /// @@ -175,7 +184,9 @@ class SearchApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Retrieve search suggestions + /// + /// Retrieve search suggestions based on partial input. This endpoint is used for typeahead search features. /// /// Parameters: /// @@ -184,7 +195,6 @@ class SearchApi { /// * [String] country: /// /// * [bool] includeNull: - /// This property was added in v111.0.0 /// /// * [String] lensModel: /// @@ -211,7 +221,9 @@ class SearchApi { return null; } - /// This endpoint requires the `asset.statistics` permission. + /// Search asset statistics + /// + /// Retrieve statistical data about assets based on search criteria, such as the total matching count. /// /// Note: This method returns the HTTP [Response]. /// @@ -243,7 +255,9 @@ class SearchApi { ); } - /// This endpoint requires the `asset.statistics` permission. + /// Search asset statistics + /// + /// Retrieve statistical data about assets based on search criteria, such as the total matching count. /// /// Parameters: /// @@ -263,7 +277,9 @@ class SearchApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Search assets by metadata + /// + /// Search for assets based on various metadata criteria. /// /// Note: This method returns the HTTP [Response]. /// @@ -295,7 +311,9 @@ class SearchApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Search assets by metadata + /// + /// Search for assets based on various metadata criteria. /// /// Parameters: /// @@ -315,7 +333,9 @@ class SearchApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Search large assets + /// + /// Search for assets that are considered large based on specified criteria. /// /// Note: This method returns the HTTP [Response]. /// @@ -506,7 +526,9 @@ class SearchApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Search large assets + /// + /// Search for assets that are considered large based on specified criteria. /// /// Parameters: /// @@ -591,7 +613,9 @@ class SearchApi { return null; } - /// This endpoint requires the `person.read` permission. + /// Search people + /// + /// Search for people by name. /// /// Note: This method returns the HTTP [Response]. /// @@ -630,7 +654,9 @@ class SearchApi { ); } - /// This endpoint requires the `person.read` permission. + /// Search people + /// + /// Search for people by name. /// /// Parameters: /// @@ -655,7 +681,9 @@ class SearchApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Search places + /// + /// Search for places by name. /// /// Note: This method returns the HTTP [Response]. /// @@ -689,7 +717,9 @@ class SearchApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Search places + /// + /// Search for places by name. /// /// Parameters: /// @@ -712,7 +742,9 @@ class SearchApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Search random assets + /// + /// Retrieve a random selection of assets based on the provided criteria. /// /// Note: This method returns the HTTP [Response]. /// @@ -744,7 +776,9 @@ class SearchApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Search random assets + /// + /// Retrieve a random selection of assets based on the provided criteria. /// /// Parameters: /// @@ -767,7 +801,9 @@ class SearchApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Smart asset search + /// + /// Perform a smart search for assets by using machine learning vectors to determine relevance. /// /// Note: This method returns the HTTP [Response]. /// @@ -799,7 +835,9 @@ class SearchApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Smart asset search + /// + /// Perform a smart search for assets by using machine learning vectors to determine relevance. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/server_api.dart b/mobile/openapi/lib/api/server_api.dart index 9fa8f2016d..f5b70a9ea4 100644 --- a/mobile/openapi/lib/api/server_api.dart +++ b/mobile/openapi/lib/api/server_api.dart @@ -16,7 +16,9 @@ class ServerApi { final ApiClient apiClient; - /// This endpoint is an admin-only route, and requires the `serverLicense.delete` permission. + /// Delete server product key + /// + /// Delete the currently set server product key. /// /// Note: This method returns the HTTP [Response]. Future deleteServerLicenseWithHttpInfo() async { @@ -44,7 +46,9 @@ class ServerApi { ); } - /// This endpoint is an admin-only route, and requires the `serverLicense.delete` permission. + /// Delete server product key + /// + /// Delete the currently set server product key. Future deleteServerLicense() async { final response = await deleteServerLicenseWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -52,7 +56,9 @@ class ServerApi { } } - /// This endpoint requires the `server.about` permission. + /// Get server information + /// + /// Retrieve a list of information about the server. /// /// Note: This method returns the HTTP [Response]. Future getAboutInfoWithHttpInfo() async { @@ -80,7 +86,9 @@ class ServerApi { ); } - /// This endpoint requires the `server.about` permission. + /// Get server information + /// + /// Retrieve a list of information about the server. Future getAboutInfo() async { final response = await getAboutInfoWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -96,7 +104,9 @@ class ServerApi { return null; } - /// This endpoint requires the `server.apkLinks` permission. + /// Get APK links + /// + /// Retrieve links to the APKs for the current server version. /// /// Note: This method returns the HTTP [Response]. Future getApkLinksWithHttpInfo() async { @@ -124,7 +134,9 @@ class ServerApi { ); } - /// This endpoint requires the `server.apkLinks` permission. + /// Get APK links + /// + /// Retrieve links to the APKs for the current server version. Future getApkLinks() async { final response = await getApkLinksWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -140,7 +152,11 @@ class ServerApi { return null; } - /// Performs an HTTP 'GET /server/config' operation and returns the [Response]. + /// Get config + /// + /// Retrieve the current server configuration. + /// + /// Note: This method returns the HTTP [Response]. Future getServerConfigWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/server/config'; @@ -166,6 +182,9 @@ class ServerApi { ); } + /// Get config + /// + /// Retrieve the current server configuration. Future getServerConfig() async { final response = await getServerConfigWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -181,7 +200,11 @@ class ServerApi { return null; } - /// Performs an HTTP 'GET /server/features' operation and returns the [Response]. + /// Get features + /// + /// Retrieve available features supported by this server. + /// + /// Note: This method returns the HTTP [Response]. Future getServerFeaturesWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/server/features'; @@ -207,6 +230,9 @@ class ServerApi { ); } + /// Get features + /// + /// Retrieve available features supported by this server. Future getServerFeatures() async { final response = await getServerFeaturesWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -222,7 +248,9 @@ class ServerApi { return null; } - /// This endpoint is an admin-only route, and requires the `serverLicense.read` permission. + /// Get product key + /// + /// Retrieve information about whether the server currently has a product key registered. /// /// Note: This method returns the HTTP [Response]. Future getServerLicenseWithHttpInfo() async { @@ -250,7 +278,9 @@ class ServerApi { ); } - /// This endpoint is an admin-only route, and requires the `serverLicense.read` permission. + /// Get product key + /// + /// Retrieve information about whether the server currently has a product key registered. Future getServerLicense() async { final response = await getServerLicenseWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -266,7 +296,9 @@ class ServerApi { return null; } - /// This endpoint is an admin-only route, and requires the `server.statistics` permission. + /// Get statistics + /// + /// Retrieve statistics about the entire Immich instance such as asset counts. /// /// Note: This method returns the HTTP [Response]. Future getServerStatisticsWithHttpInfo() async { @@ -294,7 +326,9 @@ class ServerApi { ); } - /// This endpoint is an admin-only route, and requires the `server.statistics` permission. + /// Get statistics + /// + /// Retrieve statistics about the entire Immich instance such as asset counts. Future getServerStatistics() async { final response = await getServerStatisticsWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -310,7 +344,11 @@ class ServerApi { return null; } - /// Performs an HTTP 'GET /server/version' operation and returns the [Response]. + /// Get server version + /// + /// Retrieve the current server version in semantic versioning (semver) format. + /// + /// Note: This method returns the HTTP [Response]. Future getServerVersionWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/server/version'; @@ -336,6 +374,9 @@ class ServerApi { ); } + /// Get server version + /// + /// Retrieve the current server version in semantic versioning (semver) format. Future getServerVersion() async { final response = await getServerVersionWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -351,7 +392,9 @@ class ServerApi { return null; } - /// This endpoint requires the `server.storage` permission. + /// Get storage + /// + /// Retrieve the current storage utilization information of the server. /// /// Note: This method returns the HTTP [Response]. Future getStorageWithHttpInfo() async { @@ -379,7 +422,9 @@ class ServerApi { ); } - /// This endpoint requires the `server.storage` permission. + /// Get storage + /// + /// Retrieve the current storage utilization information of the server. Future getStorage() async { final response = await getStorageWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -395,7 +440,11 @@ class ServerApi { return null; } - /// Performs an HTTP 'GET /server/media-types' operation and returns the [Response]. + /// Get supported media types + /// + /// Retrieve all media types supported by the server. + /// + /// Note: This method returns the HTTP [Response]. Future getSupportedMediaTypesWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/server/media-types'; @@ -421,6 +470,9 @@ class ServerApi { ); } + /// Get supported media types + /// + /// Retrieve all media types supported by the server. Future getSupportedMediaTypes() async { final response = await getSupportedMediaTypesWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -436,7 +488,11 @@ class ServerApi { return null; } - /// Performs an HTTP 'GET /server/theme' operation and returns the [Response]. + /// Get theme + /// + /// Retrieve the custom CSS, if existent. + /// + /// Note: This method returns the HTTP [Response]. Future getThemeWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/server/theme'; @@ -462,6 +518,9 @@ class ServerApi { ); } + /// Get theme + /// + /// Retrieve the custom CSS, if existent. Future getTheme() async { final response = await getThemeWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -477,7 +536,9 @@ class ServerApi { return null; } - /// This endpoint requires the `server.versionCheck` permission. + /// Get version check status + /// + /// Retrieve information about the last time the version check ran. /// /// Note: This method returns the HTTP [Response]. Future getVersionCheckWithHttpInfo() async { @@ -505,7 +566,9 @@ class ServerApi { ); } - /// This endpoint requires the `server.versionCheck` permission. + /// Get version check status + /// + /// Retrieve information about the last time the version check ran. Future getVersionCheck() async { final response = await getVersionCheckWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -521,7 +584,11 @@ class ServerApi { return null; } - /// Performs an HTTP 'GET /server/version-history' operation and returns the [Response]. + /// Get version history + /// + /// Retrieve a list of past versions the server has been on. + /// + /// Note: This method returns the HTTP [Response]. Future getVersionHistoryWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/server/version-history'; @@ -547,6 +614,9 @@ class ServerApi { ); } + /// Get version history + /// + /// Retrieve a list of past versions the server has been on. Future?> getVersionHistory() async { final response = await getVersionHistoryWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -565,7 +635,11 @@ class ServerApi { return null; } - /// Performs an HTTP 'GET /server/ping' operation and returns the [Response]. + /// Ping + /// + /// Pong + /// + /// Note: This method returns the HTTP [Response]. Future pingServerWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/server/ping'; @@ -591,6 +665,9 @@ class ServerApi { ); } + /// Ping + /// + /// Pong Future pingServer() async { final response = await pingServerWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -606,7 +683,9 @@ class ServerApi { return null; } - /// This endpoint is an admin-only route, and requires the `serverLicense.update` permission. + /// Set server product key + /// + /// Validate and set the server product key if successful. /// /// Note: This method returns the HTTP [Response]. /// @@ -638,7 +717,9 @@ class ServerApi { ); } - /// This endpoint is an admin-only route, and requires the `serverLicense.update` permission. + /// Set server product key + /// + /// Validate and set the server product key if successful. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index 63528d17a7..da508059bc 100644 --- a/mobile/openapi/lib/api/sessions_api.dart +++ b/mobile/openapi/lib/api/sessions_api.dart @@ -16,7 +16,9 @@ class SessionsApi { final ApiClient apiClient; - /// This endpoint requires the `session.create` permission. + /// Create a session + /// + /// Create a session as a child to the current session. This endpoint is used for casting. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class SessionsApi { ); } - /// This endpoint requires the `session.create` permission. + /// Create a session + /// + /// Create a session as a child to the current session. This endpoint is used for casting. /// /// Parameters: /// @@ -68,7 +72,9 @@ class SessionsApi { return null; } - /// This endpoint requires the `session.delete` permission. + /// Delete all sessions + /// + /// Delete all sessions for the user. This will not delete the current session. /// /// Note: This method returns the HTTP [Response]. Future deleteAllSessionsWithHttpInfo() async { @@ -96,7 +102,9 @@ class SessionsApi { ); } - /// This endpoint requires the `session.delete` permission. + /// Delete all sessions + /// + /// Delete all sessions for the user. This will not delete the current session. Future deleteAllSessions() async { final response = await deleteAllSessionsWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -104,7 +112,9 @@ class SessionsApi { } } - /// This endpoint requires the `session.delete` permission. + /// Delete a session + /// + /// Delete a specific session by id. /// /// Note: This method returns the HTTP [Response]. /// @@ -137,7 +147,9 @@ class SessionsApi { ); } - /// This endpoint requires the `session.delete` permission. + /// Delete a session + /// + /// Delete a specific session by id. /// /// Parameters: /// @@ -149,7 +161,9 @@ class SessionsApi { } } - /// This endpoint requires the `session.read` permission. + /// Retrieve sessions + /// + /// Retrieve a list of sessions for the user. /// /// Note: This method returns the HTTP [Response]. Future getSessionsWithHttpInfo() async { @@ -177,7 +191,9 @@ class SessionsApi { ); } - /// This endpoint requires the `session.read` permission. + /// Retrieve sessions + /// + /// Retrieve a list of sessions for the user. Future?> getSessions() async { final response = await getSessionsWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -196,7 +212,9 @@ class SessionsApi { return null; } - /// This endpoint requires the `session.lock` permission. + /// Lock a session + /// + /// Lock a specific session by id. /// /// Note: This method returns the HTTP [Response]. /// @@ -229,7 +247,9 @@ class SessionsApi { ); } - /// This endpoint requires the `session.lock` permission. + /// Lock a session + /// + /// Lock a specific session by id. /// /// Parameters: /// @@ -241,7 +261,9 @@ class SessionsApi { } } - /// This endpoint requires the `session.update` permission. + /// Update a session + /// + /// Update a specific session identified by id. /// /// Note: This method returns the HTTP [Response]. /// @@ -276,7 +298,9 @@ class SessionsApi { ); } - /// This endpoint requires the `session.update` permission. + /// Update a session + /// + /// Update a specific session identified by id. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index e32c566754..79106e5db6 100644 --- a/mobile/openapi/lib/api/shared_links_api.dart +++ b/mobile/openapi/lib/api/shared_links_api.dart @@ -16,7 +16,12 @@ class SharedLinksApi { final ApiClient apiClient; - /// Performs an HTTP 'PUT /shared-links/{id}/assets' operation and returns the [Response]. + /// Add assets to a shared link + /// + /// Add assets to a specific shared link by its ID. This endpoint is only relevant for shared link of type individual. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [String] id (required): @@ -59,6 +64,10 @@ class SharedLinksApi { ); } + /// Add assets to a shared link + /// + /// Add assets to a specific shared link by its ID. This endpoint is only relevant for shared link of type individual. + /// /// Parameters: /// /// * [String] id (required): @@ -86,7 +95,9 @@ class SharedLinksApi { return null; } - /// This endpoint requires the `sharedLink.create` permission. + /// Create a shared link + /// + /// Create a new shared link. /// /// Note: This method returns the HTTP [Response]. /// @@ -118,7 +129,9 @@ class SharedLinksApi { ); } - /// This endpoint requires the `sharedLink.create` permission. + /// Create a shared link + /// + /// Create a new shared link. /// /// Parameters: /// @@ -138,7 +151,9 @@ class SharedLinksApi { return null; } - /// This endpoint requires the `sharedLink.read` permission. + /// Retrieve all shared links + /// + /// Retrieve a list of all shared links. /// /// Note: This method returns the HTTP [Response]. /// @@ -174,7 +189,9 @@ class SharedLinksApi { ); } - /// This endpoint requires the `sharedLink.read` permission. + /// Retrieve all shared links + /// + /// Retrieve a list of all shared links. /// /// Parameters: /// @@ -197,17 +214,22 @@ class SharedLinksApi { return null; } - /// Performs an HTTP 'GET /shared-links/me' operation and returns the [Response]. + /// Retrieve current shared link + /// + /// Retrieve the current shared link associated with authentication method. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// - /// * [String] password: - /// - /// * [String] token: - /// /// * [String] key: /// + /// * [String] password: + /// /// * [String] slug: - Future getMySharedLinkWithHttpInfo({ String? password, String? token, String? key, String? slug, }) async { + /// + /// * [String] token: + Future getMySharedLinkWithHttpInfo({ String? key, String? password, String? slug, String? token, }) async { // ignore: prefer_const_declarations final apiPath = r'/shared-links/me'; @@ -218,18 +240,18 @@ class SharedLinksApi { final headerParams = {}; final formParams = {}; - if (password != null) { - queryParams.addAll(_queryParams('', 'password', password)); - } - if (token != null) { - queryParams.addAll(_queryParams('', 'token', token)); - } if (key != null) { queryParams.addAll(_queryParams('', 'key', key)); } + if (password != null) { + queryParams.addAll(_queryParams('', 'password', password)); + } if (slug != null) { queryParams.addAll(_queryParams('', 'slug', slug)); } + if (token != null) { + queryParams.addAll(_queryParams('', 'token', token)); + } const contentTypes = []; @@ -245,17 +267,21 @@ class SharedLinksApi { ); } + /// Retrieve current shared link + /// + /// Retrieve the current shared link associated with authentication method. + /// /// Parameters: /// - /// * [String] password: - /// - /// * [String] token: - /// /// * [String] key: /// + /// * [String] password: + /// /// * [String] slug: - Future getMySharedLink({ String? password, String? token, String? key, String? slug, }) async { - final response = await getMySharedLinkWithHttpInfo( password: password, token: token, key: key, slug: slug, ); + /// + /// * [String] token: + Future getMySharedLink({ String? key, String? password, String? slug, String? token, }) async { + final response = await getMySharedLinkWithHttpInfo( key: key, password: password, slug: slug, token: token, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -269,7 +295,9 @@ class SharedLinksApi { return null; } - /// This endpoint requires the `sharedLink.read` permission. + /// Retrieve a shared link + /// + /// Retrieve a specific shared link by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -302,7 +330,9 @@ class SharedLinksApi { ); } - /// This endpoint requires the `sharedLink.read` permission. + /// Retrieve a shared link + /// + /// Retrieve a specific shared link by its ID. /// /// Parameters: /// @@ -322,7 +352,9 @@ class SharedLinksApi { return null; } - /// This endpoint requires the `sharedLink.delete` permission. + /// Delete a shared link + /// + /// Delete a specific shared link by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -355,7 +387,9 @@ class SharedLinksApi { ); } - /// This endpoint requires the `sharedLink.delete` permission. + /// Delete a shared link + /// + /// Delete a specific shared link by its ID. /// /// Parameters: /// @@ -367,7 +401,12 @@ class SharedLinksApi { } } - /// Performs an HTTP 'DELETE /shared-links/{id}/assets' operation and returns the [Response]. + /// Remove assets from a shared link + /// + /// Remove assets from a specific shared link by its ID. This endpoint is only relevant for shared link of type individual. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [String] id (required): @@ -410,6 +449,10 @@ class SharedLinksApi { ); } + /// Remove assets from a shared link + /// + /// Remove assets from a specific shared link by its ID. This endpoint is only relevant for shared link of type individual. + /// /// Parameters: /// /// * [String] id (required): @@ -437,7 +480,9 @@ class SharedLinksApi { return null; } - /// This endpoint requires the `sharedLink.update` permission. + /// Update a shared link + /// + /// Update an existing shared link by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -472,7 +517,9 @@ class SharedLinksApi { ); } - /// This endpoint requires the `sharedLink.update` permission. + /// Update a shared link + /// + /// Update an existing shared link by its ID. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/stacks_api.dart b/mobile/openapi/lib/api/stacks_api.dart index 0f76f3396b..66fa1881ac 100644 --- a/mobile/openapi/lib/api/stacks_api.dart +++ b/mobile/openapi/lib/api/stacks_api.dart @@ -16,7 +16,9 @@ class StacksApi { final ApiClient apiClient; - /// This endpoint requires the `stack.create` permission. + /// Create a stack + /// + /// Create a new stack by providing a name and a list of asset IDs to include in the stack. If any of the provided asset IDs are primary assets of an existing stack, the existing stack will be merged into the newly created stack. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class StacksApi { ); } - /// This endpoint requires the `stack.create` permission. + /// Create a stack + /// + /// Create a new stack by providing a name and a list of asset IDs to include in the stack. If any of the provided asset IDs are primary assets of an existing stack, the existing stack will be merged into the newly created stack. /// /// Parameters: /// @@ -68,7 +72,9 @@ class StacksApi { return null; } - /// This endpoint requires the `stack.delete` permission. + /// Delete a stack + /// + /// Delete a specific stack by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -101,7 +107,9 @@ class StacksApi { ); } - /// This endpoint requires the `stack.delete` permission. + /// Delete a stack + /// + /// Delete a specific stack by its ID. /// /// Parameters: /// @@ -113,7 +121,9 @@ class StacksApi { } } - /// This endpoint requires the `stack.delete` permission. + /// Delete stacks + /// + /// Delete multiple stacks by providing a list of stack IDs. /// /// Note: This method returns the HTTP [Response]. /// @@ -145,7 +155,9 @@ class StacksApi { ); } - /// This endpoint requires the `stack.delete` permission. + /// Delete stacks + /// + /// Delete multiple stacks by providing a list of stack IDs. /// /// Parameters: /// @@ -157,7 +169,9 @@ class StacksApi { } } - /// This endpoint requires the `stack.read` permission. + /// Retrieve a stack + /// + /// Retrieve a specific stack by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -190,7 +204,9 @@ class StacksApi { ); } - /// This endpoint requires the `stack.read` permission. + /// Retrieve a stack + /// + /// Retrieve a specific stack by its ID. /// /// Parameters: /// @@ -210,7 +226,9 @@ class StacksApi { return null; } - /// This endpoint requires the `stack.update` permission. + /// Remove an asset from a stack + /// + /// Remove a specific asset from a stack by providing the stack ID and asset ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -246,7 +264,9 @@ class StacksApi { ); } - /// This endpoint requires the `stack.update` permission. + /// Remove an asset from a stack + /// + /// Remove a specific asset from a stack by providing the stack ID and asset ID. /// /// Parameters: /// @@ -260,7 +280,9 @@ class StacksApi { } } - /// This endpoint requires the `stack.read` permission. + /// Retrieve stacks + /// + /// Retrieve a list of stacks. /// /// Note: This method returns the HTTP [Response]. /// @@ -296,7 +318,9 @@ class StacksApi { ); } - /// This endpoint requires the `stack.read` permission. + /// Retrieve stacks + /// + /// Retrieve a list of stacks. /// /// Parameters: /// @@ -319,7 +343,9 @@ class StacksApi { return null; } - /// This endpoint requires the `stack.update` permission. + /// Update a stack + /// + /// Update an existing stack by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -354,7 +380,9 @@ class StacksApi { ); } - /// This endpoint requires the `stack.update` permission. + /// Update a stack + /// + /// Update an existing stack by its ID. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/sync_api.dart b/mobile/openapi/lib/api/sync_api.dart index 9e594d6ace..6194fd0f89 100644 --- a/mobile/openapi/lib/api/sync_api.dart +++ b/mobile/openapi/lib/api/sync_api.dart @@ -16,7 +16,9 @@ class SyncApi { final ApiClient apiClient; - /// This endpoint requires the `syncCheckpoint.delete` permission. + /// Delete acknowledgements + /// + /// Delete specific synchronization acknowledgments. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class SyncApi { ); } - /// This endpoint requires the `syncCheckpoint.delete` permission. + /// Delete acknowledgements + /// + /// Delete specific synchronization acknowledgments. /// /// Parameters: /// @@ -60,7 +64,12 @@ class SyncApi { } } - /// Performs an HTTP 'POST /sync/delta-sync' operation and returns the [Response]. + /// Get delta sync for user + /// + /// Retrieve changed assets since the last sync for the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [AssetDeltaSyncDto] assetDeltaSyncDto (required): @@ -89,6 +98,10 @@ class SyncApi { ); } + /// Get delta sync for user + /// + /// Retrieve changed assets since the last sync for the authenticated user. + /// /// Parameters: /// /// * [AssetDeltaSyncDto] assetDeltaSyncDto (required): @@ -107,7 +120,12 @@ class SyncApi { return null; } - /// Performs an HTTP 'POST /sync/full-sync' operation and returns the [Response]. + /// Get full sync for user + /// + /// Retrieve all assets for a full synchronization for the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [AssetFullSyncDto] assetFullSyncDto (required): @@ -136,6 +154,10 @@ class SyncApi { ); } + /// Get full sync for user + /// + /// Retrieve all assets for a full synchronization for the authenticated user. + /// /// Parameters: /// /// * [AssetFullSyncDto] assetFullSyncDto (required): @@ -157,7 +179,9 @@ class SyncApi { return null; } - /// This endpoint requires the `syncCheckpoint.read` permission. + /// Retrieve acknowledgements + /// + /// Retrieve the synchronization acknowledgments for the current session. /// /// Note: This method returns the HTTP [Response]. Future getSyncAckWithHttpInfo() async { @@ -185,7 +209,9 @@ class SyncApi { ); } - /// This endpoint requires the `syncCheckpoint.read` permission. + /// Retrieve acknowledgements + /// + /// Retrieve the synchronization acknowledgments for the current session. Future?> getSyncAck() async { final response = await getSyncAckWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -204,7 +230,9 @@ class SyncApi { return null; } - /// This endpoint requires the `sync.stream` permission. + /// Stream sync changes + /// + /// Retrieve a JSON lines streamed response of changes for synchronization. This endpoint is used by the mobile app to efficiently stay up to date with changes. /// /// Note: This method returns the HTTP [Response]. /// @@ -236,7 +264,9 @@ class SyncApi { ); } - /// This endpoint requires the `sync.stream` permission. + /// Stream sync changes + /// + /// Retrieve a JSON lines streamed response of changes for synchronization. This endpoint is used by the mobile app to efficiently stay up to date with changes. /// /// Parameters: /// @@ -248,7 +278,9 @@ class SyncApi { } } - /// This endpoint requires the `syncCheckpoint.update` permission. + /// Acknowledge changes + /// + /// Send a list of synchronization acknowledgements to confirm that the latest changes have been received. /// /// Note: This method returns the HTTP [Response]. /// @@ -280,7 +312,9 @@ class SyncApi { ); } - /// This endpoint requires the `syncCheckpoint.update` permission. + /// Acknowledge changes + /// + /// Send a list of synchronization acknowledgements to confirm that the latest changes have been received. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/system_config_api.dart b/mobile/openapi/lib/api/system_config_api.dart index 2ab3879b8a..b04da71273 100644 --- a/mobile/openapi/lib/api/system_config_api.dart +++ b/mobile/openapi/lib/api/system_config_api.dart @@ -16,7 +16,9 @@ class SystemConfigApi { final ApiClient apiClient; - /// This endpoint is an admin-only route, and requires the `systemConfig.read` permission. + /// Get system configuration + /// + /// Retrieve the current system configuration. /// /// Note: This method returns the HTTP [Response]. Future getConfigWithHttpInfo() async { @@ -44,7 +46,9 @@ class SystemConfigApi { ); } - /// This endpoint is an admin-only route, and requires the `systemConfig.read` permission. + /// Get system configuration + /// + /// Retrieve the current system configuration. Future getConfig() async { final response = await getConfigWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -60,7 +64,9 @@ class SystemConfigApi { return null; } - /// This endpoint is an admin-only route, and requires the `systemConfig.read` permission. + /// Get system configuration defaults + /// + /// Retrieve the default values for the system configuration. /// /// Note: This method returns the HTTP [Response]. Future getConfigDefaultsWithHttpInfo() async { @@ -88,7 +94,9 @@ class SystemConfigApi { ); } - /// This endpoint is an admin-only route, and requires the `systemConfig.read` permission. + /// Get system configuration defaults + /// + /// Retrieve the default values for the system configuration. Future getConfigDefaults() async { final response = await getConfigDefaultsWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -104,7 +112,9 @@ class SystemConfigApi { return null; } - /// This endpoint is an admin-only route, and requires the `systemConfig.read` permission. + /// Get storage template options + /// + /// Retrieve exemplary storage template options. /// /// Note: This method returns the HTTP [Response]. Future getStorageTemplateOptionsWithHttpInfo() async { @@ -132,7 +142,9 @@ class SystemConfigApi { ); } - /// This endpoint is an admin-only route, and requires the `systemConfig.read` permission. + /// Get storage template options + /// + /// Retrieve exemplary storage template options. Future getStorageTemplateOptions() async { final response = await getStorageTemplateOptionsWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -148,7 +160,9 @@ class SystemConfigApi { return null; } - /// This endpoint is an admin-only route, and requires the `systemConfig.update` permission. + /// Update system configuration + /// + /// Update the system configuration with a new system configuration. /// /// Note: This method returns the HTTP [Response]. /// @@ -180,7 +194,9 @@ class SystemConfigApi { ); } - /// This endpoint is an admin-only route, and requires the `systemConfig.update` permission. + /// Update system configuration + /// + /// Update the system configuration with a new system configuration. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/system_metadata_api.dart b/mobile/openapi/lib/api/system_metadata_api.dart index f6b9bad1d6..63fd7628ec 100644 --- a/mobile/openapi/lib/api/system_metadata_api.dart +++ b/mobile/openapi/lib/api/system_metadata_api.dart @@ -16,7 +16,9 @@ class SystemMetadataApi { final ApiClient apiClient; - /// This endpoint is an admin-only route, and requires the `systemMetadata.read` permission. + /// Retrieve admin onboarding + /// + /// Retrieve the current admin onboarding status. /// /// Note: This method returns the HTTP [Response]. Future getAdminOnboardingWithHttpInfo() async { @@ -44,7 +46,9 @@ class SystemMetadataApi { ); } - /// This endpoint is an admin-only route, and requires the `systemMetadata.read` permission. + /// Retrieve admin onboarding + /// + /// Retrieve the current admin onboarding status. Future getAdminOnboarding() async { final response = await getAdminOnboardingWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -60,7 +64,9 @@ class SystemMetadataApi { return null; } - /// This endpoint is an admin-only route, and requires the `systemMetadata.read` permission. + /// Retrieve reverse geocoding state + /// + /// Retrieve the current state of the reverse geocoding import. /// /// Note: This method returns the HTTP [Response]. Future getReverseGeocodingStateWithHttpInfo() async { @@ -88,7 +94,9 @@ class SystemMetadataApi { ); } - /// This endpoint is an admin-only route, and requires the `systemMetadata.read` permission. + /// Retrieve reverse geocoding state + /// + /// Retrieve the current state of the reverse geocoding import. Future getReverseGeocodingState() async { final response = await getReverseGeocodingStateWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -104,7 +112,9 @@ class SystemMetadataApi { return null; } - /// This endpoint is an admin-only route, and requires the `systemMetadata.read` permission. + /// Retrieve version check state + /// + /// Retrieve the current state of the version check process. /// /// Note: This method returns the HTTP [Response]. Future getVersionCheckStateWithHttpInfo() async { @@ -132,7 +142,9 @@ class SystemMetadataApi { ); } - /// This endpoint is an admin-only route, and requires the `systemMetadata.read` permission. + /// Retrieve version check state + /// + /// Retrieve the current state of the version check process. Future getVersionCheckState() async { final response = await getVersionCheckStateWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -148,7 +160,9 @@ class SystemMetadataApi { return null; } - /// This endpoint is an admin-only route, and requires the `systemMetadata.update` permission. + /// Update admin onboarding + /// + /// Update the admin onboarding status. /// /// Note: This method returns the HTTP [Response]. /// @@ -180,7 +194,9 @@ class SystemMetadataApi { ); } - /// This endpoint is an admin-only route, and requires the `systemMetadata.update` permission. + /// Update admin onboarding + /// + /// Update the admin onboarding status. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/tags_api.dart b/mobile/openapi/lib/api/tags_api.dart index a0cdb91acf..a6840f9483 100644 --- a/mobile/openapi/lib/api/tags_api.dart +++ b/mobile/openapi/lib/api/tags_api.dart @@ -16,7 +16,9 @@ class TagsApi { final ApiClient apiClient; - /// This endpoint requires the `tag.asset` permission. + /// Tag assets + /// + /// Add multiple tags to multiple assets in a single request. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class TagsApi { ); } - /// This endpoint requires the `tag.asset` permission. + /// Tag assets + /// + /// Add multiple tags to multiple assets in a single request. /// /// Parameters: /// @@ -68,7 +72,9 @@ class TagsApi { return null; } - /// This endpoint requires the `tag.create` permission. + /// Create a tag + /// + /// Create a new tag by providing a name and optional color. /// /// Note: This method returns the HTTP [Response]. /// @@ -100,7 +106,9 @@ class TagsApi { ); } - /// This endpoint requires the `tag.create` permission. + /// Create a tag + /// + /// Create a new tag by providing a name and optional color. /// /// Parameters: /// @@ -120,7 +128,9 @@ class TagsApi { return null; } - /// This endpoint requires the `tag.delete` permission. + /// Delete a tag + /// + /// Delete a specific tag by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -153,7 +163,9 @@ class TagsApi { ); } - /// This endpoint requires the `tag.delete` permission. + /// Delete a tag + /// + /// Delete a specific tag by its ID. /// /// Parameters: /// @@ -165,7 +177,9 @@ class TagsApi { } } - /// This endpoint requires the `tag.read` permission. + /// Retrieve tags + /// + /// Retrieve a list of all tags. /// /// Note: This method returns the HTTP [Response]. Future getAllTagsWithHttpInfo() async { @@ -193,7 +207,9 @@ class TagsApi { ); } - /// This endpoint requires the `tag.read` permission. + /// Retrieve tags + /// + /// Retrieve a list of all tags. Future?> getAllTags() async { final response = await getAllTagsWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -212,7 +228,9 @@ class TagsApi { return null; } - /// This endpoint requires the `tag.read` permission. + /// Retrieve a tag + /// + /// Retrieve a specific tag by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -245,7 +263,9 @@ class TagsApi { ); } - /// This endpoint requires the `tag.read` permission. + /// Retrieve a tag + /// + /// Retrieve a specific tag by its ID. /// /// Parameters: /// @@ -265,7 +285,9 @@ class TagsApi { return null; } - /// This endpoint requires the `tag.asset` permission. + /// Tag assets + /// + /// Add a tag to all the specified assets. /// /// Note: This method returns the HTTP [Response]. /// @@ -300,7 +322,9 @@ class TagsApi { ); } - /// This endpoint requires the `tag.asset` permission. + /// Tag assets + /// + /// Add a tag to all the specified assets. /// /// Parameters: /// @@ -325,7 +349,9 @@ class TagsApi { return null; } - /// This endpoint requires the `tag.asset` permission. + /// Untag assets + /// + /// Remove a tag from all the specified assets. /// /// Note: This method returns the HTTP [Response]. /// @@ -360,7 +386,9 @@ class TagsApi { ); } - /// This endpoint requires the `tag.asset` permission. + /// Untag assets + /// + /// Remove a tag from all the specified assets. /// /// Parameters: /// @@ -385,7 +413,9 @@ class TagsApi { return null; } - /// This endpoint requires the `tag.update` permission. + /// Update a tag + /// + /// Update an existing tag identified by its ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -420,7 +450,9 @@ class TagsApi { ); } - /// This endpoint requires the `tag.update` permission. + /// Update a tag + /// + /// Update an existing tag identified by its ID. /// /// Parameters: /// @@ -442,7 +474,9 @@ class TagsApi { return null; } - /// This endpoint requires the `tag.create` permission. + /// Upsert tags + /// + /// Create or update multiple tags in a single request. /// /// Note: This method returns the HTTP [Response]. /// @@ -474,7 +508,9 @@ class TagsApi { ); } - /// This endpoint requires the `tag.create` permission. + /// Upsert tags + /// + /// Create or update multiple tags in a single request. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 70ac076c9d..2afcea20ff 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -16,7 +16,9 @@ class TimelineApi { final ApiClient apiClient; - /// This endpoint requires the `asset.read` permission. + /// Get time bucket + /// + /// Retrieve a string of all asset ids in a given time bucket. /// /// Note: This method returns the HTTP [Response]. /// @@ -127,7 +129,9 @@ class TimelineApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Get time bucket + /// + /// Retrieve a string of all asset ids in a given time bucket. /// /// Parameters: /// @@ -185,7 +189,9 @@ class TimelineApi { return null; } - /// This endpoint requires the `asset.read` permission. + /// Get time buckets + /// + /// Retrieve a list of all minimal time buckets. /// /// Note: This method returns the HTTP [Response]. /// @@ -292,7 +298,9 @@ class TimelineApi { ); } - /// This endpoint requires the `asset.read` permission. + /// Get time buckets + /// + /// Retrieve a list of all minimal time buckets. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/trash_api.dart b/mobile/openapi/lib/api/trash_api.dart index 480d19960a..f1dcbb8896 100644 --- a/mobile/openapi/lib/api/trash_api.dart +++ b/mobile/openapi/lib/api/trash_api.dart @@ -16,7 +16,9 @@ class TrashApi { final ApiClient apiClient; - /// This endpoint requires the `asset.delete` permission. + /// Empty trash + /// + /// Permanently delete all items in the trash. /// /// Note: This method returns the HTTP [Response]. Future emptyTrashWithHttpInfo() async { @@ -44,7 +46,9 @@ class TrashApi { ); } - /// This endpoint requires the `asset.delete` permission. + /// Empty trash + /// + /// Permanently delete all items in the trash. Future emptyTrash() async { final response = await emptyTrashWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -60,7 +64,9 @@ class TrashApi { return null; } - /// This endpoint requires the `asset.delete` permission. + /// Restore assets + /// + /// Restore specific assets from the trash. /// /// Note: This method returns the HTTP [Response]. /// @@ -92,7 +98,9 @@ class TrashApi { ); } - /// This endpoint requires the `asset.delete` permission. + /// Restore assets + /// + /// Restore specific assets from the trash. /// /// Parameters: /// @@ -112,7 +120,9 @@ class TrashApi { return null; } - /// This endpoint requires the `asset.delete` permission. + /// Restore trash + /// + /// Restore all items in the trash. /// /// Note: This method returns the HTTP [Response]. Future restoreTrashWithHttpInfo() async { @@ -140,7 +150,9 @@ class TrashApi { ); } - /// This endpoint requires the `asset.delete` permission. + /// Restore trash + /// + /// Restore all items in the trash. Future restoreTrash() async { final response = await restoreTrashWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { diff --git a/mobile/openapi/lib/api/users_admin_api.dart b/mobile/openapi/lib/api/users_admin_api.dart index 4a4301ff43..842a3ebc5b 100644 --- a/mobile/openapi/lib/api/users_admin_api.dart +++ b/mobile/openapi/lib/api/users_admin_api.dart @@ -16,7 +16,9 @@ class UsersAdminApi { final ApiClient apiClient; - /// This endpoint is an admin-only route, and requires the `adminUser.create` permission. + /// Create a user + /// + /// Create a new user. /// /// Note: This method returns the HTTP [Response]. /// @@ -48,7 +50,9 @@ class UsersAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminUser.create` permission. + /// Create a user + /// + /// Create a new user. /// /// Parameters: /// @@ -68,7 +72,9 @@ class UsersAdminApi { return null; } - /// This endpoint is an admin-only route, and requires the `adminUser.delete` permission. + /// Delete a user + /// + /// Delete a user. /// /// Note: This method returns the HTTP [Response]. /// @@ -103,7 +109,9 @@ class UsersAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminUser.delete` permission. + /// Delete a user + /// + /// Delete a user. /// /// Parameters: /// @@ -125,7 +133,9 @@ class UsersAdminApi { return null; } - /// This endpoint is an admin-only route, and requires the `adminUser.read` permission. + /// Retrieve a user + /// + /// Retrieve a specific user by their ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -158,7 +168,9 @@ class UsersAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminUser.read` permission. + /// Retrieve a user + /// + /// Retrieve a specific user by their ID. /// /// Parameters: /// @@ -178,7 +190,9 @@ class UsersAdminApi { return null; } - /// This endpoint is an admin-only route, and requires the `adminUser.read` permission. + /// Retrieve user preferences + /// + /// Retrieve the preferences of a specific user. /// /// Note: This method returns the HTTP [Response]. /// @@ -211,7 +225,9 @@ class UsersAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminUser.read` permission. + /// Retrieve user preferences + /// + /// Retrieve the preferences of a specific user. /// /// Parameters: /// @@ -231,7 +247,9 @@ class UsersAdminApi { return null; } - /// This endpoint is an admin-only route, and requires the `adminSession.read` permission. + /// Retrieve user sessions + /// + /// Retrieve all sessions for a specific user. /// /// Note: This method returns the HTTP [Response]. /// @@ -264,7 +282,9 @@ class UsersAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminSession.read` permission. + /// Retrieve user sessions + /// + /// Retrieve all sessions for a specific user. /// /// Parameters: /// @@ -287,7 +307,9 @@ class UsersAdminApi { return null; } - /// This endpoint is an admin-only route, and requires the `adminUser.read` permission. + /// Retrieve user statistics + /// + /// Retrieve asset statistics for a specific user. /// /// Note: This method returns the HTTP [Response]. /// @@ -336,7 +358,9 @@ class UsersAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminUser.read` permission. + /// Retrieve user statistics + /// + /// Retrieve asset statistics for a specific user. /// /// Parameters: /// @@ -362,7 +386,9 @@ class UsersAdminApi { return null; } - /// This endpoint is an admin-only route, and requires the `adminUser.delete` permission. + /// Restore a deleted user + /// + /// Restore a previously deleted user. /// /// Note: This method returns the HTTP [Response]. /// @@ -395,7 +421,9 @@ class UsersAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminUser.delete` permission. + /// Restore a deleted user + /// + /// Restore a previously deleted user. /// /// Parameters: /// @@ -415,7 +443,9 @@ class UsersAdminApi { return null; } - /// This endpoint is an admin-only route, and requires the `adminUser.read` permission. + /// Search users + /// + /// Search for users. /// /// Note: This method returns the HTTP [Response]. /// @@ -456,7 +486,9 @@ class UsersAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminUser.read` permission. + /// Search users + /// + /// Search for users. /// /// Parameters: /// @@ -481,7 +513,9 @@ class UsersAdminApi { return null; } - /// This endpoint is an admin-only route, and requires the `adminUser.update` permission. + /// Update a user + /// + /// Update an existing user. /// /// Note: This method returns the HTTP [Response]. /// @@ -516,7 +550,9 @@ class UsersAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminUser.update` permission. + /// Update a user + /// + /// Update an existing user. /// /// Parameters: /// @@ -538,7 +574,9 @@ class UsersAdminApi { return null; } - /// This endpoint is an admin-only route, and requires the `adminUser.update` permission. + /// Update user preferences + /// + /// Update the preferences of a specific user. /// /// Note: This method returns the HTTP [Response]. /// @@ -573,7 +611,9 @@ class UsersAdminApi { ); } - /// This endpoint is an admin-only route, and requires the `adminUser.update` permission. + /// Update user preferences + /// + /// Update the preferences of a specific user. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/users_api.dart b/mobile/openapi/lib/api/users_api.dart index c8891ba0c2..f398d9c813 100644 --- a/mobile/openapi/lib/api/users_api.dart +++ b/mobile/openapi/lib/api/users_api.dart @@ -16,7 +16,9 @@ class UsersApi { final ApiClient apiClient; - /// This endpoint requires the `userProfileImage.update` permission. + /// Create user profile image + /// + /// Upload and set a new profile image for the current user. /// /// Note: This method returns the HTTP [Response]. /// @@ -58,7 +60,9 @@ class UsersApi { ); } - /// This endpoint requires the `userProfileImage.update` permission. + /// Create user profile image + /// + /// Upload and set a new profile image for the current user. /// /// Parameters: /// @@ -78,7 +82,9 @@ class UsersApi { return null; } - /// This endpoint requires the `userProfileImage.delete` permission. + /// Delete user profile image + /// + /// Delete the profile image of the current user. /// /// Note: This method returns the HTTP [Response]. Future deleteProfileImageWithHttpInfo() async { @@ -106,7 +112,9 @@ class UsersApi { ); } - /// This endpoint requires the `userProfileImage.delete` permission. + /// Delete user profile image + /// + /// Delete the profile image of the current user. Future deleteProfileImage() async { final response = await deleteProfileImageWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -114,7 +122,9 @@ class UsersApi { } } - /// This endpoint requires the `userLicense.delete` permission. + /// Delete user product key + /// + /// Delete the registered product key for the current user. /// /// Note: This method returns the HTTP [Response]. Future deleteUserLicenseWithHttpInfo() async { @@ -142,7 +152,9 @@ class UsersApi { ); } - /// This endpoint requires the `userLicense.delete` permission. + /// Delete user product key + /// + /// Delete the registered product key for the current user. Future deleteUserLicense() async { final response = await deleteUserLicenseWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -150,7 +162,9 @@ class UsersApi { } } - /// This endpoint requires the `userOnboarding.delete` permission. + /// Delete user onboarding + /// + /// Delete the onboarding status of the current user. /// /// Note: This method returns the HTTP [Response]. Future deleteUserOnboardingWithHttpInfo() async { @@ -178,7 +192,9 @@ class UsersApi { ); } - /// This endpoint requires the `userOnboarding.delete` permission. + /// Delete user onboarding + /// + /// Delete the onboarding status of the current user. Future deleteUserOnboarding() async { final response = await deleteUserOnboardingWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -186,7 +202,9 @@ class UsersApi { } } - /// This endpoint requires the `userPreference.read` permission. + /// Get my preferences + /// + /// Retrieve the preferences for the current user. /// /// Note: This method returns the HTTP [Response]. Future getMyPreferencesWithHttpInfo() async { @@ -214,7 +232,9 @@ class UsersApi { ); } - /// This endpoint requires the `userPreference.read` permission. + /// Get my preferences + /// + /// Retrieve the preferences for the current user. Future getMyPreferences() async { final response = await getMyPreferencesWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -230,7 +250,9 @@ class UsersApi { return null; } - /// This endpoint requires the `user.read` permission. + /// Get current user + /// + /// Retrieve information about the user making the API request. /// /// Note: This method returns the HTTP [Response]. Future getMyUserWithHttpInfo() async { @@ -258,7 +280,9 @@ class UsersApi { ); } - /// This endpoint requires the `user.read` permission. + /// Get current user + /// + /// Retrieve information about the user making the API request. Future getMyUser() async { final response = await getMyUserWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -274,7 +298,9 @@ class UsersApi { return null; } - /// This endpoint requires the `userProfileImage.read` permission. + /// Retrieve user profile image + /// + /// Retrieve the profile image file for a user. /// /// Note: This method returns the HTTP [Response]. /// @@ -307,7 +333,9 @@ class UsersApi { ); } - /// This endpoint requires the `userProfileImage.read` permission. + /// Retrieve user profile image + /// + /// Retrieve the profile image file for a user. /// /// Parameters: /// @@ -327,7 +355,9 @@ class UsersApi { return null; } - /// This endpoint requires the `user.read` permission. + /// Retrieve a user + /// + /// Retrieve a specific user by their ID. /// /// Note: This method returns the HTTP [Response]. /// @@ -360,7 +390,9 @@ class UsersApi { ); } - /// This endpoint requires the `user.read` permission. + /// Retrieve a user + /// + /// Retrieve a specific user by their ID. /// /// Parameters: /// @@ -380,7 +412,9 @@ class UsersApi { return null; } - /// This endpoint requires the `userLicense.read` permission. + /// Retrieve user product key + /// + /// Retrieve information about whether the current user has a registered product key. /// /// Note: This method returns the HTTP [Response]. Future getUserLicenseWithHttpInfo() async { @@ -408,7 +442,9 @@ class UsersApi { ); } - /// This endpoint requires the `userLicense.read` permission. + /// Retrieve user product key + /// + /// Retrieve information about whether the current user has a registered product key. Future getUserLicense() async { final response = await getUserLicenseWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -424,7 +460,9 @@ class UsersApi { return null; } - /// This endpoint requires the `userOnboarding.read` permission. + /// Retrieve user onboarding + /// + /// Retrieve the onboarding status of the current user. /// /// Note: This method returns the HTTP [Response]. Future getUserOnboardingWithHttpInfo() async { @@ -452,7 +490,9 @@ class UsersApi { ); } - /// This endpoint requires the `userOnboarding.read` permission. + /// Retrieve user onboarding + /// + /// Retrieve the onboarding status of the current user. Future getUserOnboarding() async { final response = await getUserOnboardingWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -468,7 +508,9 @@ class UsersApi { return null; } - /// This endpoint requires the `user.read` permission. + /// Get all users + /// + /// Retrieve a list of all users on the server. /// /// Note: This method returns the HTTP [Response]. Future searchUsersWithHttpInfo() async { @@ -496,7 +538,9 @@ class UsersApi { ); } - /// This endpoint requires the `user.read` permission. + /// Get all users + /// + /// Retrieve a list of all users on the server. Future?> searchUsers() async { final response = await searchUsersWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { @@ -515,7 +559,9 @@ class UsersApi { return null; } - /// This endpoint requires the `userLicense.update` permission. + /// Set user product key + /// + /// Register a product key for the current user. /// /// Note: This method returns the HTTP [Response]. /// @@ -547,7 +593,9 @@ class UsersApi { ); } - /// This endpoint requires the `userLicense.update` permission. + /// Set user product key + /// + /// Register a product key for the current user. /// /// Parameters: /// @@ -567,7 +615,9 @@ class UsersApi { return null; } - /// This endpoint requires the `userOnboarding.update` permission. + /// Update user onboarding + /// + /// Update the onboarding status of the current user. /// /// Note: This method returns the HTTP [Response]. /// @@ -599,7 +649,9 @@ class UsersApi { ); } - /// This endpoint requires the `userOnboarding.update` permission. + /// Update user onboarding + /// + /// Update the onboarding status of the current user. /// /// Parameters: /// @@ -619,7 +671,9 @@ class UsersApi { return null; } - /// This endpoint requires the `userPreference.update` permission. + /// Update my preferences + /// + /// Update the preferences of the current user. /// /// Note: This method returns the HTTP [Response]. /// @@ -651,7 +705,9 @@ class UsersApi { ); } - /// This endpoint requires the `userPreference.update` permission. + /// Update my preferences + /// + /// Update the preferences of the current user. /// /// Parameters: /// @@ -671,7 +727,9 @@ class UsersApi { return null; } - /// This endpoint requires the `user.update` permission. + /// Update current user + /// + /// Update the current user making teh API request. /// /// Note: This method returns the HTTP [Response]. /// @@ -703,7 +761,9 @@ class UsersApi { ); } - /// This endpoint requires the `user.update` permission. + /// Update current user + /// + /// Update the current user making teh API request. /// /// Parameters: /// diff --git a/mobile/openapi/lib/api/view_api.dart b/mobile/openapi/lib/api/views_api.dart similarity index 83% rename from mobile/openapi/lib/api/view_api.dart rename to mobile/openapi/lib/api/views_api.dart index 1fcaec759c..a45e89d58f 100644 --- a/mobile/openapi/lib/api/view_api.dart +++ b/mobile/openapi/lib/api/views_api.dart @@ -11,12 +11,17 @@ part of openapi.api; -class ViewApi { - ViewApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; +class ViewsApi { + ViewsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; final ApiClient apiClient; - /// Performs an HTTP 'GET /view/folder' operation and returns the [Response]. + /// Retrieve assets by original path + /// + /// Retrieve assets that are children of a specific folder. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [String] path (required): @@ -47,6 +52,10 @@ class ViewApi { ); } + /// Retrieve assets by original path + /// + /// Retrieve assets that are children of a specific folder. + /// /// Parameters: /// /// * [String] path (required): @@ -68,7 +77,11 @@ class ViewApi { return null; } - /// Performs an HTTP 'GET /view/folder/unique-paths' operation and returns the [Response]. + /// Retrieve unique paths + /// + /// Retrieve a list of unique folder paths from asset original paths. + /// + /// Note: This method returns the HTTP [Response]. Future getUniqueOriginalPathsWithHttpInfo() async { // ignore: prefer_const_declarations final apiPath = r'/view/folder/unique-paths'; @@ -94,6 +107,9 @@ class ViewApi { ); } + /// Retrieve unique paths + /// + /// Retrieve a list of unique folder paths from asset original paths. Future?> getUniqueOriginalPaths() async { final response = await getUniqueOriginalPathsWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { diff --git a/mobile/openapi/lib/api/o_auth_api.dart b/mobile/openapi/lib/api/workflows_api.dart similarity index 55% rename from mobile/openapi/lib/api/o_auth_api.dart rename to mobile/openapi/lib/api/workflows_api.dart index 9f16e37c70..c589ec9823 100644 --- a/mobile/openapi/lib/api/o_auth_api.dart +++ b/mobile/openapi/lib/api/workflows_api.dart @@ -11,21 +11,26 @@ part of openapi.api; -class OAuthApi { - OAuthApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; +class WorkflowsApi { + WorkflowsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; final ApiClient apiClient; - /// Performs an HTTP 'POST /oauth/callback' operation and returns the [Response]. + /// Create a workflow + /// + /// Create a new workflow, the workflow can also be created with empty filters and actions. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// - /// * [OAuthCallbackDto] oAuthCallbackDto (required): - Future finishOAuthWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async { + /// * [WorkflowCreateDto] workflowCreateDto (required): + Future createWorkflowWithHttpInfo(WorkflowCreateDto workflowCreateDto,) async { // ignore: prefer_const_declarations - final apiPath = r'/oauth/callback'; + final apiPath = r'/workflows'; // ignore: prefer_final_locals - Object? postBody = oAuthCallbackDto; + Object? postBody = workflowCreateDto; final queryParams = []; final headerParams = {}; @@ -45,11 +50,15 @@ class OAuthApi { ); } + /// Create a workflow + /// + /// Create a new workflow, the workflow can also be created with empty filters and actions. + /// /// Parameters: /// - /// * [OAuthCallbackDto] oAuthCallbackDto (required): - Future finishOAuth(OAuthCallbackDto oAuthCallbackDto,) async { - final response = await finishOAuthWithHttpInfo(oAuthCallbackDto,); + /// * [WorkflowCreateDto] workflowCreateDto (required): + Future createWorkflow(WorkflowCreateDto workflowCreateDto,) async { + final response = await createWorkflowWithHttpInfo(workflowCreateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -57,33 +66,39 @@ class OAuthApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LoginResponseDto',) as LoginResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto; } return null; } - /// Performs an HTTP 'POST /oauth/link' operation and returns the [Response]. + /// Delete a workflow + /// + /// Delete a workflow by its ID. + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// - /// * [OAuthCallbackDto] oAuthCallbackDto (required): - Future linkOAuthAccountWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async { + /// * [String] id (required): + Future deleteWorkflowWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final apiPath = r'/oauth/link'; + final apiPath = r'/workflows/{id}' + .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = oAuthCallbackDto; + Object? postBody; final queryParams = []; final headerParams = {}; final formParams = {}; - const contentTypes = ['application/json']; + const contentTypes = []; return apiClient.invokeAPI( apiPath, - 'POST', + 'DELETE', queryParams, postBody, headerParams, @@ -92,28 +107,33 @@ class OAuthApi { ); } + /// Delete a workflow + /// + /// Delete a workflow by its ID. + /// /// Parameters: /// - /// * [OAuthCallbackDto] oAuthCallbackDto (required): - Future linkOAuthAccount(OAuthCallbackDto oAuthCallbackDto,) async { - final response = await linkOAuthAccountWithHttpInfo(oAuthCallbackDto,); + /// * [String] id (required): + Future deleteWorkflow(String id,) async { + final response = await deleteWorkflowWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; - - } - return null; } - /// Performs an HTTP 'GET /oauth/mobile-redirect' operation and returns the [Response]. - Future redirectOAuthToMobileWithHttpInfo() async { + /// Retrieve a workflow + /// + /// Retrieve information about a specific workflow by its ID. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getWorkflowWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final apiPath = r'/oauth/mobile-redirect'; + final apiPath = r'/workflows/{id}' + .replaceAll('{id}', id); // ignore: prefer_final_locals Object? postBody; @@ -136,47 +156,15 @@ class OAuthApi { ); } - Future redirectOAuthToMobile() async { - final response = await redirectOAuthToMobileWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - - /// Performs an HTTP 'POST /oauth/authorize' operation and returns the [Response]. + /// Retrieve a workflow + /// + /// Retrieve information about a specific workflow by its ID. + /// /// Parameters: /// - /// * [OAuthConfigDto] oAuthConfigDto (required): - Future startOAuthWithHttpInfo(OAuthConfigDto oAuthConfigDto,) async { - // ignore: prefer_const_declarations - final apiPath = r'/oauth/authorize'; - - // ignore: prefer_final_locals - Object? postBody = oAuthConfigDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - apiPath, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [OAuthConfigDto] oAuthConfigDto (required): - Future startOAuth(OAuthConfigDto oAuthConfigDto,) async { - final response = await startOAuthWithHttpInfo(oAuthConfigDto,); + /// * [String] id (required): + Future getWorkflow(String id,) async { + final response = await getWorkflowWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -184,16 +172,20 @@ class OAuthApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'OAuthAuthorizeResponseDto',) as OAuthAuthorizeResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto; } return null; } - /// Performs an HTTP 'POST /oauth/unlink' operation and returns the [Response]. - Future unlinkOAuthAccountWithHttpInfo() async { + /// List all workflows + /// + /// Retrieve a list of workflows available to the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + Future getWorkflowsWithHttpInfo() async { // ignore: prefer_const_declarations - final apiPath = r'/oauth/unlink'; + final apiPath = r'/workflows'; // ignore: prefer_final_locals Object? postBody; @@ -207,7 +199,7 @@ class OAuthApi { return apiClient.invokeAPI( apiPath, - 'POST', + 'GET', queryParams, postBody, headerParams, @@ -216,8 +208,11 @@ class OAuthApi { ); } - Future unlinkOAuthAccount() async { - final response = await unlinkOAuthAccountWithHttpInfo(); + /// List all workflows + /// + /// Retrieve a list of workflows available to the authenticated user. + Future?> getWorkflows() async { + final response = await getWorkflowsWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -225,7 +220,71 @@ class OAuthApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Update a workflow + /// + /// Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [WorkflowUpdateDto] workflowUpdateDto (required): + Future updateWorkflowWithHttpInfo(String id, WorkflowUpdateDto workflowUpdateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = workflowUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Update a workflow + /// + /// Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [WorkflowUpdateDto] workflowUpdateDto (required): + Future updateWorkflow(String id, WorkflowUpdateDto workflowUpdateDto,) async { + final response = await updateWorkflowWithHttpInfo(id, workflowUpdateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto; } return null; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5139c5cf62..91dc670d12 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -220,8 +220,6 @@ class ApiClient { return AlbumsResponse.fromJson(value); case 'AlbumsUpdate': return AlbumsUpdate.fromJson(value); - case 'AllJobStatusResponseDto': - return AllJobStatusResponseDto.fromJson(value); case 'AssetBulkDeleteDto': return AssetBulkDeleteDto.fromJson(value); case 'AssetBulkUpdateDto': @@ -358,20 +356,10 @@ class ApiClient { return FoldersUpdate.fromJson(value); case 'ImageFormat': return ImageFormatTypeTransformer().decode(value); - case 'JobCommand': - return JobCommandTypeTransformer().decode(value); - case 'JobCommandDto': - return JobCommandDto.fromJson(value); - case 'JobCountsDto': - return JobCountsDto.fromJson(value); case 'JobCreateDto': return JobCreateDto.fromJson(value); - case 'JobName': - return JobNameTypeTransformer().decode(value); case 'JobSettingsDto': return JobSettingsDto.fromJson(value); - case 'JobStatusDto': - return JobStatusDto.fromJson(value); case 'LibraryResponseDto': return LibraryResponseDto.fromJson(value); case 'LibraryStatsResponseDto': @@ -404,6 +392,8 @@ class ApiClient { return MemoryCreateDto.fromJson(value); case 'MemoryResponseDto': return MemoryResponseDto.fromJson(value); + case 'MemorySearchOrder': + return MemorySearchOrderTypeTransformer().decode(value); case 'MemoryStatisticsResponseDto': return MemoryStatisticsResponseDto.fromJson(value); case 'MemoryType': @@ -482,12 +472,34 @@ class ApiClient { return PinCodeSetupDto.fromJson(value); case 'PlacesResponseDto': return PlacesResponseDto.fromJson(value); + case 'PluginActionResponseDto': + return PluginActionResponseDto.fromJson(value); + case 'PluginContext': + return PluginContextTypeTransformer().decode(value); + case 'PluginFilterResponseDto': + return PluginFilterResponseDto.fromJson(value); + case 'PluginResponseDto': + return PluginResponseDto.fromJson(value); + case 'PluginTriggerType': + return PluginTriggerTypeTypeTransformer().decode(value); case 'PurchaseResponse': return PurchaseResponse.fromJson(value); case 'PurchaseUpdate': return PurchaseUpdate.fromJson(value); + case 'QueueCommand': + return QueueCommandTypeTransformer().decode(value); + case 'QueueCommandDto': + return QueueCommandDto.fromJson(value); + case 'QueueName': + return QueueNameTypeTransformer().decode(value); + case 'QueueResponseDto': + return QueueResponseDto.fromJson(value); + case 'QueueStatisticsDto': + return QueueStatisticsDto.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); + case 'QueuesResponseDto': + return QueuesResponseDto.fromJson(value); case 'RandomSearchDto': return RandomSearchDto.fromJson(value); case 'RatingsResponse': @@ -788,6 +800,20 @@ class ApiClient { return VideoCodecTypeTransformer().decode(value); case 'VideoContainer': return VideoContainerTypeTransformer().decode(value); + case 'WorkflowActionItemDto': + return WorkflowActionItemDto.fromJson(value); + case 'WorkflowActionResponseDto': + return WorkflowActionResponseDto.fromJson(value); + case 'WorkflowCreateDto': + return WorkflowCreateDto.fromJson(value); + case 'WorkflowFilterItemDto': + return WorkflowFilterItemDto.fromJson(value); + case 'WorkflowFilterResponseDto': + return WorkflowFilterResponseDto.fromJson(value); + case 'WorkflowResponseDto': + return WorkflowResponseDto.fromJson(value); + case 'WorkflowUpdateDto': + return WorkflowUpdateDto.fromJson(value); default: dynamic match; if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index b34e9210c8..4b33a07214 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -94,18 +94,15 @@ String parameterToString(dynamic value) { if (value is ImageFormat) { return ImageFormatTypeTransformer().encode(value).toString(); } - if (value is JobCommand) { - return JobCommandTypeTransformer().encode(value).toString(); - } - if (value is JobName) { - return JobNameTypeTransformer().encode(value).toString(); - } if (value is LogLevel) { return LogLevelTypeTransformer().encode(value).toString(); } if (value is ManualJobName) { return ManualJobNameTypeTransformer().encode(value).toString(); } + if (value is MemorySearchOrder) { + return MemorySearchOrderTypeTransformer().encode(value).toString(); + } if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); } @@ -124,6 +121,18 @@ String parameterToString(dynamic value) { if (value is Permission) { return PermissionTypeTransformer().encode(value).toString(); } + if (value is PluginContext) { + return PluginContextTypeTransformer().encode(value).toString(); + } + if (value is PluginTriggerType) { + return PluginTriggerTypeTypeTransformer().encode(value).toString(); + } + if (value is QueueCommand) { + return QueueCommandTypeTransformer().encode(value).toString(); + } + if (value is QueueName) { + return QueueNameTypeTransformer().encode(value).toString(); + } if (value is ReactionLevel) { return ReactionLevelTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index dc957b3bfc..8d49986359 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -87,7 +87,6 @@ class AssetResponseDto { bool isTrashed; - /// This property was deprecated in v1.106.0 String? libraryId; String? livePhotoVideoId; @@ -119,7 +118,6 @@ class AssetResponseDto { List people; - /// This property was deprecated in v1.113.0 /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated diff --git a/mobile/openapi/lib/model/job_command.dart b/mobile/openapi/lib/model/job_command.dart deleted file mode 100644 index 46ca7db68f..0000000000 --- a/mobile/openapi/lib/model/job_command.dart +++ /dev/null @@ -1,94 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class JobCommand { - /// Instantiate a new enum with the provided [value]. - const JobCommand._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const start = JobCommand._(r'start'); - static const pause = JobCommand._(r'pause'); - static const resume = JobCommand._(r'resume'); - static const empty = JobCommand._(r'empty'); - static const clearFailed = JobCommand._(r'clear-failed'); - - /// List of all possible values in this [enum][JobCommand]. - static const values = [ - start, - pause, - resume, - empty, - clearFailed, - ]; - - static JobCommand? fromJson(dynamic value) => JobCommandTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = JobCommand.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [JobCommand] to String, -/// and [decode] dynamic data back to [JobCommand]. -class JobCommandTypeTransformer { - factory JobCommandTypeTransformer() => _instance ??= const JobCommandTypeTransformer._(); - - const JobCommandTypeTransformer._(); - - String encode(JobCommand data) => data.value; - - /// Decodes a [dynamic value][data] to a JobCommand. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - JobCommand? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'start': return JobCommand.start; - case r'pause': return JobCommand.pause; - case r'resume': return JobCommand.resume; - case r'empty': return JobCommand.empty; - case r'clear-failed': return JobCommand.clearFailed; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [JobCommandTypeTransformer] instance. - static JobCommandTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart deleted file mode 100644 index bbb9111105..0000000000 --- a/mobile/openapi/lib/model/job_name.dart +++ /dev/null @@ -1,127 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class JobName { - /// Instantiate a new enum with the provided [value]. - const JobName._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const thumbnailGeneration = JobName._(r'thumbnailGeneration'); - static const metadataExtraction = JobName._(r'metadataExtraction'); - static const videoConversion = JobName._(r'videoConversion'); - static const faceDetection = JobName._(r'faceDetection'); - static const facialRecognition = JobName._(r'facialRecognition'); - static const smartSearch = JobName._(r'smartSearch'); - static const duplicateDetection = JobName._(r'duplicateDetection'); - static const backgroundTask = JobName._(r'backgroundTask'); - static const storageTemplateMigration = JobName._(r'storageTemplateMigration'); - static const migration = JobName._(r'migration'); - static const search = JobName._(r'search'); - static const sidecar = JobName._(r'sidecar'); - static const library_ = JobName._(r'library'); - static const notifications = JobName._(r'notifications'); - static const backupDatabase = JobName._(r'backupDatabase'); - static const ocr = JobName._(r'ocr'); - - /// List of all possible values in this [enum][JobName]. - static const values = [ - thumbnailGeneration, - metadataExtraction, - videoConversion, - faceDetection, - facialRecognition, - smartSearch, - duplicateDetection, - backgroundTask, - storageTemplateMigration, - migration, - search, - sidecar, - library_, - notifications, - backupDatabase, - ocr, - ]; - - static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = JobName.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [JobName] to String, -/// and [decode] dynamic data back to [JobName]. -class JobNameTypeTransformer { - factory JobNameTypeTransformer() => _instance ??= const JobNameTypeTransformer._(); - - const JobNameTypeTransformer._(); - - String encode(JobName data) => data.value; - - /// Decodes a [dynamic value][data] to a JobName. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - JobName? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'thumbnailGeneration': return JobName.thumbnailGeneration; - case r'metadataExtraction': return JobName.metadataExtraction; - case r'videoConversion': return JobName.videoConversion; - case r'faceDetection': return JobName.faceDetection; - case r'facialRecognition': return JobName.facialRecognition; - case r'smartSearch': return JobName.smartSearch; - case r'duplicateDetection': return JobName.duplicateDetection; - case r'backgroundTask': return JobName.backgroundTask; - case r'storageTemplateMigration': return JobName.storageTemplateMigration; - case r'migration': return JobName.migration; - case r'search': return JobName.search; - case r'sidecar': return JobName.sidecar; - case r'library': return JobName.library_; - case r'notifications': return JobName.notifications; - case r'backupDatabase': return JobName.backupDatabase; - case r'ocr': return JobName.ocr; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [JobNameTypeTransformer] instance. - static JobNameTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/memories_response.dart b/mobile/openapi/lib/model/memories_response.dart index b9f8b5d8b1..cb42f596a6 100644 --- a/mobile/openapi/lib/model/memories_response.dart +++ b/mobile/openapi/lib/model/memories_response.dart @@ -13,25 +13,31 @@ part of openapi.api; class MemoriesResponse { /// Returns a new [MemoriesResponse] instance. MemoriesResponse({ + this.duration = 5, this.enabled = true, }); + int duration; + bool enabled; @override bool operator ==(Object other) => identical(this, other) || other is MemoriesResponse && + other.duration == duration && other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis + (duration.hashCode) + (enabled.hashCode); @override - String toString() => 'MemoriesResponse[enabled=$enabled]'; + String toString() => 'MemoriesResponse[duration=$duration, enabled=$enabled]'; Map toJson() { final json = {}; + json[r'duration'] = this.duration; json[r'enabled'] = this.enabled; return json; } @@ -45,6 +51,7 @@ class MemoriesResponse { final json = value.cast(); return MemoriesResponse( + duration: mapValueOfType(json, r'duration')!, enabled: mapValueOfType(json, r'enabled')!, ); } @@ -93,6 +100,7 @@ class MemoriesResponse { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'duration', 'enabled', }; } diff --git a/mobile/openapi/lib/model/memories_update.dart b/mobile/openapi/lib/model/memories_update.dart index 71efd71ae7..39c46ffd2f 100644 --- a/mobile/openapi/lib/model/memories_update.dart +++ b/mobile/openapi/lib/model/memories_update.dart @@ -13,9 +13,19 @@ part of openapi.api; class MemoriesUpdate { /// Returns a new [MemoriesUpdate] instance. MemoriesUpdate({ + this.duration, this.enabled, }); + /// Minimum value: 1 + /// + /// 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. + /// + int? duration; + /// /// 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 @@ -26,18 +36,25 @@ class MemoriesUpdate { @override bool operator ==(Object other) => identical(this, other) || other is MemoriesUpdate && + other.duration == duration && other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis + (duration == null ? 0 : duration!.hashCode) + (enabled == null ? 0 : enabled!.hashCode); @override - String toString() => 'MemoriesUpdate[enabled=$enabled]'; + String toString() => 'MemoriesUpdate[duration=$duration, enabled=$enabled]'; Map toJson() { final json = {}; + if (this.duration != null) { + json[r'duration'] = this.duration; + } else { + // json[r'duration'] = null; + } if (this.enabled != null) { json[r'enabled'] = this.enabled; } else { @@ -55,6 +72,7 @@ class MemoriesUpdate { final json = value.cast(); return MemoriesUpdate( + duration: mapValueOfType(json, r'duration'), enabled: mapValueOfType(json, r'enabled'), ); } diff --git a/mobile/openapi/lib/model/memory_search_order.dart b/mobile/openapi/lib/model/memory_search_order.dart new file mode 100644 index 0000000000..bdf5b59894 --- /dev/null +++ b/mobile/openapi/lib/model/memory_search_order.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class MemorySearchOrder { + /// Instantiate a new enum with the provided [value]. + const MemorySearchOrder._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const asc = MemorySearchOrder._(r'asc'); + static const desc = MemorySearchOrder._(r'desc'); + static const random = MemorySearchOrder._(r'random'); + + /// List of all possible values in this [enum][MemorySearchOrder]. + static const values = [ + asc, + desc, + random, + ]; + + static MemorySearchOrder? fromJson(dynamic value) => MemorySearchOrderTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MemorySearchOrder.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [MemorySearchOrder] to String, +/// and [decode] dynamic data back to [MemorySearchOrder]. +class MemorySearchOrderTypeTransformer { + factory MemorySearchOrderTypeTransformer() => _instance ??= const MemorySearchOrderTypeTransformer._(); + + const MemorySearchOrderTypeTransformer._(); + + String encode(MemorySearchOrder data) => data.value; + + /// Decodes a [dynamic value][data] to a MemorySearchOrder. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + MemorySearchOrder? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'asc': return MemorySearchOrder.asc; + case r'desc': return MemorySearchOrder.desc; + case r'random': return MemorySearchOrder.random; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [MemorySearchOrderTypeTransformer] instance. + static MemorySearchOrderTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart index 49f0e85aad..901c38ade9 100644 --- a/mobile/openapi/lib/model/people_response_dto.dart +++ b/mobile/openapi/lib/model/people_response_dto.dart @@ -19,7 +19,6 @@ class PeopleResponseDto { required this.total, }); - /// This property was added in v1.110.0 /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index e05c3e84bc..8b05de523b 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -98,6 +98,10 @@ class Permission { static const pinCodePeriodCreate = Permission._(r'pinCode.create'); static const pinCodePeriodUpdate = Permission._(r'pinCode.update'); static const pinCodePeriodDelete = Permission._(r'pinCode.delete'); + static const pluginPeriodCreate = Permission._(r'plugin.create'); + static const pluginPeriodRead = Permission._(r'plugin.read'); + static const pluginPeriodUpdate = Permission._(r'plugin.update'); + static const pluginPeriodDelete = Permission._(r'plugin.delete'); static const serverPeriodAbout = Permission._(r'server.about'); static const serverPeriodApkLinks = Permission._(r'server.apkLinks'); static const serverPeriodStorage = Permission._(r'server.storage'); @@ -147,6 +151,10 @@ class Permission { static const userProfileImagePeriodRead = Permission._(r'userProfileImage.read'); static const userProfileImagePeriodUpdate = Permission._(r'userProfileImage.update'); static const userProfileImagePeriodDelete = Permission._(r'userProfileImage.delete'); + static const workflowPeriodCreate = Permission._(r'workflow.create'); + static const workflowPeriodRead = Permission._(r'workflow.read'); + static const workflowPeriodUpdate = Permission._(r'workflow.update'); + static const workflowPeriodDelete = Permission._(r'workflow.delete'); static const adminUserPeriodCreate = Permission._(r'adminUser.create'); static const adminUserPeriodRead = Permission._(r'adminUser.read'); static const adminUserPeriodUpdate = Permission._(r'adminUser.update'); @@ -231,6 +239,10 @@ class Permission { pinCodePeriodCreate, pinCodePeriodUpdate, pinCodePeriodDelete, + pluginPeriodCreate, + pluginPeriodRead, + pluginPeriodUpdate, + pluginPeriodDelete, serverPeriodAbout, serverPeriodApkLinks, serverPeriodStorage, @@ -280,6 +292,10 @@ class Permission { userProfileImagePeriodRead, userProfileImagePeriodUpdate, userProfileImagePeriodDelete, + workflowPeriodCreate, + workflowPeriodRead, + workflowPeriodUpdate, + workflowPeriodDelete, adminUserPeriodCreate, adminUserPeriodRead, adminUserPeriodUpdate, @@ -399,6 +415,10 @@ class PermissionTypeTransformer { case r'pinCode.create': return Permission.pinCodePeriodCreate; case r'pinCode.update': return Permission.pinCodePeriodUpdate; case r'pinCode.delete': return Permission.pinCodePeriodDelete; + case r'plugin.create': return Permission.pluginPeriodCreate; + case r'plugin.read': return Permission.pluginPeriodRead; + case r'plugin.update': return Permission.pluginPeriodUpdate; + case r'plugin.delete': return Permission.pluginPeriodDelete; case r'server.about': return Permission.serverPeriodAbout; case r'server.apkLinks': return Permission.serverPeriodApkLinks; case r'server.storage': return Permission.serverPeriodStorage; @@ -448,6 +468,10 @@ class PermissionTypeTransformer { case r'userProfileImage.read': return Permission.userProfileImagePeriodRead; case r'userProfileImage.update': return Permission.userProfileImagePeriodUpdate; case r'userProfileImage.delete': return Permission.userProfileImagePeriodDelete; + case r'workflow.create': return Permission.workflowPeriodCreate; + case r'workflow.read': return Permission.workflowPeriodRead; + case r'workflow.update': return Permission.workflowPeriodUpdate; + case r'workflow.delete': return Permission.workflowPeriodDelete; case r'adminUser.create': return Permission.adminUserPeriodCreate; case r'adminUser.read': return Permission.adminUserPeriodRead; case r'adminUser.update': return Permission.adminUserPeriodUpdate; diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index c9ebb14c72..a6ad5e0c24 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -25,7 +25,6 @@ class PersonResponseDto { DateTime? birthDate; - /// This property was added in v1.126.0 /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -36,7 +35,6 @@ class PersonResponseDto { String id; - /// This property was added in v1.126.0 /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -51,7 +49,6 @@ class PersonResponseDto { String thumbnailPath; - /// This property was added in v1.107.0 /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index 0bd38b0870..9b2e40cf56 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -26,7 +26,6 @@ class PersonWithFacesResponseDto { DateTime? birthDate; - /// This property was added in v1.126.0 /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -39,7 +38,6 @@ class PersonWithFacesResponseDto { String id; - /// This property was added in v1.126.0 /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -54,7 +52,6 @@ class PersonWithFacesResponseDto { String thumbnailPath; - /// This property was added in v1.107.0 /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated diff --git a/mobile/openapi/lib/model/plugin_action_response_dto.dart b/mobile/openapi/lib/model/plugin_action_response_dto.dart new file mode 100644 index 0000000000..75b23fc8a4 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_action_response_dto.dart @@ -0,0 +1,151 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PluginActionResponseDto { + /// Returns a new [PluginActionResponseDto] instance. + PluginActionResponseDto({ + required this.description, + required this.id, + required this.methodName, + required this.pluginId, + required this.schema, + this.supportedContexts = const [], + required this.title, + }); + + String description; + + String id; + + String methodName; + + String pluginId; + + Object? schema; + + List supportedContexts; + + String title; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginActionResponseDto && + other.description == description && + other.id == id && + other.methodName == methodName && + other.pluginId == pluginId && + other.schema == schema && + _deepEquality.equals(other.supportedContexts, supportedContexts) && + other.title == title; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (description.hashCode) + + (id.hashCode) + + (methodName.hashCode) + + (pluginId.hashCode) + + (schema == null ? 0 : schema!.hashCode) + + (supportedContexts.hashCode) + + (title.hashCode); + + @override + String toString() => 'PluginActionResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]'; + + Map toJson() { + final json = {}; + json[r'description'] = this.description; + json[r'id'] = this.id; + json[r'methodName'] = this.methodName; + json[r'pluginId'] = this.pluginId; + if (this.schema != null) { + json[r'schema'] = this.schema; + } else { + // json[r'schema'] = null; + } + json[r'supportedContexts'] = this.supportedContexts; + json[r'title'] = this.title; + return json; + } + + /// Returns a new [PluginActionResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginActionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginActionResponseDto"); + if (value is Map) { + final json = value.cast(); + + return PluginActionResponseDto( + description: mapValueOfType(json, r'description')!, + id: mapValueOfType(json, r'id')!, + methodName: mapValueOfType(json, r'methodName')!, + pluginId: mapValueOfType(json, r'pluginId')!, + schema: mapValueOfType(json, r'schema'), + supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']), + title: mapValueOfType(json, r'title')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginActionResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PluginActionResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginActionResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PluginActionResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'description', + 'id', + 'methodName', + 'pluginId', + 'schema', + 'supportedContexts', + 'title', + }; +} + diff --git a/mobile/openapi/lib/model/plugin_context.dart b/mobile/openapi/lib/model/plugin_context.dart new file mode 100644 index 0000000000..efb701c7d0 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_context.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class PluginContext { + /// Instantiate a new enum with the provided [value]. + const PluginContext._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const asset = PluginContext._(r'asset'); + static const album = PluginContext._(r'album'); + static const person = PluginContext._(r'person'); + + /// List of all possible values in this [enum][PluginContext]. + static const values = [ + asset, + album, + person, + ]; + + static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginContext.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [PluginContext] to String, +/// and [decode] dynamic data back to [PluginContext]. +class PluginContextTypeTransformer { + factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._(); + + const PluginContextTypeTransformer._(); + + String encode(PluginContext data) => data.value; + + /// Decodes a [dynamic value][data] to a PluginContext. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + PluginContext? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'asset': return PluginContext.asset; + case r'album': return PluginContext.album; + case r'person': return PluginContext.person; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [PluginContextTypeTransformer] instance. + static PluginContextTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/plugin_filter_response_dto.dart b/mobile/openapi/lib/model/plugin_filter_response_dto.dart new file mode 100644 index 0000000000..8ed6acec78 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_filter_response_dto.dart @@ -0,0 +1,151 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PluginFilterResponseDto { + /// Returns a new [PluginFilterResponseDto] instance. + PluginFilterResponseDto({ + required this.description, + required this.id, + required this.methodName, + required this.pluginId, + required this.schema, + this.supportedContexts = const [], + required this.title, + }); + + String description; + + String id; + + String methodName; + + String pluginId; + + Object? schema; + + List supportedContexts; + + String title; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginFilterResponseDto && + other.description == description && + other.id == id && + other.methodName == methodName && + other.pluginId == pluginId && + other.schema == schema && + _deepEquality.equals(other.supportedContexts, supportedContexts) && + other.title == title; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (description.hashCode) + + (id.hashCode) + + (methodName.hashCode) + + (pluginId.hashCode) + + (schema == null ? 0 : schema!.hashCode) + + (supportedContexts.hashCode) + + (title.hashCode); + + @override + String toString() => 'PluginFilterResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]'; + + Map toJson() { + final json = {}; + json[r'description'] = this.description; + json[r'id'] = this.id; + json[r'methodName'] = this.methodName; + json[r'pluginId'] = this.pluginId; + if (this.schema != null) { + json[r'schema'] = this.schema; + } else { + // json[r'schema'] = null; + } + json[r'supportedContexts'] = this.supportedContexts; + json[r'title'] = this.title; + return json; + } + + /// Returns a new [PluginFilterResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginFilterResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginFilterResponseDto"); + if (value is Map) { + final json = value.cast(); + + return PluginFilterResponseDto( + description: mapValueOfType(json, r'description')!, + id: mapValueOfType(json, r'id')!, + methodName: mapValueOfType(json, r'methodName')!, + pluginId: mapValueOfType(json, r'pluginId')!, + schema: mapValueOfType(json, r'schema'), + supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']), + title: mapValueOfType(json, r'title')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginFilterResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PluginFilterResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginFilterResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PluginFilterResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'description', + 'id', + 'methodName', + 'pluginId', + 'schema', + 'supportedContexts', + 'title', + }; +} + diff --git a/mobile/openapi/lib/model/plugin_response_dto.dart b/mobile/openapi/lib/model/plugin_response_dto.dart new file mode 100644 index 0000000000..afa6f3e1ab --- /dev/null +++ b/mobile/openapi/lib/model/plugin_response_dto.dart @@ -0,0 +1,171 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PluginResponseDto { + /// Returns a new [PluginResponseDto] instance. + PluginResponseDto({ + this.actions = const [], + required this.author, + required this.createdAt, + required this.description, + this.filters = const [], + required this.id, + required this.name, + required this.title, + required this.updatedAt, + required this.version, + }); + + List actions; + + String author; + + String createdAt; + + String description; + + List filters; + + String id; + + String name; + + String title; + + String updatedAt; + + String version; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginResponseDto && + _deepEquality.equals(other.actions, actions) && + other.author == author && + other.createdAt == createdAt && + other.description == description && + _deepEquality.equals(other.filters, filters) && + other.id == id && + other.name == name && + other.title == title && + other.updatedAt == updatedAt && + other.version == version; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actions.hashCode) + + (author.hashCode) + + (createdAt.hashCode) + + (description.hashCode) + + (filters.hashCode) + + (id.hashCode) + + (name.hashCode) + + (title.hashCode) + + (updatedAt.hashCode) + + (version.hashCode); + + @override + String toString() => 'PluginResponseDto[actions=$actions, author=$author, createdAt=$createdAt, description=$description, filters=$filters, id=$id, name=$name, title=$title, updatedAt=$updatedAt, version=$version]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + json[r'author'] = this.author; + json[r'createdAt'] = this.createdAt; + json[r'description'] = this.description; + json[r'filters'] = this.filters; + json[r'id'] = this.id; + json[r'name'] = this.name; + json[r'title'] = this.title; + json[r'updatedAt'] = this.updatedAt; + json[r'version'] = this.version; + return json; + } + + /// Returns a new [PluginResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginResponseDto"); + if (value is Map) { + final json = value.cast(); + + return PluginResponseDto( + actions: PluginActionResponseDto.listFromJson(json[r'actions']), + author: mapValueOfType(json, r'author')!, + createdAt: mapValueOfType(json, r'createdAt')!, + description: mapValueOfType(json, r'description')!, + filters: PluginFilterResponseDto.listFromJson(json[r'filters']), + id: mapValueOfType(json, r'id')!, + name: mapValueOfType(json, r'name')!, + title: mapValueOfType(json, r'title')!, + updatedAt: mapValueOfType(json, r'updatedAt')!, + version: mapValueOfType(json, r'version')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PluginResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PluginResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actions', + 'author', + 'createdAt', + 'description', + 'filters', + 'id', + 'name', + 'title', + 'updatedAt', + 'version', + }; +} + diff --git a/mobile/openapi/lib/model/plugin_trigger_type.dart b/mobile/openapi/lib/model/plugin_trigger_type.dart new file mode 100644 index 0000000000..b200f1b9e6 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_trigger_type.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class PluginTriggerType { + /// Instantiate a new enum with the provided [value]. + const PluginTriggerType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const assetCreate = PluginTriggerType._(r'AssetCreate'); + static const personRecognized = PluginTriggerType._(r'PersonRecognized'); + + /// List of all possible values in this [enum][PluginTriggerType]. + static const values = [ + assetCreate, + personRecognized, + ]; + + static PluginTriggerType? fromJson(dynamic value) => PluginTriggerTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginTriggerType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [PluginTriggerType] to String, +/// and [decode] dynamic data back to [PluginTriggerType]. +class PluginTriggerTypeTypeTransformer { + factory PluginTriggerTypeTypeTransformer() => _instance ??= const PluginTriggerTypeTypeTransformer._(); + + const PluginTriggerTypeTypeTransformer._(); + + String encode(PluginTriggerType data) => data.value; + + /// Decodes a [dynamic value][data] to a PluginTriggerType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + PluginTriggerType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'AssetCreate': return PluginTriggerType.assetCreate; + case r'PersonRecognized': return PluginTriggerType.personRecognized; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [PluginTriggerTypeTypeTransformer] instance. + static PluginTriggerTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/queue_command.dart b/mobile/openapi/lib/model/queue_command.dart new file mode 100644 index 0000000000..f03ec6eccd --- /dev/null +++ b/mobile/openapi/lib/model/queue_command.dart @@ -0,0 +1,94 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class QueueCommand { + /// Instantiate a new enum with the provided [value]. + const QueueCommand._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const start = QueueCommand._(r'start'); + static const pause = QueueCommand._(r'pause'); + static const resume = QueueCommand._(r'resume'); + static const empty = QueueCommand._(r'empty'); + static const clearFailed = QueueCommand._(r'clear-failed'); + + /// List of all possible values in this [enum][QueueCommand]. + static const values = [ + start, + pause, + resume, + empty, + clearFailed, + ]; + + static QueueCommand? fromJson(dynamic value) => QueueCommandTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = QueueCommand.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [QueueCommand] to String, +/// and [decode] dynamic data back to [QueueCommand]. +class QueueCommandTypeTransformer { + factory QueueCommandTypeTransformer() => _instance ??= const QueueCommandTypeTransformer._(); + + const QueueCommandTypeTransformer._(); + + String encode(QueueCommand data) => data.value; + + /// Decodes a [dynamic value][data] to a QueueCommand. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + QueueCommand? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'start': return QueueCommand.start; + case r'pause': return QueueCommand.pause; + case r'resume': return QueueCommand.resume; + case r'empty': return QueueCommand.empty; + case r'clear-failed': return QueueCommand.clearFailed; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [QueueCommandTypeTransformer] instance. + static QueueCommandTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/queue_command_dto.dart similarity index 66% rename from mobile/openapi/lib/model/job_command_dto.dart rename to mobile/openapi/lib/model/queue_command_dto.dart index 32274037f6..ded848c12f 100644 --- a/mobile/openapi/lib/model/job_command_dto.dart +++ b/mobile/openapi/lib/model/queue_command_dto.dart @@ -10,14 +10,14 @@ part of openapi.api; -class JobCommandDto { - /// Returns a new [JobCommandDto] instance. - JobCommandDto({ +class QueueCommandDto { + /// Returns a new [QueueCommandDto] instance. + QueueCommandDto({ required this.command, this.force, }); - JobCommand command; + QueueCommand command; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -28,7 +28,7 @@ class JobCommandDto { bool? force; @override - bool operator ==(Object other) => identical(this, other) || other is JobCommandDto && + bool operator ==(Object other) => identical(this, other) || other is QueueCommandDto && other.command == command && other.force == force; @@ -39,7 +39,7 @@ class JobCommandDto { (force == null ? 0 : force!.hashCode); @override - String toString() => 'JobCommandDto[command=$command, force=$force]'; + String toString() => 'QueueCommandDto[command=$command, force=$force]'; Map toJson() { final json = {}; @@ -52,27 +52,27 @@ class JobCommandDto { return json; } - /// Returns a new [JobCommandDto] instance and imports its values from + /// Returns a new [QueueCommandDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static JobCommandDto? fromJson(dynamic value) { - upgradeDto(value, "JobCommandDto"); + static QueueCommandDto? fromJson(dynamic value) { + upgradeDto(value, "QueueCommandDto"); if (value is Map) { final json = value.cast(); - return JobCommandDto( - command: JobCommand.fromJson(json[r'command'])!, + return QueueCommandDto( + command: QueueCommand.fromJson(json[r'command'])!, force: mapValueOfType(json, r'force'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = JobCommandDto.fromJson(row); + final value = QueueCommandDto.fromJson(row); if (value != null) { result.add(value); } @@ -81,12 +81,12 @@ class JobCommandDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = JobCommandDto.fromJson(entry.value); + final value = QueueCommandDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -95,14 +95,14 @@ class JobCommandDto { return map; } - // maps a json object with a list of JobCommandDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of QueueCommandDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = JobCommandDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = QueueCommandDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/queue_name.dart b/mobile/openapi/lib/model/queue_name.dart new file mode 100644 index 0000000000..bcc4159fce --- /dev/null +++ b/mobile/openapi/lib/model/queue_name.dart @@ -0,0 +1,130 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class QueueName { + /// Instantiate a new enum with the provided [value]. + const QueueName._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const thumbnailGeneration = QueueName._(r'thumbnailGeneration'); + static const metadataExtraction = QueueName._(r'metadataExtraction'); + static const videoConversion = QueueName._(r'videoConversion'); + static const faceDetection = QueueName._(r'faceDetection'); + static const facialRecognition = QueueName._(r'facialRecognition'); + static const smartSearch = QueueName._(r'smartSearch'); + static const duplicateDetection = QueueName._(r'duplicateDetection'); + static const backgroundTask = QueueName._(r'backgroundTask'); + static const storageTemplateMigration = QueueName._(r'storageTemplateMigration'); + static const migration = QueueName._(r'migration'); + static const search = QueueName._(r'search'); + static const sidecar = QueueName._(r'sidecar'); + static const library_ = QueueName._(r'library'); + static const notifications = QueueName._(r'notifications'); + static const backupDatabase = QueueName._(r'backupDatabase'); + static const ocr = QueueName._(r'ocr'); + static const workflow = QueueName._(r'workflow'); + + /// List of all possible values in this [enum][QueueName]. + static const values = [ + thumbnailGeneration, + metadataExtraction, + videoConversion, + faceDetection, + facialRecognition, + smartSearch, + duplicateDetection, + backgroundTask, + storageTemplateMigration, + migration, + search, + sidecar, + library_, + notifications, + backupDatabase, + ocr, + workflow, + ]; + + static QueueName? fromJson(dynamic value) => QueueNameTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = QueueName.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [QueueName] to String, +/// and [decode] dynamic data back to [QueueName]. +class QueueNameTypeTransformer { + factory QueueNameTypeTransformer() => _instance ??= const QueueNameTypeTransformer._(); + + const QueueNameTypeTransformer._(); + + String encode(QueueName data) => data.value; + + /// Decodes a [dynamic value][data] to a QueueName. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + QueueName? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'thumbnailGeneration': return QueueName.thumbnailGeneration; + case r'metadataExtraction': return QueueName.metadataExtraction; + case r'videoConversion': return QueueName.videoConversion; + case r'faceDetection': return QueueName.faceDetection; + case r'facialRecognition': return QueueName.facialRecognition; + case r'smartSearch': return QueueName.smartSearch; + case r'duplicateDetection': return QueueName.duplicateDetection; + case r'backgroundTask': return QueueName.backgroundTask; + case r'storageTemplateMigration': return QueueName.storageTemplateMigration; + case r'migration': return QueueName.migration; + case r'search': return QueueName.search; + case r'sidecar': return QueueName.sidecar; + case r'library': return QueueName.library_; + case r'notifications': return QueueName.notifications; + case r'backupDatabase': return QueueName.backupDatabase; + case r'ocr': return QueueName.ocr; + case r'workflow': return QueueName.workflow; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [QueueNameTypeTransformer] instance. + static QueueNameTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/job_status_dto.dart b/mobile/openapi/lib/model/queue_response_dto.dart similarity index 62% rename from mobile/openapi/lib/model/job_status_dto.dart rename to mobile/openapi/lib/model/queue_response_dto.dart index 18fab8dfb3..b20449f721 100644 --- a/mobile/openapi/lib/model/job_status_dto.dart +++ b/mobile/openapi/lib/model/queue_response_dto.dart @@ -10,19 +10,19 @@ part of openapi.api; -class JobStatusDto { - /// Returns a new [JobStatusDto] instance. - JobStatusDto({ +class QueueResponseDto { + /// Returns a new [QueueResponseDto] instance. + QueueResponseDto({ required this.jobCounts, required this.queueStatus, }); - JobCountsDto jobCounts; + QueueStatisticsDto jobCounts; QueueStatusDto queueStatus; @override - bool operator ==(Object other) => identical(this, other) || other is JobStatusDto && + bool operator ==(Object other) => identical(this, other) || other is QueueResponseDto && other.jobCounts == jobCounts && other.queueStatus == queueStatus; @@ -33,7 +33,7 @@ class JobStatusDto { (queueStatus.hashCode); @override - String toString() => 'JobStatusDto[jobCounts=$jobCounts, queueStatus=$queueStatus]'; + String toString() => 'QueueResponseDto[jobCounts=$jobCounts, queueStatus=$queueStatus]'; Map toJson() { final json = {}; @@ -42,27 +42,27 @@ class JobStatusDto { return json; } - /// Returns a new [JobStatusDto] instance and imports its values from + /// Returns a new [QueueResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static JobStatusDto? fromJson(dynamic value) { - upgradeDto(value, "JobStatusDto"); + static QueueResponseDto? fromJson(dynamic value) { + upgradeDto(value, "QueueResponseDto"); if (value is Map) { final json = value.cast(); - return JobStatusDto( - jobCounts: JobCountsDto.fromJson(json[r'jobCounts'])!, + return QueueResponseDto( + jobCounts: QueueStatisticsDto.fromJson(json[r'jobCounts'])!, queueStatus: QueueStatusDto.fromJson(json[r'queueStatus'])!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = JobStatusDto.fromJson(row); + final value = QueueResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -71,12 +71,12 @@ class JobStatusDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = JobStatusDto.fromJson(entry.value); + final value = QueueResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -85,14 +85,14 @@ class JobStatusDto { return map; } - // maps a json object with a list of JobStatusDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of QueueResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = JobStatusDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = QueueResponseDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/job_counts_dto.dart b/mobile/openapi/lib/model/queue_statistics_dto.dart similarity index 70% rename from mobile/openapi/lib/model/job_counts_dto.dart rename to mobile/openapi/lib/model/queue_statistics_dto.dart index afc90d1084..c27c4a5892 100644 --- a/mobile/openapi/lib/model/job_counts_dto.dart +++ b/mobile/openapi/lib/model/queue_statistics_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class JobCountsDto { - /// Returns a new [JobCountsDto] instance. - JobCountsDto({ +class QueueStatisticsDto { + /// Returns a new [QueueStatisticsDto] instance. + QueueStatisticsDto({ required this.active, required this.completed, required this.delayed, @@ -34,7 +34,7 @@ class JobCountsDto { int waiting; @override - bool operator ==(Object other) => identical(this, other) || other is JobCountsDto && + bool operator ==(Object other) => identical(this, other) || other is QueueStatisticsDto && other.active == active && other.completed == completed && other.delayed == delayed && @@ -53,7 +53,7 @@ class JobCountsDto { (waiting.hashCode); @override - String toString() => 'JobCountsDto[active=$active, completed=$completed, delayed=$delayed, failed=$failed, paused=$paused, waiting=$waiting]'; + String toString() => 'QueueStatisticsDto[active=$active, completed=$completed, delayed=$delayed, failed=$failed, paused=$paused, waiting=$waiting]'; Map toJson() { final json = {}; @@ -66,15 +66,15 @@ class JobCountsDto { return json; } - /// Returns a new [JobCountsDto] instance and imports its values from + /// Returns a new [QueueStatisticsDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static JobCountsDto? fromJson(dynamic value) { - upgradeDto(value, "JobCountsDto"); + static QueueStatisticsDto? fromJson(dynamic value) { + upgradeDto(value, "QueueStatisticsDto"); if (value is Map) { final json = value.cast(); - return JobCountsDto( + return QueueStatisticsDto( active: mapValueOfType(json, r'active')!, completed: mapValueOfType(json, r'completed')!, delayed: mapValueOfType(json, r'delayed')!, @@ -86,11 +86,11 @@ class JobCountsDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = JobCountsDto.fromJson(row); + final value = QueueStatisticsDto.fromJson(row); if (value != null) { result.add(value); } @@ -99,12 +99,12 @@ class JobCountsDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = JobCountsDto.fromJson(entry.value); + final value = QueueStatisticsDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -113,14 +113,14 @@ class JobCountsDto { return map; } - // maps a json object with a list of JobCountsDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of QueueStatisticsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = JobCountsDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = QueueStatisticsDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/queues_response_dto.dart similarity index 54% rename from mobile/openapi/lib/model/all_job_status_response_dto.dart rename to mobile/openapi/lib/model/queues_response_dto.dart index 291bec4394..be40a56fb1 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/queues_response_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class AllJobStatusResponseDto { - /// Returns a new [AllJobStatusResponseDto] instance. - AllJobStatusResponseDto({ +class QueuesResponseDto { + /// Returns a new [QueuesResponseDto] instance. + QueuesResponseDto({ required this.backgroundTask, required this.backupDatabase, required this.duplicateDetection, @@ -29,42 +29,45 @@ class AllJobStatusResponseDto { required this.storageTemplateMigration, required this.thumbnailGeneration, required this.videoConversion, + required this.workflow, }); - JobStatusDto backgroundTask; + QueueResponseDto backgroundTask; - JobStatusDto backupDatabase; + QueueResponseDto backupDatabase; - JobStatusDto duplicateDetection; + QueueResponseDto duplicateDetection; - JobStatusDto faceDetection; + QueueResponseDto faceDetection; - JobStatusDto facialRecognition; + QueueResponseDto facialRecognition; - JobStatusDto library_; + QueueResponseDto library_; - JobStatusDto metadataExtraction; + QueueResponseDto metadataExtraction; - JobStatusDto migration; + QueueResponseDto migration; - JobStatusDto notifications; + QueueResponseDto notifications; - JobStatusDto ocr; + QueueResponseDto ocr; - JobStatusDto search; + QueueResponseDto search; - JobStatusDto sidecar; + QueueResponseDto sidecar; - JobStatusDto smartSearch; + QueueResponseDto smartSearch; - JobStatusDto storageTemplateMigration; + QueueResponseDto storageTemplateMigration; - JobStatusDto thumbnailGeneration; + QueueResponseDto thumbnailGeneration; - JobStatusDto videoConversion; + QueueResponseDto videoConversion; + + QueueResponseDto workflow; @override - bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto && + bool operator ==(Object other) => identical(this, other) || other is QueuesResponseDto && other.backgroundTask == backgroundTask && other.backupDatabase == backupDatabase && other.duplicateDetection == duplicateDetection && @@ -80,7 +83,8 @@ class AllJobStatusResponseDto { other.smartSearch == smartSearch && other.storageTemplateMigration == storageTemplateMigration && other.thumbnailGeneration == thumbnailGeneration && - other.videoConversion == videoConversion; + other.videoConversion == videoConversion && + other.workflow == workflow; @override int get hashCode => @@ -100,10 +104,11 @@ class AllJobStatusResponseDto { (smartSearch.hashCode) + (storageTemplateMigration.hashCode) + (thumbnailGeneration.hashCode) + - (videoConversion.hashCode); + (videoConversion.hashCode) + + (workflow.hashCode); @override - String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'QueuesResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; @@ -123,44 +128,46 @@ class AllJobStatusResponseDto { json[r'storageTemplateMigration'] = this.storageTemplateMigration; json[r'thumbnailGeneration'] = this.thumbnailGeneration; json[r'videoConversion'] = this.videoConversion; + json[r'workflow'] = this.workflow; return json; } - /// Returns a new [AllJobStatusResponseDto] instance and imports its values from + /// Returns a new [QueuesResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AllJobStatusResponseDto? fromJson(dynamic value) { - upgradeDto(value, "AllJobStatusResponseDto"); + static QueuesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "QueuesResponseDto"); if (value is Map) { final json = value.cast(); - return AllJobStatusResponseDto( - backgroundTask: JobStatusDto.fromJson(json[r'backgroundTask'])!, - backupDatabase: JobStatusDto.fromJson(json[r'backupDatabase'])!, - duplicateDetection: JobStatusDto.fromJson(json[r'duplicateDetection'])!, - faceDetection: JobStatusDto.fromJson(json[r'faceDetection'])!, - facialRecognition: JobStatusDto.fromJson(json[r'facialRecognition'])!, - library_: JobStatusDto.fromJson(json[r'library'])!, - metadataExtraction: JobStatusDto.fromJson(json[r'metadataExtraction'])!, - migration: JobStatusDto.fromJson(json[r'migration'])!, - notifications: JobStatusDto.fromJson(json[r'notifications'])!, - ocr: JobStatusDto.fromJson(json[r'ocr'])!, - search: JobStatusDto.fromJson(json[r'search'])!, - sidecar: JobStatusDto.fromJson(json[r'sidecar'])!, - smartSearch: JobStatusDto.fromJson(json[r'smartSearch'])!, - storageTemplateMigration: JobStatusDto.fromJson(json[r'storageTemplateMigration'])!, - thumbnailGeneration: JobStatusDto.fromJson(json[r'thumbnailGeneration'])!, - videoConversion: JobStatusDto.fromJson(json[r'videoConversion'])!, + return QueuesResponseDto( + backgroundTask: QueueResponseDto.fromJson(json[r'backgroundTask'])!, + backupDatabase: QueueResponseDto.fromJson(json[r'backupDatabase'])!, + duplicateDetection: QueueResponseDto.fromJson(json[r'duplicateDetection'])!, + faceDetection: QueueResponseDto.fromJson(json[r'faceDetection'])!, + facialRecognition: QueueResponseDto.fromJson(json[r'facialRecognition'])!, + library_: QueueResponseDto.fromJson(json[r'library'])!, + metadataExtraction: QueueResponseDto.fromJson(json[r'metadataExtraction'])!, + migration: QueueResponseDto.fromJson(json[r'migration'])!, + notifications: QueueResponseDto.fromJson(json[r'notifications'])!, + ocr: QueueResponseDto.fromJson(json[r'ocr'])!, + search: QueueResponseDto.fromJson(json[r'search'])!, + sidecar: QueueResponseDto.fromJson(json[r'sidecar'])!, + smartSearch: QueueResponseDto.fromJson(json[r'smartSearch'])!, + storageTemplateMigration: QueueResponseDto.fromJson(json[r'storageTemplateMigration'])!, + thumbnailGeneration: QueueResponseDto.fromJson(json[r'thumbnailGeneration'])!, + videoConversion: QueueResponseDto.fromJson(json[r'videoConversion'])!, + workflow: QueueResponseDto.fromJson(json[r'workflow'])!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AllJobStatusResponseDto.fromJson(row); + final value = QueuesResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -169,12 +176,12 @@ class AllJobStatusResponseDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AllJobStatusResponseDto.fromJson(entry.value); + final value = QueuesResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -183,14 +190,14 @@ class AllJobStatusResponseDto { return map; } - // maps a json object with a list of AllJobStatusResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of QueuesResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = AllJobStatusResponseDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = QueuesResponseDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -214,6 +221,7 @@ class AllJobStatusResponseDto { 'storageTemplateMigration', 'thumbnailGeneration', 'videoConversion', + 'workflow', }; } diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 3eeb9c7d3b..461420b3e3 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -25,6 +25,7 @@ class SystemConfigJobDto { required this.smartSearch, required this.thumbnailGeneration, required this.videoConversion, + required this.workflow, }); JobSettingsDto backgroundTask; @@ -51,6 +52,8 @@ class SystemConfigJobDto { JobSettingsDto videoConversion; + JobSettingsDto workflow; + @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigJobDto && other.backgroundTask == backgroundTask && @@ -64,7 +67,8 @@ class SystemConfigJobDto { other.sidecar == sidecar && other.smartSearch == smartSearch && other.thumbnailGeneration == thumbnailGeneration && - other.videoConversion == videoConversion; + other.videoConversion == videoConversion && + other.workflow == workflow; @override int get hashCode => @@ -80,10 +84,11 @@ class SystemConfigJobDto { (sidecar.hashCode) + (smartSearch.hashCode) + (thumbnailGeneration.hashCode) + - (videoConversion.hashCode); + (videoConversion.hashCode) + + (workflow.hashCode); @override - String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; @@ -99,6 +104,7 @@ class SystemConfigJobDto { json[r'smartSearch'] = this.smartSearch; json[r'thumbnailGeneration'] = this.thumbnailGeneration; json[r'videoConversion'] = this.videoConversion; + json[r'workflow'] = this.workflow; return json; } @@ -123,6 +129,7 @@ class SystemConfigJobDto { smartSearch: JobSettingsDto.fromJson(json[r'smartSearch'])!, thumbnailGeneration: JobSettingsDto.fromJson(json[r'thumbnailGeneration'])!, videoConversion: JobSettingsDto.fromJson(json[r'videoConversion'])!, + workflow: JobSettingsDto.fromJson(json[r'workflow'])!, ); } return null; @@ -182,6 +189,7 @@ class SystemConfigJobDto { 'smartSearch', 'thumbnailGeneration', 'videoConversion', + 'workflow', }; } diff --git a/mobile/openapi/lib/model/workflow_action_item_dto.dart b/mobile/openapi/lib/model/workflow_action_item_dto.dart new file mode 100644 index 0000000000..ee0b30216d --- /dev/null +++ b/mobile/openapi/lib/model/workflow_action_item_dto.dart @@ -0,0 +1,116 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowActionItemDto { + /// Returns a new [WorkflowActionItemDto] instance. + WorkflowActionItemDto({ + this.actionConfig, + required this.actionId, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Object? actionConfig; + + String actionId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowActionItemDto && + other.actionConfig == actionConfig && + other.actionId == actionId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actionConfig == null ? 0 : actionConfig!.hashCode) + + (actionId.hashCode); + + @override + String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, actionId=$actionId]'; + + Map toJson() { + final json = {}; + if (this.actionConfig != null) { + json[r'actionConfig'] = this.actionConfig; + } else { + // json[r'actionConfig'] = null; + } + json[r'actionId'] = this.actionId; + return json; + } + + /// Returns a new [WorkflowActionItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowActionItemDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowActionItemDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowActionItemDto( + actionConfig: mapValueOfType(json, r'actionConfig'), + actionId: mapValueOfType(json, r'actionId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowActionItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowActionItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowActionItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowActionItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actionId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_action_response_dto.dart b/mobile/openapi/lib/model/workflow_action_response_dto.dart new file mode 100644 index 0000000000..6528f018c9 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_action_response_dto.dart @@ -0,0 +1,135 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowActionResponseDto { + /// Returns a new [WorkflowActionResponseDto] instance. + WorkflowActionResponseDto({ + required this.actionConfig, + required this.actionId, + required this.id, + required this.order, + required this.workflowId, + }); + + Object? actionConfig; + + String actionId; + + String id; + + num order; + + String workflowId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowActionResponseDto && + other.actionConfig == actionConfig && + other.actionId == actionId && + other.id == id && + other.order == order && + other.workflowId == workflowId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actionConfig == null ? 0 : actionConfig!.hashCode) + + (actionId.hashCode) + + (id.hashCode) + + (order.hashCode) + + (workflowId.hashCode); + + @override + String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, actionId=$actionId, id=$id, order=$order, workflowId=$workflowId]'; + + Map toJson() { + final json = {}; + if (this.actionConfig != null) { + json[r'actionConfig'] = this.actionConfig; + } else { + // json[r'actionConfig'] = null; + } + json[r'actionId'] = this.actionId; + json[r'id'] = this.id; + json[r'order'] = this.order; + json[r'workflowId'] = this.workflowId; + return json; + } + + /// Returns a new [WorkflowActionResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowActionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowActionResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowActionResponseDto( + actionConfig: mapValueOfType(json, r'actionConfig'), + actionId: mapValueOfType(json, r'actionId')!, + id: mapValueOfType(json, r'id')!, + order: num.parse('${json[r'order']}'), + workflowId: mapValueOfType(json, r'workflowId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowActionResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowActionResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowActionResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowActionResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actionConfig', + 'actionId', + 'id', + 'order', + 'workflowId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_create_dto.dart b/mobile/openapi/lib/model/workflow_create_dto.dart new file mode 100644 index 0000000000..c6e44743ac --- /dev/null +++ b/mobile/openapi/lib/model/workflow_create_dto.dart @@ -0,0 +1,157 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowCreateDto { + /// Returns a new [WorkflowCreateDto] instance. + WorkflowCreateDto({ + this.actions = const [], + this.description, + this.enabled, + this.filters = const [], + required this.name, + required this.triggerType, + }); + + List actions; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + List filters; + + String name; + + PluginTriggerType triggerType; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowCreateDto && + _deepEquality.equals(other.actions, actions) && + other.description == description && + other.enabled == enabled && + _deepEquality.equals(other.filters, filters) && + other.name == name && + other.triggerType == triggerType; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actions.hashCode) + + (description == null ? 0 : description!.hashCode) + + (enabled == null ? 0 : enabled!.hashCode) + + (filters.hashCode) + + (name.hashCode) + + (triggerType.hashCode); + + @override + String toString() => 'WorkflowCreateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + json[r'filters'] = this.filters; + json[r'name'] = this.name; + json[r'triggerType'] = this.triggerType; + return json; + } + + /// Returns a new [WorkflowCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowCreateDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowCreateDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowCreateDto( + actions: WorkflowActionItemDto.listFromJson(json[r'actions']), + description: mapValueOfType(json, r'description'), + enabled: mapValueOfType(json, r'enabled'), + filters: WorkflowFilterItemDto.listFromJson(json[r'filters']), + name: mapValueOfType(json, r'name')!, + triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actions', + 'filters', + 'name', + 'triggerType', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_filter_item_dto.dart b/mobile/openapi/lib/model/workflow_filter_item_dto.dart new file mode 100644 index 0000000000..5b78585c3d --- /dev/null +++ b/mobile/openapi/lib/model/workflow_filter_item_dto.dart @@ -0,0 +1,116 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowFilterItemDto { + /// Returns a new [WorkflowFilterItemDto] instance. + WorkflowFilterItemDto({ + this.filterConfig, + required this.filterId, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Object? filterConfig; + + String filterId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterItemDto && + other.filterConfig == filterConfig && + other.filterId == filterId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (filterConfig == null ? 0 : filterConfig!.hashCode) + + (filterId.hashCode); + + @override + String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, filterId=$filterId]'; + + Map toJson() { + final json = {}; + if (this.filterConfig != null) { + json[r'filterConfig'] = this.filterConfig; + } else { + // json[r'filterConfig'] = null; + } + json[r'filterId'] = this.filterId; + return json; + } + + /// Returns a new [WorkflowFilterItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowFilterItemDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowFilterItemDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowFilterItemDto( + filterConfig: mapValueOfType(json, r'filterConfig'), + filterId: mapValueOfType(json, r'filterId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowFilterItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowFilterItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowFilterItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowFilterItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'filterId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_filter_response_dto.dart b/mobile/openapi/lib/model/workflow_filter_response_dto.dart new file mode 100644 index 0000000000..5257c92b80 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_filter_response_dto.dart @@ -0,0 +1,135 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowFilterResponseDto { + /// Returns a new [WorkflowFilterResponseDto] instance. + WorkflowFilterResponseDto({ + required this.filterConfig, + required this.filterId, + required this.id, + required this.order, + required this.workflowId, + }); + + Object? filterConfig; + + String filterId; + + String id; + + num order; + + String workflowId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterResponseDto && + other.filterConfig == filterConfig && + other.filterId == filterId && + other.id == id && + other.order == order && + other.workflowId == workflowId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (filterConfig == null ? 0 : filterConfig!.hashCode) + + (filterId.hashCode) + + (id.hashCode) + + (order.hashCode) + + (workflowId.hashCode); + + @override + String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, filterId=$filterId, id=$id, order=$order, workflowId=$workflowId]'; + + Map toJson() { + final json = {}; + if (this.filterConfig != null) { + json[r'filterConfig'] = this.filterConfig; + } else { + // json[r'filterConfig'] = null; + } + json[r'filterId'] = this.filterId; + json[r'id'] = this.id; + json[r'order'] = this.order; + json[r'workflowId'] = this.workflowId; + return json; + } + + /// Returns a new [WorkflowFilterResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowFilterResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowFilterResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowFilterResponseDto( + filterConfig: mapValueOfType(json, r'filterConfig'), + filterId: mapValueOfType(json, r'filterId')!, + id: mapValueOfType(json, r'id')!, + order: num.parse('${json[r'order']}'), + workflowId: mapValueOfType(json, r'workflowId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowFilterResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowFilterResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowFilterResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowFilterResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'filterConfig', + 'filterId', + 'id', + 'order', + 'workflowId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_response_dto.dart b/mobile/openapi/lib/model/workflow_response_dto.dart new file mode 100644 index 0000000000..5132e7cb73 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_response_dto.dart @@ -0,0 +1,241 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowResponseDto { + /// Returns a new [WorkflowResponseDto] instance. + WorkflowResponseDto({ + this.actions = const [], + required this.createdAt, + required this.description, + required this.enabled, + this.filters = const [], + required this.id, + required this.name, + required this.ownerId, + required this.triggerType, + }); + + List actions; + + String createdAt; + + String description; + + bool enabled; + + List filters; + + String id; + + String? name; + + String ownerId; + + WorkflowResponseDtoTriggerTypeEnum triggerType; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto && + _deepEquality.equals(other.actions, actions) && + other.createdAt == createdAt && + other.description == description && + other.enabled == enabled && + _deepEquality.equals(other.filters, filters) && + other.id == id && + other.name == name && + other.ownerId == ownerId && + other.triggerType == triggerType; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actions.hashCode) + + (createdAt.hashCode) + + (description.hashCode) + + (enabled.hashCode) + + (filters.hashCode) + + (id.hashCode) + + (name == null ? 0 : name!.hashCode) + + (ownerId.hashCode) + + (triggerType.hashCode); + + @override + String toString() => 'WorkflowResponseDto[actions=$actions, createdAt=$createdAt, description=$description, enabled=$enabled, filters=$filters, id=$id, name=$name, ownerId=$ownerId, triggerType=$triggerType]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + json[r'createdAt'] = this.createdAt; + json[r'description'] = this.description; + json[r'enabled'] = this.enabled; + json[r'filters'] = this.filters; + json[r'id'] = this.id; + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + json[r'ownerId'] = this.ownerId; + json[r'triggerType'] = this.triggerType; + return json; + } + + /// Returns a new [WorkflowResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowResponseDto( + actions: WorkflowActionResponseDto.listFromJson(json[r'actions']), + createdAt: mapValueOfType(json, r'createdAt')!, + description: mapValueOfType(json, r'description')!, + enabled: mapValueOfType(json, r'enabled')!, + filters: WorkflowFilterResponseDto.listFromJson(json[r'filters']), + id: mapValueOfType(json, r'id')!, + name: mapValueOfType(json, r'name'), + ownerId: mapValueOfType(json, r'ownerId')!, + triggerType: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actions', + 'createdAt', + 'description', + 'enabled', + 'filters', + 'id', + 'name', + 'ownerId', + 'triggerType', + }; +} + + +class WorkflowResponseDtoTriggerTypeEnum { + /// Instantiate a new enum with the provided [value]. + const WorkflowResponseDtoTriggerTypeEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate'); + static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized'); + + /// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum]. + static const values = [ + assetCreate, + personRecognized, + ]; + + static WorkflowResponseDtoTriggerTypeEnum? fromJson(dynamic value) => WorkflowResponseDtoTriggerTypeEnumTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowResponseDtoTriggerTypeEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [WorkflowResponseDtoTriggerTypeEnum] to String, +/// and [decode] dynamic data back to [WorkflowResponseDtoTriggerTypeEnum]. +class WorkflowResponseDtoTriggerTypeEnumTypeTransformer { + factory WorkflowResponseDtoTriggerTypeEnumTypeTransformer() => _instance ??= const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._(); + + const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._(); + + String encode(WorkflowResponseDtoTriggerTypeEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a WorkflowResponseDtoTriggerTypeEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + WorkflowResponseDtoTriggerTypeEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'AssetCreate': return WorkflowResponseDtoTriggerTypeEnum.assetCreate; + case r'PersonRecognized': return WorkflowResponseDtoTriggerTypeEnum.personRecognized; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [WorkflowResponseDtoTriggerTypeEnumTypeTransformer] instance. + static WorkflowResponseDtoTriggerTypeEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/workflow_update_dto.dart b/mobile/openapi/lib/model/workflow_update_dto.dart new file mode 100644 index 0000000000..b36a396dc6 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_update_dto.dart @@ -0,0 +1,156 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowUpdateDto { + /// Returns a new [WorkflowUpdateDto] instance. + WorkflowUpdateDto({ + this.actions = const [], + this.description, + this.enabled, + this.filters = const [], + this.name, + }); + + List actions; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + List filters; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? name; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto && + _deepEquality.equals(other.actions, actions) && + other.description == description && + other.enabled == enabled && + _deepEquality.equals(other.filters, filters) && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actions.hashCode) + + (description == null ? 0 : description!.hashCode) + + (enabled == null ? 0 : enabled!.hashCode) + + (filters.hashCode) + + (name == null ? 0 : name!.hashCode); + + @override + String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + json[r'filters'] = this.filters; + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + return json; + } + + /// Returns a new [WorkflowUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowUpdateDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowUpdateDto( + actions: WorkflowActionItemDto.listFromJson(json[r'actions']), + description: mapValueOfType(json, r'description'), + enabled: mapValueOfType(json, r'enabled'), + filters: WorkflowFilterItemDto.listFromJson(json[r'filters']), + name: mapValueOfType(json, r'name'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowUpdateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowUpdateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index ac08a68ca3..822e2eddb3 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -14,8 +14,10 @@ import 'package:pigeon/pigeon.dart'; class PlatformAsset { final String id; final String name; + // Follows AssetType enum from base_asset.model.dart final int type; + // Seconds since epoch final int? createdAt; final int? updatedAt; @@ -42,6 +44,7 @@ class PlatformAsset { class PlatformAlbum { final String id; final String name; + // Seconds since epoch final int? updatedAt; final bool isCloud; @@ -60,6 +63,7 @@ class SyncDelta { final bool hasChanges; final List updates; final List deletes; + // Asset -> Album mapping final Map> assetAlbums; @@ -107,4 +111,7 @@ abstract class NativeSyncApi { List hashAssets(List assetIds, {bool allowNetworkAccess = false}); void cancelHashing(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + Map> getTrashedAssets(); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index da560c4064..d0e6031bdf 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: "direct main" description: name: background_downloader - sha256: a22acfa37aa06ba5cfe6eb7b1aa700c78af64770ff450c73dd3d279d7c37d4ac + sha256: a913b37cc47a656a225e9562b69576000d516f705482f392e2663500e6ff6032 url: "https://pub.dev" source: hosted - version: "9.2.6" + version: "9.3.0" bonsoir: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a48d17c7bb..6b06d26e4d 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 2.2.2+3025 +version: 2.2.3+3026 environment: sdk: '>=3.8.0 <4.0.0' @@ -11,7 +11,7 @@ environment: dependencies: async: ^2.13.0 auto_route: ^9.2.0 - background_downloader: ^9.2.6 + background_downloader: ^9.3.0 cached_network_image: ^3.4.1 cancellation_token_http: ^2.1.0 cast: ^2.1.0 diff --git a/mobile/scripts/fdroid_build_isar.sh b/mobile/scripts/fdroid_build_isar.sh index f42bc51d9a..a145268356 100755 --- a/mobile/scripts/fdroid_build_isar.sh +++ b/mobile/scripts/fdroid_build_isar.sh @@ -8,11 +8,11 @@ bash tool/build_android.sh x64 bash tool/build_android.sh armv7 bash tool/build_android.sh arm64 mv libisar_android_arm64.so libisar.so -mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/ +mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/ mv libisar_android_armv7.so libisar.so -mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/ +mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/ mv libisar_android_x64.so libisar.so -mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/ +mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/x86_64/ mv libisar_android_x86.so libisar.so -mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/ -) \ No newline at end of file +mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/x86/ +) diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index 8293faf125..0bab675889 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -17,3 +17,4 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {} class MockAppSettingsService extends Mock implements AppSettingsService {} class MockUploadService extends Mock implements UploadService {} + diff --git a/mobile/test/domain/services/hash_service_test.dart b/mobile/test/domain/services/hash_service_test.dart index 20d60b6866..3529ecca38 100644 --- a/mobile/test/domain/services/hash_service_test.dart +++ b/mobile/test/domain/services/hash_service_test.dart @@ -14,16 +14,19 @@ void main() { late MockLocalAlbumRepository mockAlbumRepo; late MockLocalAssetRepository mockAssetRepo; late MockNativeSyncApi mockNativeApi; + late MockTrashedLocalAssetRepository mockTrashedAssetRepo; setUp(() { mockAlbumRepo = MockLocalAlbumRepository(); mockAssetRepo = MockLocalAssetRepository(); mockNativeApi = MockNativeSyncApi(); + mockTrashedAssetRepo = MockTrashedLocalAssetRepository(); sut = HashService( localAlbumRepository: mockAlbumRepo, localAssetRepository: mockAssetRepo, nativeSyncApi: mockNativeApi, + trashedLocalAssetRepository: mockTrashedAssetRepo, ); registerFallbackValue(LocalAlbumStub.recent); @@ -114,6 +117,7 @@ void main() { localAssetRepository: mockAssetRepo, nativeSyncApi: mockNativeApi, batchSize: batchSize, + trashedLocalAssetRepository: mockTrashedAssetRepo, ); final album = LocalAlbumStub.recent; @@ -186,4 +190,5 @@ void main() { verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1); }); }); + } diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart new file mode 100644 index 0000000000..2f236971e0 --- /dev/null +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -0,0 +1,190 @@ +import 'package:drift/drift.dart' as drift; +import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/local_sync.service.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../domain/service.mock.dart'; +import '../../fixtures/asset.stub.dart'; +import '../../infrastructure/repository.mock.dart'; +import '../../mocks/asset_entity.mock.dart'; +import '../../repository.mocks.dart'; + +void main() { + late LocalSyncService sut; + late DriftLocalAlbumRepository mockLocalAlbumRepository; + late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository; + late LocalFilesManagerRepository mockLocalFilesManager; + late StorageRepository mockStorageRepository; + late MockNativeSyncApi mockNativeSyncApi; + late Drift db; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + await StoreService.init(storeRepository: DriftStoreRepository(db)); + }); + + tearDownAll(() async { + debugDefaultTargetPlatformOverride = null; + await Store.clear(); + await db.close(); + }); + + setUp(() async { + mockLocalAlbumRepository = MockLocalAlbumRepository(); + mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository(); + mockLocalFilesManager = MockLocalFilesManagerRepository(); + mockStorageRepository = MockStorageRepository(); + mockNativeSyncApi = MockNativeSyncApi(); + + when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false); + when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer( + (_) async => SyncDelta( + hasChanges: false, + updates: const [], + deletes: const [], + assetAlbums: const {}, + ), + ); + when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {}); + when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {}); + when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []); + when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {}); + when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {}); + when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {}); + when(() => mockLocalFilesManager.moveToTrash(any>())).thenAnswer((_) async => true); + + sut = LocalSyncService( + localAlbumRepository: mockLocalAlbumRepository, + trashedLocalAssetRepository: mockTrashedLocalAssetRepository, + localFilesManager: mockLocalFilesManager, + storageRepository: mockStorageRepository, + nativeSyncApi: mockNativeSyncApi, + ); + + await Store.put(StoreKey.manageLocalMediaAndroid, false); + when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false); + }); + + group('LocalSyncService - syncTrashedAssets gating', () { + test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async { + await Store.put(StoreKey.manageLocalMediaAndroid, true); + when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); + + await sut.sync(); + + verify(() => mockNativeSyncApi.getTrashedAssets()).called(1); + verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1); + }); + + test('skips syncTrashedAssets when store flag disabled', () async { + await Store.put(StoreKey.manageLocalMediaAndroid, false); + when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); + + await sut.sync(); + + verifyNever(() => mockNativeSyncApi.getTrashedAssets()); + }); + + test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async { + await Store.put(StoreKey.manageLocalMediaAndroid, true); + when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false); + + await sut.sync(); + + verifyNever(() => mockNativeSyncApi.getTrashedAssets()); + }); + + test('skips syncTrashedAssets on non-Android platforms', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); + + await Store.put(StoreKey.manageLocalMediaAndroid, true); + when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); + + await sut.sync(); + + verifyNever(() => mockNativeSyncApi.getTrashedAssets()); + }); + }); + + group('LocalSyncService - syncTrashedAssets behavior', () { + test('processes trashed snapshot, restores assets, and trashes local files', () async { + final platformAsset = PlatformAsset( + id: 'remote-id', + name: 'remote.jpg', + type: AssetType.image.index, + durationInSeconds: 0, + orientation: 0, + isFavorite: false, + ); + + final assetsToRestore = [LocalAssetStub.image1]; + when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore); + final restoredIds = ['image1']; + when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async { + final Iterable requested = invocation.positionalArguments.first as Iterable; + expect(requested, orderedEquals(assetsToRestore)); + return restoredIds; + }); + + final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash'); + when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {'album-a': [localAssetToTrash]}); + + final assetEntity = MockAssetEntity(); + when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash'); + when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity); + + await sut.processTrashedAssets({'album-a': [platformAsset]}); + + verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1); + verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1); + + verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1); + verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1); + + verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1); + final moveArgs = + verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List; + expect(moveArgs, ['content://local-trash']); + final trashArgs = + verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single + as Map>; + expect(trashArgs.keys, ['album-a']); + expect(trashArgs['album-a'], [localAssetToTrash]); + }); + + test('does not attempt restore when repository has no assets to restore', () async { + when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []); + + await sut.processTrashedAssets({}); + + verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any())); + verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())); + }); + + test('does not move local assets when repository finds nothing to trash', () async { + when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {}); + + await sut.processTrashedAssets({}); + + verifyNever(() => mockLocalFilesManager.moveToTrash(any())); + verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())); + }); + }); +} diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 0126b11e46..109b54a907 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -1,14 +1,30 @@ import 'dart:async'; +import 'package:drift/drift.dart' as drift; +import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/sync_stream.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:mocktail/mocktail.dart'; +import '../../fixtures/asset.stub.dart'; import '../../fixtures/sync_stream.stub.dart'; import '../../infrastructure/repository.mock.dart'; +import '../../mocks/asset_entity.mock.dart'; +import '../../repository.mocks.dart'; class _AbortCallbackWrapper { const _AbortCallbackWrapper(); @@ -30,15 +46,40 @@ void main() { late SyncStreamService sut; late SyncStreamRepository mockSyncStreamRepo; late SyncApiRepository mockSyncApiRepo; + late DriftLocalAssetRepository mockLocalAssetRepo; + late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo; + late LocalFilesManagerRepository mockLocalFilesManagerRepo; + late StorageRepository mockStorageRepo; late Future Function(List, Function(), Function()) handleEventsCallback; late _MockAbortCallbackWrapper mockAbortCallbackWrapper; late _MockAbortCallbackWrapper mockResetCallbackWrapper; + late Drift db; + late bool hasManageMediaPermission; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + registerFallbackValue(LocalAssetStub.image1); + + db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + await StoreService.init(storeRepository: DriftStoreRepository(db)); + }); + + tearDownAll(() async { + debugDefaultTargetPlatformOverride = null; + await Store.clear(); + await db.close(); + }); successHandler(Invocation _) async => true; - setUp(() { + setUp(() async { mockSyncStreamRepo = MockSyncStreamRepository(); mockSyncApiRepo = MockSyncApiRepository(); + mockLocalAssetRepo = MockLocalAssetRepository(); + mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository(); + mockLocalFilesManagerRepo = MockLocalFilesManagerRepository(); + mockStorageRepo = MockStorageRepository(); mockAbortCallbackWrapper = _MockAbortCallbackWrapper(); mockResetCallbackWrapper = _MockAbortCallbackWrapper(); @@ -87,7 +128,25 @@ void main() { when(() => mockSyncStreamRepo.updateAssetFacesV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteAssetFacesV1(any())).thenAnswer(successHandler); - sut = SyncStreamService(syncApiRepository: mockSyncApiRepo, syncStreamRepository: mockSyncStreamRepo); + sut = SyncStreamService( + syncApiRepository: mockSyncApiRepo, + syncStreamRepository: mockSyncStreamRepo, + localAssetRepository: mockLocalAssetRepo, + trashedLocalAssetRepository: mockTrashedLocalAssetRepo, + localFilesManager: mockLocalFilesManagerRepo, + storageRepository: mockStorageRepo, + ); + + when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {}); + when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {}); + when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []); + when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {}); + hasManageMediaPermission = false; + when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission); + when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true); + when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []); + when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null); + await Store.put(StoreKey.manageLocalMediaAndroid, false); }); Future simulateEvents(List events) async { @@ -152,6 +211,10 @@ void main() { sut = SyncStreamService( syncApiRepository: mockSyncApiRepo, syncStreamRepository: mockSyncStreamRepo, + localAssetRepository: mockLocalAssetRepo, + trashedLocalAssetRepository: mockTrashedLocalAssetRepo, + localFilesManager: mockLocalFilesManagerRepo, + storageRepository: mockStorageRepo, cancelChecker: cancellationChecker.call, ); await sut.sync(); @@ -187,6 +250,10 @@ void main() { sut = SyncStreamService( syncApiRepository: mockSyncApiRepo, syncStreamRepository: mockSyncStreamRepo, + localAssetRepository: mockLocalAssetRepo, + trashedLocalAssetRepository: mockTrashedLocalAssetRepo, + localFilesManager: mockLocalFilesManagerRepo, + storageRepository: mockStorageRepo, cancelChecker: cancellationChecker.call, ); @@ -296,4 +363,127 @@ void main() { verify(() => mockSyncApiRepo.ack(["5"])).called(1); }); }); + + group("SyncStreamService - remote trash & restore", () { + setUp(() async { + await Store.put(StoreKey.manageLocalMediaAndroid, true); + hasManageMediaPermission = true; + }); + + tearDown(() async { + await Store.put(StoreKey.manageLocalMediaAndroid, false); + hasManageMediaPermission = false; + }); + + test("moves backed up local and merged assets to device trash when remote trash events are received", () async { + final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-local', remoteId: null); + final mergedAsset = LocalAssetStub.image2.copyWith( + id: 'merged-local', + checksum: 'checksum-merged', + remoteId: 'remote-merged', + ); + final assetsByAlbum = { + 'album-a': [localAsset], + 'album-b': [mergedAsset], + }; + when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async { + final Iterable requestedChecksums = invocation.positionalArguments.first as Iterable; + expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'})); + return assetsByAlbum; + }); + + final localEntity = MockAssetEntity(); + when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only'); + when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity); + + final mergedEntity = MockAssetEntity(); + when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local'); + when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity); + + when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async { + final urls = invocation.positionalArguments.first as List; + expect(urls, unorderedEquals(['content://local-only', 'content://merged-local'])); + return true; + }); + + final events = [ + SyncStreamStub.assetTrashed( + id: 'remote-1', + checksum: localAsset.checksum!, + ack: 'asset-remote-local-1', + trashedAt: DateTime(2025, 5, 1), + ), + SyncStreamStub.assetTrashed( + id: 'remote-2', + checksum: mergedAsset.checksum!, + ack: 'asset-remote-merged-2', + trashedAt: DateTime(2025, 5, 2), + ), + SyncStreamStub.assetTrashed( + id: 'remote-3', + checksum: 'checksum-remote-only', + ack: 'asset-remote-only-3', + trashedAt: DateTime(2025, 5, 3), + ), + ]; + + await simulateEvents(events); + + verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1); + verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1); + }); + + test("skips device trashing when no local assets match the remote trash payload", () async { + final events = [ + SyncStreamStub.assetTrashed( + id: 'remote-only', + checksum: 'checksum-only', + ack: 'asset-remote-only-9', + trashedAt: DateTime(2025, 6, 1), + ), + ]; + + await simulateEvents(events); + + verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1); + verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any())); + verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())); + }); + + test("does not request local deletions for permanent remote delete events", () async { + final events = [SyncStreamStub.assetDeleteV1]; + + await simulateEvents(events); + + verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())); + verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any())); + verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1); + }); + + test("restores trashed local assets once the matching remote assets leave the trash", () async { + final trashedAssets = [ + LocalAssetStub.image1.copyWith(id: 'trashed-1', checksum: 'checksum-trash', remoteId: 'remote-1'), + ]; + when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets); + + final restoredIds = ['trashed-1']; + when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async { + final Iterable requestedAssets = invocation.positionalArguments.first as Iterable; + expect(requestedAssets, orderedEquals(trashedAssets)); + return restoredIds; + }); + + final events = [ + SyncStreamStub.assetModified( + id: 'remote-1', + checksum: 'checksum-trash', + ack: 'asset-remote-1-11', + ), + ]; + + await simulateEvents(events); + + verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1); + }); + }); } diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 073a86078f..69dff89fb1 100644 --- a/mobile/test/drift/main/generated/schema.dart +++ b/mobile/test/drift/main/generated/schema.dart @@ -15,6 +15,7 @@ import 'schema_v9.dart' as v9; import 'schema_v10.dart' as v10; import 'schema_v11.dart' as v11; import 'schema_v12.dart' as v12; +import 'schema_v13.dart' as v13; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -44,10 +45,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v11.DatabaseAtV11(db); case 12: return v12.DatabaseAtV12(db); + case 13: + return v13.DatabaseAtV13(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; } diff --git a/mobile/test/drift/main/generated/schema_v13.dart b/mobile/test/drift/main/generated/schema_v13.dart new file mode 100644 index 0000000000..da0d853678 --- /dev/null +++ b/mobile/test/drift/main/generated/schema_v13.dart @@ -0,0 +1,7765 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class UserEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn hasProfileImage = GeneratedColumn( + 'has_profile_image', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("has_profile_image" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn profileChangedAt = + GeneratedColumn( + 'profile_changed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + name, + email, + hasProfileImage, + profileChangedAt, + avatarColor, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_entity'; + @override + Set get $primaryKey => {id}; + @override + UserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + hasProfileImage: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}has_profile_image'], + )!, + profileChangedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}profile_changed_at'], + )!, + avatarColor: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}avatar_color'], + )!, + ); + } + + @override + UserEntity createAlias(String alias) { + return UserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserEntityData extends DataClass implements Insertable { + final String id; + final String name; + final String email; + final bool hasProfileImage; + final DateTime profileChangedAt; + final int avatarColor; + const UserEntityData({ + required this.id, + required this.name, + required this.email, + required this.hasProfileImage, + required this.profileChangedAt, + required this.avatarColor, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['email'] = Variable(email); + map['has_profile_image'] = Variable(hasProfileImage); + map['profile_changed_at'] = Variable(profileChangedAt); + map['avatar_color'] = Variable(avatarColor); + return map; + } + + factory UserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + email: serializer.fromJson(json['email']), + hasProfileImage: serializer.fromJson(json['hasProfileImage']), + profileChangedAt: serializer.fromJson(json['profileChangedAt']), + avatarColor: serializer.fromJson(json['avatarColor']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'email': serializer.toJson(email), + 'hasProfileImage': serializer.toJson(hasProfileImage), + 'profileChangedAt': serializer.toJson(profileChangedAt), + 'avatarColor': serializer.toJson(avatarColor), + }; + } + + UserEntityData copyWith({ + String? id, + String? name, + String? email, + bool? hasProfileImage, + DateTime? profileChangedAt, + int? avatarColor, + }) => UserEntityData( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + ); + UserEntityData copyWithCompanion(UserEntityCompanion data) { + return UserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + email: data.email.present ? data.email.value : this.email, + hasProfileImage: data.hasProfileImage.present + ? data.hasProfileImage.value + : this.hasProfileImage, + profileChangedAt: data.profileChangedAt.present + ? data.profileChangedAt.value + : this.profileChangedAt, + avatarColor: data.avatarColor.present + ? data.avatarColor.value + : this.avatarColor, + ); + } + + @override + String toString() { + return (StringBuffer('UserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + email, + hasProfileImage, + profileChangedAt, + avatarColor, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserEntityData && + other.id == this.id && + other.name == this.name && + other.email == this.email && + other.hasProfileImage == this.hasProfileImage && + other.profileChangedAt == this.profileChangedAt && + other.avatarColor == this.avatarColor); +} + +class UserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value email; + final Value hasProfileImage; + final Value profileChangedAt; + final Value avatarColor; + const UserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.email = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + }); + UserEntityCompanion.insert({ + required String id, + required String name, + required String email, + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? email, + Expression? hasProfileImage, + Expression? profileChangedAt, + Expression? avatarColor, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (email != null) 'email': email, + if (hasProfileImage != null) 'has_profile_image': hasProfileImage, + if (profileChangedAt != null) 'profile_changed_at': profileChangedAt, + if (avatarColor != null) 'avatar_color': avatarColor, + }); + } + + UserEntityCompanion copyWith({ + Value? id, + Value? name, + Value? email, + Value? hasProfileImage, + Value? profileChangedAt, + Value? avatarColor, + }) { + return UserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (hasProfileImage.present) { + map['has_profile_image'] = Variable(hasProfileImage.value); + } + if (profileChangedAt.present) { + map['profile_changed_at'] = Variable(profileChangedAt.value); + } + if (avatarColor.present) { + map['avatar_color'] = Variable(avatarColor.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor') + ..write(')')) + .toString(); + } +} + +class RemoteAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn localDateTime = + GeneratedColumn( + 'local_date_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn thumbHash = GeneratedColumn( + 'thumb_hash', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn livePhotoVideoId = GeneratedColumn( + 'live_photo_video_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visibility = GeneratedColumn( + 'visibility', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn stackId = GeneratedColumn( + 'stack_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn libraryId = GeneratedColumn( + 'library_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId, + libraryId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + )!, + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + localDateTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}local_date_time'], + ), + thumbHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumb_hash'], + ), + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + livePhotoVideoId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}live_photo_video_id'], + ), + visibility: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}visibility'], + )!, + stackId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}stack_id'], + ), + libraryId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}library_id'], + ), + ); + } + + @override + RemoteAssetEntity createAlias(String alias) { + return RemoteAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String checksum; + final bool isFavorite; + final String ownerId; + final DateTime? localDateTime; + final String? thumbHash; + final DateTime? deletedAt; + final String? livePhotoVideoId; + final int visibility; + final String? stackId; + final String? libraryId; + const RemoteAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.checksum, + required this.isFavorite, + required this.ownerId, + this.localDateTime, + this.thumbHash, + this.deletedAt, + this.livePhotoVideoId, + required this.visibility, + this.stackId, + this.libraryId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['checksum'] = Variable(checksum); + map['is_favorite'] = Variable(isFavorite); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || localDateTime != null) { + map['local_date_time'] = Variable(localDateTime); + } + if (!nullToAbsent || thumbHash != null) { + map['thumb_hash'] = Variable(thumbHash); + } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + if (!nullToAbsent || livePhotoVideoId != null) { + map['live_photo_video_id'] = Variable(livePhotoVideoId); + } + map['visibility'] = Variable(visibility); + if (!nullToAbsent || stackId != null) { + map['stack_id'] = Variable(stackId); + } + if (!nullToAbsent || libraryId != null) { + map['library_id'] = Variable(libraryId); + } + return map; + } + + factory RemoteAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + ownerId: serializer.fromJson(json['ownerId']), + localDateTime: serializer.fromJson(json['localDateTime']), + thumbHash: serializer.fromJson(json['thumbHash']), + deletedAt: serializer.fromJson(json['deletedAt']), + livePhotoVideoId: serializer.fromJson(json['livePhotoVideoId']), + visibility: serializer.fromJson(json['visibility']), + stackId: serializer.fromJson(json['stackId']), + libraryId: serializer.fromJson(json['libraryId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'ownerId': serializer.toJson(ownerId), + 'localDateTime': serializer.toJson(localDateTime), + 'thumbHash': serializer.toJson(thumbHash), + 'deletedAt': serializer.toJson(deletedAt), + 'livePhotoVideoId': serializer.toJson(livePhotoVideoId), + 'visibility': serializer.toJson(visibility), + 'stackId': serializer.toJson(stackId), + 'libraryId': serializer.toJson(libraryId), + }; + } + + RemoteAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? checksum, + bool? isFavorite, + String? ownerId, + Value localDateTime = const Value.absent(), + Value thumbHash = const Value.absent(), + Value deletedAt = const Value.absent(), + Value livePhotoVideoId = const Value.absent(), + int? visibility, + Value stackId = const Value.absent(), + Value libraryId = const Value.absent(), + }) => RemoteAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime.present + ? localDateTime.value + : this.localDateTime, + thumbHash: thumbHash.present ? thumbHash.value : this.thumbHash, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + livePhotoVideoId: livePhotoVideoId.present + ? livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + stackId: stackId.present ? stackId.value : this.stackId, + libraryId: libraryId.present ? libraryId.value : this.libraryId, + ); + RemoteAssetEntityData copyWithCompanion(RemoteAssetEntityCompanion data) { + return RemoteAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + localDateTime: data.localDateTime.present + ? data.localDateTime.value + : this.localDateTime, + thumbHash: data.thumbHash.present ? data.thumbHash.value : this.thumbHash, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + livePhotoVideoId: data.livePhotoVideoId.present + ? data.livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: data.visibility.present + ? data.visibility.value + : this.visibility, + stackId: data.stackId.present ? data.stackId.value : this.stackId, + libraryId: data.libraryId.present ? data.libraryId.value : this.libraryId, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId, ') + ..write('libraryId: $libraryId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId, + libraryId, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.ownerId == this.ownerId && + other.localDateTime == this.localDateTime && + other.thumbHash == this.thumbHash && + other.deletedAt == this.deletedAt && + other.livePhotoVideoId == this.livePhotoVideoId && + other.visibility == this.visibility && + other.stackId == this.stackId && + other.libraryId == this.libraryId); +} + +class RemoteAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value ownerId; + final Value localDateTime; + final Value thumbHash; + final Value deletedAt; + final Value livePhotoVideoId; + final Value visibility; + final Value stackId; + final Value libraryId; + const RemoteAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.ownerId = const Value.absent(), + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + this.visibility = const Value.absent(), + this.stackId = const Value.absent(), + this.libraryId = const Value.absent(), + }); + RemoteAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String checksum, + this.isFavorite = const Value.absent(), + required String ownerId, + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + required int visibility, + this.stackId = const Value.absent(), + this.libraryId = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id), + checksum = Value(checksum), + ownerId = Value(ownerId), + visibility = Value(visibility); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? ownerId, + Expression? localDateTime, + Expression? thumbHash, + Expression? deletedAt, + Expression? livePhotoVideoId, + Expression? visibility, + Expression? stackId, + Expression? libraryId, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (ownerId != null) 'owner_id': ownerId, + if (localDateTime != null) 'local_date_time': localDateTime, + if (thumbHash != null) 'thumb_hash': thumbHash, + if (deletedAt != null) 'deleted_at': deletedAt, + if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId, + if (visibility != null) 'visibility': visibility, + if (stackId != null) 'stack_id': stackId, + if (libraryId != null) 'library_id': libraryId, + }); + } + + RemoteAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? ownerId, + Value? localDateTime, + Value? thumbHash, + Value? deletedAt, + Value? livePhotoVideoId, + Value? visibility, + Value? stackId, + Value? libraryId, + }) { + return RemoteAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime ?? this.localDateTime, + thumbHash: thumbHash ?? this.thumbHash, + deletedAt: deletedAt ?? this.deletedAt, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + stackId: stackId ?? this.stackId, + libraryId: libraryId ?? this.libraryId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (localDateTime.present) { + map['local_date_time'] = Variable(localDateTime.value); + } + if (thumbHash.present) { + map['thumb_hash'] = Variable(thumbHash.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (livePhotoVideoId.present) { + map['live_photo_video_id'] = Variable(livePhotoVideoId.value); + } + if (visibility.present) { + map['visibility'] = Variable(visibility.value); + } + if (stackId.present) { + map['stack_id'] = Variable(stackId.value); + } + if (libraryId.present) { + map['library_id'] = Variable(libraryId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId, ') + ..write('libraryId: $libraryId') + ..write(')')) + .toString(); + } +} + +class StackEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StackEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn primaryAssetId = GeneratedColumn( + 'primary_asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + primaryAssetId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'stack_entity'; + @override + Set get $primaryKey => {id}; + @override + StackEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StackEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + primaryAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}primary_asset_id'], + )!, + ); + } + + @override + StackEntity createAlias(String alias) { + return StackEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StackEntityData extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String primaryAssetId; + const StackEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.primaryAssetId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['primary_asset_id'] = Variable(primaryAssetId); + return map; + } + + factory StackEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StackEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + primaryAssetId: serializer.fromJson(json['primaryAssetId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'primaryAssetId': serializer.toJson(primaryAssetId), + }; + } + + StackEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? primaryAssetId, + }) => StackEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + StackEntityData copyWithCompanion(StackEntityCompanion data) { + return StackEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + primaryAssetId: data.primaryAssetId.present + ? data.primaryAssetId.value + : this.primaryAssetId, + ); + } + + @override + String toString() { + return (StringBuffer('StackEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StackEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.primaryAssetId == this.primaryAssetId); +} + +class StackEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value primaryAssetId; + const StackEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.primaryAssetId = const Value.absent(), + }); + StackEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String primaryAssetId, + }) : id = Value(id), + ownerId = Value(ownerId), + primaryAssetId = Value(primaryAssetId); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? primaryAssetId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, + }); + } + + StackEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? primaryAssetId, + }) { + return StackEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (primaryAssetId.present) { + map['primary_asset_id'] = Variable(primaryAssetId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StackEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } +} + +class LocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}orientation'], + )!, + ); + } + + @override + LocalAssetEntity createAlias(String alias) { + return LocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String? checksum; + final bool isFavorite; + final int orientation; + const LocalAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + this.checksum, + required this.isFavorite, + required this.orientation, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + return map; + } + + factory LocalAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + }; + } + + LocalAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation, + }) => LocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + LocalAssetEntityData copyWithCompanion(LocalAssetEntityCompanion data) { + return LocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation); +} + +class LocalAssetEntityCompanion extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value orientation; + const LocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + }); + LocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + }); + } + + LocalAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? orientation, + }) { + return LocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const CustomExpression('\'\''), + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn thumbnailAssetId = GeneratedColumn( + 'thumbnail_asset_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn isActivityEnabled = GeneratedColumn( + 'is_activity_enabled', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_activity_enabled" IN (0, 1))', + ), + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn order = GeneratedColumn( + 'order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + thumbnailAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumbnail_asset_id'], + ), + isActivityEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_activity_enabled'], + )!, + order: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}order'], + )!, + ); + } + + @override + RemoteAlbumEntity createAlias(String alias) { + return RemoteAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String description; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String? thumbnailAssetId; + final bool isActivityEnabled; + final int order; + const RemoteAlbumEntityData({ + required this.id, + required this.name, + required this.description, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + this.thumbnailAssetId, + required this.isActivityEnabled, + required this.order, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['description'] = Variable(description); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || thumbnailAssetId != null) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId); + } + map['is_activity_enabled'] = Variable(isActivityEnabled); + map['order'] = Variable(order); + return map; + } + + factory RemoteAlbumEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + description: serializer.fromJson(json['description']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + thumbnailAssetId: serializer.fromJson(json['thumbnailAssetId']), + isActivityEnabled: serializer.fromJson(json['isActivityEnabled']), + order: serializer.fromJson(json['order']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'description': serializer.toJson(description), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'thumbnailAssetId': serializer.toJson(thumbnailAssetId), + 'isActivityEnabled': serializer.toJson(isActivityEnabled), + 'order': serializer.toJson(order), + }; + } + + RemoteAlbumEntityData copyWith({ + String? id, + String? name, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + Value thumbnailAssetId = const Value.absent(), + bool? isActivityEnabled, + int? order, + }) => RemoteAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId.present + ? thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + RemoteAlbumEntityData copyWithCompanion(RemoteAlbumEntityCompanion data) { + return RemoteAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + description: data.description.present + ? data.description.value + : this.description, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + thumbnailAssetId: data.thumbnailAssetId.present + ? data.thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: data.isActivityEnabled.present + ? data.isActivityEnabled.value + : this.isActivityEnabled, + order: data.order.present ? data.order.value : this.order, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.description == this.description && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.thumbnailAssetId == this.thumbnailAssetId && + other.isActivityEnabled == this.isActivityEnabled && + other.order == this.order); +} + +class RemoteAlbumEntityCompanion + extends UpdateCompanion { + final Value id; + final Value name; + final Value description; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value thumbnailAssetId; + final Value isActivityEnabled; + final Value order; + const RemoteAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + this.order = const Value.absent(), + }); + RemoteAlbumEntityCompanion.insert({ + required String id, + required String name, + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + required int order, + }) : id = Value(id), + name = Value(name), + ownerId = Value(ownerId), + order = Value(order); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? description, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? thumbnailAssetId, + Expression? isActivityEnabled, + Expression? order, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (description != null) 'description': description, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (thumbnailAssetId != null) 'thumbnail_asset_id': thumbnailAssetId, + if (isActivityEnabled != null) 'is_activity_enabled': isActivityEnabled, + if (order != null) 'order': order, + }); + } + + RemoteAlbumEntityCompanion copyWith({ + Value? id, + Value? name, + Value? description, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? thumbnailAssetId, + Value? isActivityEnabled, + Value? order, + }) { + return RemoteAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (thumbnailAssetId.present) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId.value); + } + if (isActivityEnabled.present) { + map['is_activity_enabled'] = Variable(isActivityEnabled.value); + } + if (order.present) { + map['order'] = Variable(order.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } +} + +class LocalAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn backupSelection = GeneratedColumn( + 'backup_selection', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn isIosSharedAlbum = GeneratedColumn( + 'is_ios_shared_album', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_ios_shared_album" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn linkedRemoteAlbumId = + GeneratedColumn( + 'linked_remote_album_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("marker" IN (0, 1))', + ), + ); + @override + List get $columns => [ + id, + name, + updatedAt, + backupSelection, + isIosSharedAlbum, + linkedRemoteAlbumId, + marker_, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + backupSelection: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}backup_selection'], + )!, + isIosSharedAlbum: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_ios_shared_album'], + )!, + linkedRemoteAlbumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}linked_remote_album_id'], + ), + marker_: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}marker'], + ), + ); + } + + @override + LocalAlbumEntity createAlias(String alias) { + return LocalAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final DateTime updatedAt; + final int backupSelection; + final bool isIosSharedAlbum; + final String? linkedRemoteAlbumId; + final bool? marker_; + const LocalAlbumEntityData({ + required this.id, + required this.name, + required this.updatedAt, + required this.backupSelection, + required this.isIosSharedAlbum, + this.linkedRemoteAlbumId, + this.marker_, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['updated_at'] = Variable(updatedAt); + map['backup_selection'] = Variable(backupSelection); + map['is_ios_shared_album'] = Variable(isIosSharedAlbum); + if (!nullToAbsent || linkedRemoteAlbumId != null) { + map['linked_remote_album_id'] = Variable(linkedRemoteAlbumId); + } + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + updatedAt: serializer.fromJson(json['updatedAt']), + backupSelection: serializer.fromJson(json['backupSelection']), + isIosSharedAlbum: serializer.fromJson(json['isIosSharedAlbum']), + linkedRemoteAlbumId: serializer.fromJson( + json['linkedRemoteAlbumId'], + ), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'updatedAt': serializer.toJson(updatedAt), + 'backupSelection': serializer.toJson(backupSelection), + 'isIosSharedAlbum': serializer.toJson(isIosSharedAlbum), + 'linkedRemoteAlbumId': serializer.toJson(linkedRemoteAlbumId), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumEntityData copyWith({ + String? id, + String? name, + DateTime? updatedAt, + int? backupSelection, + bool? isIosSharedAlbum, + Value linkedRemoteAlbumId = const Value.absent(), + Value marker_ = const Value.absent(), + }) => LocalAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + linkedRemoteAlbumId: linkedRemoteAlbumId.present + ? linkedRemoteAlbumId.value + : this.linkedRemoteAlbumId, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumEntityData copyWithCompanion(LocalAlbumEntityCompanion data) { + return LocalAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + backupSelection: data.backupSelection.present + ? data.backupSelection.value + : this.backupSelection, + isIosSharedAlbum: data.isIosSharedAlbum.present + ? data.isIosSharedAlbum.value + : this.isIosSharedAlbum, + linkedRemoteAlbumId: data.linkedRemoteAlbumId.present + ? data.linkedRemoteAlbumId.value + : this.linkedRemoteAlbumId, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + updatedAt, + backupSelection, + isIosSharedAlbum, + linkedRemoteAlbumId, + marker_, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.updatedAt == this.updatedAt && + other.backupSelection == this.backupSelection && + other.isIosSharedAlbum == this.isIosSharedAlbum && + other.linkedRemoteAlbumId == this.linkedRemoteAlbumId && + other.marker_ == this.marker_); +} + +class LocalAlbumEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value updatedAt; + final Value backupSelection; + final Value isIosSharedAlbum; + final Value linkedRemoteAlbumId; + final Value marker_; + const LocalAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.updatedAt = const Value.absent(), + this.backupSelection = const Value.absent(), + this.isIosSharedAlbum = const Value.absent(), + this.linkedRemoteAlbumId = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumEntityCompanion.insert({ + required String id, + required String name, + this.updatedAt = const Value.absent(), + required int backupSelection, + this.isIosSharedAlbum = const Value.absent(), + this.linkedRemoteAlbumId = const Value.absent(), + this.marker_ = const Value.absent(), + }) : id = Value(id), + name = Value(name), + backupSelection = Value(backupSelection); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? updatedAt, + Expression? backupSelection, + Expression? isIosSharedAlbum, + Expression? linkedRemoteAlbumId, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (updatedAt != null) 'updated_at': updatedAt, + if (backupSelection != null) 'backup_selection': backupSelection, + if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum, + if (linkedRemoteAlbumId != null) + 'linked_remote_album_id': linkedRemoteAlbumId, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumEntityCompanion copyWith({ + Value? id, + Value? name, + Value? updatedAt, + Value? backupSelection, + Value? isIosSharedAlbum, + Value? linkedRemoteAlbumId, + Value? marker_, + }) { + return LocalAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (backupSelection.present) { + map['backup_selection'] = Variable(backupSelection.value); + } + if (isIosSharedAlbum.present) { + map['is_ios_shared_album'] = Variable(isIosSharedAlbum.value); + } + if (linkedRemoteAlbumId.present) { + map['linked_remote_album_id'] = Variable( + linkedRemoteAlbumId.value, + ); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class LocalAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_album_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("marker" IN (0, 1))', + ), + ); + @override + List get $columns => [assetId, albumId, marker_]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + LocalAlbumAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + marker_: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}marker'], + ), + ); + } + + @override + LocalAlbumAssetEntity createAlias(String alias) { + return LocalAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + final bool? marker_; + const LocalAlbumAssetEntityData({ + required this.assetId, + required this.albumId, + this.marker_, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumAssetEntityData copyWith({ + String? assetId, + String? albumId, + Value marker_ = const Value.absent(), + }) => LocalAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumAssetEntityData copyWithCompanion( + LocalAlbumAssetEntityCompanion data, + ) { + return LocalAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId, marker_); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId && + other.marker_ == this.marker_); +} + +class LocalAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + final Value marker_; + const LocalAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + this.marker_ = const Value.absent(), + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumAssetEntityCompanion copyWith({ + Value? assetId, + Value? albumId, + Value? marker_, + }) { + return LocalAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class AuthUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AuthUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isAdmin = GeneratedColumn( + 'is_admin', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_admin" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn hasProfileImage = GeneratedColumn( + 'has_profile_image', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("has_profile_image" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn profileChangedAt = + GeneratedColumn( + 'profile_changed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn quotaSizeInBytes = GeneratedColumn( + 'quota_size_in_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn quotaUsageInBytes = GeneratedColumn( + 'quota_usage_in_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn pinCode = GeneratedColumn( + 'pin_code', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + name, + email, + isAdmin, + hasProfileImage, + profileChangedAt, + avatarColor, + quotaSizeInBytes, + quotaUsageInBytes, + pinCode, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'auth_user_entity'; + @override + Set get $primaryKey => {id}; + @override + AuthUserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AuthUserEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + isAdmin: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_admin'], + )!, + hasProfileImage: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}has_profile_image'], + )!, + profileChangedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}profile_changed_at'], + )!, + avatarColor: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}avatar_color'], + )!, + quotaSizeInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}quota_size_in_bytes'], + )!, + quotaUsageInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}quota_usage_in_bytes'], + )!, + pinCode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}pin_code'], + ), + ); + } + + @override + AuthUserEntity createAlias(String alias) { + return AuthUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AuthUserEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String email; + final bool isAdmin; + final bool hasProfileImage; + final DateTime profileChangedAt; + final int avatarColor; + final int quotaSizeInBytes; + final int quotaUsageInBytes; + final String? pinCode; + const AuthUserEntityData({ + required this.id, + required this.name, + required this.email, + required this.isAdmin, + required this.hasProfileImage, + required this.profileChangedAt, + required this.avatarColor, + required this.quotaSizeInBytes, + required this.quotaUsageInBytes, + this.pinCode, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['email'] = Variable(email); + map['is_admin'] = Variable(isAdmin); + map['has_profile_image'] = Variable(hasProfileImage); + map['profile_changed_at'] = Variable(profileChangedAt); + map['avatar_color'] = Variable(avatarColor); + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes); + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes); + if (!nullToAbsent || pinCode != null) { + map['pin_code'] = Variable(pinCode); + } + return map; + } + + factory AuthUserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AuthUserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + email: serializer.fromJson(json['email']), + isAdmin: serializer.fromJson(json['isAdmin']), + hasProfileImage: serializer.fromJson(json['hasProfileImage']), + profileChangedAt: serializer.fromJson(json['profileChangedAt']), + avatarColor: serializer.fromJson(json['avatarColor']), + quotaSizeInBytes: serializer.fromJson(json['quotaSizeInBytes']), + quotaUsageInBytes: serializer.fromJson(json['quotaUsageInBytes']), + pinCode: serializer.fromJson(json['pinCode']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'email': serializer.toJson(email), + 'isAdmin': serializer.toJson(isAdmin), + 'hasProfileImage': serializer.toJson(hasProfileImage), + 'profileChangedAt': serializer.toJson(profileChangedAt), + 'avatarColor': serializer.toJson(avatarColor), + 'quotaSizeInBytes': serializer.toJson(quotaSizeInBytes), + 'quotaUsageInBytes': serializer.toJson(quotaUsageInBytes), + 'pinCode': serializer.toJson(pinCode), + }; + } + + AuthUserEntityData copyWith({ + String? id, + String? name, + String? email, + bool? isAdmin, + bool? hasProfileImage, + DateTime? profileChangedAt, + int? avatarColor, + int? quotaSizeInBytes, + int? quotaUsageInBytes, + Value pinCode = const Value.absent(), + }) => AuthUserEntityData( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + isAdmin: isAdmin ?? this.isAdmin, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + pinCode: pinCode.present ? pinCode.value : this.pinCode, + ); + AuthUserEntityData copyWithCompanion(AuthUserEntityCompanion data) { + return AuthUserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + email: data.email.present ? data.email.value : this.email, + isAdmin: data.isAdmin.present ? data.isAdmin.value : this.isAdmin, + hasProfileImage: data.hasProfileImage.present + ? data.hasProfileImage.value + : this.hasProfileImage, + profileChangedAt: data.profileChangedAt.present + ? data.profileChangedAt.value + : this.profileChangedAt, + avatarColor: data.avatarColor.present + ? data.avatarColor.value + : this.avatarColor, + quotaSizeInBytes: data.quotaSizeInBytes.present + ? data.quotaSizeInBytes.value + : this.quotaSizeInBytes, + quotaUsageInBytes: data.quotaUsageInBytes.present + ? data.quotaUsageInBytes.value + : this.quotaUsageInBytes, + pinCode: data.pinCode.present ? data.pinCode.value : this.pinCode, + ); + } + + @override + String toString() { + return (StringBuffer('AuthUserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('isAdmin: $isAdmin, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes, ') + ..write('pinCode: $pinCode') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + email, + isAdmin, + hasProfileImage, + profileChangedAt, + avatarColor, + quotaSizeInBytes, + quotaUsageInBytes, + pinCode, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AuthUserEntityData && + other.id == this.id && + other.name == this.name && + other.email == this.email && + other.isAdmin == this.isAdmin && + other.hasProfileImage == this.hasProfileImage && + other.profileChangedAt == this.profileChangedAt && + other.avatarColor == this.avatarColor && + other.quotaSizeInBytes == this.quotaSizeInBytes && + other.quotaUsageInBytes == this.quotaUsageInBytes && + other.pinCode == this.pinCode); +} + +class AuthUserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value email; + final Value isAdmin; + final Value hasProfileImage; + final Value profileChangedAt; + final Value avatarColor; + final Value quotaSizeInBytes; + final Value quotaUsageInBytes; + final Value pinCode; + const AuthUserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.email = const Value.absent(), + this.isAdmin = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + this.pinCode = const Value.absent(), + }); + AuthUserEntityCompanion.insert({ + required String id, + required String name, + required String email, + this.isAdmin = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + required int avatarColor, + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + this.pinCode = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email), + avatarColor = Value(avatarColor); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? email, + Expression? isAdmin, + Expression? hasProfileImage, + Expression? profileChangedAt, + Expression? avatarColor, + Expression? quotaSizeInBytes, + Expression? quotaUsageInBytes, + Expression? pinCode, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (email != null) 'email': email, + if (isAdmin != null) 'is_admin': isAdmin, + if (hasProfileImage != null) 'has_profile_image': hasProfileImage, + if (profileChangedAt != null) 'profile_changed_at': profileChangedAt, + if (avatarColor != null) 'avatar_color': avatarColor, + if (quotaSizeInBytes != null) 'quota_size_in_bytes': quotaSizeInBytes, + if (quotaUsageInBytes != null) 'quota_usage_in_bytes': quotaUsageInBytes, + if (pinCode != null) 'pin_code': pinCode, + }); + } + + AuthUserEntityCompanion copyWith({ + Value? id, + Value? name, + Value? email, + Value? isAdmin, + Value? hasProfileImage, + Value? profileChangedAt, + Value? avatarColor, + Value? quotaSizeInBytes, + Value? quotaUsageInBytes, + Value? pinCode, + }) { + return AuthUserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + isAdmin: isAdmin ?? this.isAdmin, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + pinCode: pinCode ?? this.pinCode, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (isAdmin.present) { + map['is_admin'] = Variable(isAdmin.value); + } + if (hasProfileImage.present) { + map['has_profile_image'] = Variable(hasProfileImage.value); + } + if (profileChangedAt.present) { + map['profile_changed_at'] = Variable(profileChangedAt.value); + } + if (avatarColor.present) { + map['avatar_color'] = Variable(avatarColor.value); + } + if (quotaSizeInBytes.present) { + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes.value); + } + if (quotaUsageInBytes.present) { + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes.value); + } + if (pinCode.present) { + map['pin_code'] = Variable(pinCode.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AuthUserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('isAdmin: $isAdmin, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes, ') + ..write('pinCode: $pinCode') + ..write(')')) + .toString(); + } +} + +class UserMetadataEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserMetadataEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + ); + @override + List get $columns => [userId, key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_metadata_entity'; + @override + Set get $primaryKey => {userId, key}; + @override + UserMetadataEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserMetadataEntityData( + userId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_id'], + )!, + key: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + UserMetadataEntity createAlias(String alias) { + return UserMetadataEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserMetadataEntityData extends DataClass + implements Insertable { + final String userId; + final int key; + final Uint8List value; + const UserMetadataEntityData({ + required this.userId, + required this.key, + required this.value, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['user_id'] = Variable(userId); + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + factory UserMetadataEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserMetadataEntityData( + userId: serializer.fromJson(json['userId']), + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'userId': serializer.toJson(userId), + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + UserMetadataEntityData copyWith({ + String? userId, + int? key, + Uint8List? value, + }) => UserMetadataEntityData( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + UserMetadataEntityData copyWithCompanion(UserMetadataEntityCompanion data) { + return UserMetadataEntityData( + userId: data.userId.present ? data.userId.value : this.userId, + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityData(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(userId, key, $driftBlobEquality.hash(value)); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserMetadataEntityData && + other.userId == this.userId && + other.key == this.key && + $driftBlobEquality.equals(other.value, this.value)); +} + +class UserMetadataEntityCompanion + extends UpdateCompanion { + final Value userId; + final Value key; + final Value value; + const UserMetadataEntityCompanion({ + this.userId = const Value.absent(), + this.key = const Value.absent(), + this.value = const Value.absent(), + }); + UserMetadataEntityCompanion.insert({ + required String userId, + required int key, + required Uint8List value, + }) : userId = Value(userId), + key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? userId, + Expression? key, + Expression? value, + }) { + return RawValuesInsertable({ + if (userId != null) 'user_id': userId, + if (key != null) 'key': key, + if (value != null) 'value': value, + }); + } + + UserMetadataEntityCompanion copyWith({ + Value? userId, + Value? key, + Value? value, + }) { + return UserMetadataEntityCompanion( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityCompanion(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } +} + +class PartnerEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PartnerEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn sharedById = GeneratedColumn( + 'shared_by_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn sharedWithId = GeneratedColumn( + 'shared_with_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn inTimeline = GeneratedColumn( + 'in_timeline', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("in_timeline" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [sharedById, sharedWithId, inTimeline]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'partner_entity'; + @override + Set get $primaryKey => {sharedById, sharedWithId}; + @override + PartnerEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PartnerEntityData( + sharedById: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}shared_by_id'], + )!, + sharedWithId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}shared_with_id'], + )!, + inTimeline: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}in_timeline'], + )!, + ); + } + + @override + PartnerEntity createAlias(String alias) { + return PartnerEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PartnerEntityData extends DataClass + implements Insertable { + final String sharedById; + final String sharedWithId; + final bool inTimeline; + const PartnerEntityData({ + required this.sharedById, + required this.sharedWithId, + required this.inTimeline, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['shared_by_id'] = Variable(sharedById); + map['shared_with_id'] = Variable(sharedWithId); + map['in_timeline'] = Variable(inTimeline); + return map; + } + + factory PartnerEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PartnerEntityData( + sharedById: serializer.fromJson(json['sharedById']), + sharedWithId: serializer.fromJson(json['sharedWithId']), + inTimeline: serializer.fromJson(json['inTimeline']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'sharedById': serializer.toJson(sharedById), + 'sharedWithId': serializer.toJson(sharedWithId), + 'inTimeline': serializer.toJson(inTimeline), + }; + } + + PartnerEntityData copyWith({ + String? sharedById, + String? sharedWithId, + bool? inTimeline, + }) => PartnerEntityData( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + PartnerEntityData copyWithCompanion(PartnerEntityCompanion data) { + return PartnerEntityData( + sharedById: data.sharedById.present + ? data.sharedById.value + : this.sharedById, + sharedWithId: data.sharedWithId.present + ? data.sharedWithId.value + : this.sharedWithId, + inTimeline: data.inTimeline.present + ? data.inTimeline.value + : this.inTimeline, + ); + } + + @override + String toString() { + return (StringBuffer('PartnerEntityData(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(sharedById, sharedWithId, inTimeline); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PartnerEntityData && + other.sharedById == this.sharedById && + other.sharedWithId == this.sharedWithId && + other.inTimeline == this.inTimeline); +} + +class PartnerEntityCompanion extends UpdateCompanion { + final Value sharedById; + final Value sharedWithId; + final Value inTimeline; + const PartnerEntityCompanion({ + this.sharedById = const Value.absent(), + this.sharedWithId = const Value.absent(), + this.inTimeline = const Value.absent(), + }); + PartnerEntityCompanion.insert({ + required String sharedById, + required String sharedWithId, + this.inTimeline = const Value.absent(), + }) : sharedById = Value(sharedById), + sharedWithId = Value(sharedWithId); + static Insertable custom({ + Expression? sharedById, + Expression? sharedWithId, + Expression? inTimeline, + }) { + return RawValuesInsertable({ + if (sharedById != null) 'shared_by_id': sharedById, + if (sharedWithId != null) 'shared_with_id': sharedWithId, + if (inTimeline != null) 'in_timeline': inTimeline, + }); + } + + PartnerEntityCompanion copyWith({ + Value? sharedById, + Value? sharedWithId, + Value? inTimeline, + }) { + return PartnerEntityCompanion( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (sharedById.present) { + map['shared_by_id'] = Variable(sharedById.value); + } + if (sharedWithId.present) { + map['shared_with_id'] = Variable(sharedWithId.value); + } + if (inTimeline.present) { + map['in_timeline'] = Variable(inTimeline.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PartnerEntityCompanion(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } +} + +class RemoteExifEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteExifEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn city = GeneratedColumn( + 'city', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn state = GeneratedColumn( + 'state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn country = GeneratedColumn( + 'country', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn dateTimeOriginal = + GeneratedColumn( + 'date_time_original', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn exposureTime = GeneratedColumn( + 'exposure_time', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn fNumber = GeneratedColumn( + 'f_number', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn fileSize = GeneratedColumn( + 'file_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn focalLength = GeneratedColumn( + 'focal_length', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn iso = GeneratedColumn( + 'iso', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn make = GeneratedColumn( + 'make', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn model = GeneratedColumn( + 'model', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn lens = GeneratedColumn( + 'lens', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn timeZone = GeneratedColumn( + 'time_zone', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn rating = GeneratedColumn( + 'rating', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn projectionType = GeneratedColumn( + 'projection_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_exif_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteExifEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteExifEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + city: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}city'], + ), + state: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}state'], + ), + country: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}country'], + ), + dateTimeOriginal: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}date_time_original'], + ), + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + exposureTime: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}exposure_time'], + ), + fNumber: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}f_number'], + ), + fileSize: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}file_size'], + ), + focalLength: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}focal_length'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + iso: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}iso'], + ), + make: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}make'], + ), + model: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}model'], + ), + lens: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}lens'], + ), + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}orientation'], + ), + timeZone: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}time_zone'], + ), + rating: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}rating'], + ), + projectionType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}projection_type'], + ), + ); + } + + @override + RemoteExifEntity createAlias(String alias) { + return RemoteExifEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteExifEntityData extends DataClass + implements Insertable { + final String assetId; + final String? city; + final String? state; + final String? country; + final DateTime? dateTimeOriginal; + final String? description; + final int? height; + final int? width; + final String? exposureTime; + final double? fNumber; + final int? fileSize; + final double? focalLength; + final double? latitude; + final double? longitude; + final int? iso; + final String? make; + final String? model; + final String? lens; + final String? orientation; + final String? timeZone; + final int? rating; + final String? projectionType; + const RemoteExifEntityData({ + required this.assetId, + this.city, + this.state, + this.country, + this.dateTimeOriginal, + this.description, + this.height, + this.width, + this.exposureTime, + this.fNumber, + this.fileSize, + this.focalLength, + this.latitude, + this.longitude, + this.iso, + this.make, + this.model, + this.lens, + this.orientation, + this.timeZone, + this.rating, + this.projectionType, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || city != null) { + map['city'] = Variable(city); + } + if (!nullToAbsent || state != null) { + map['state'] = Variable(state); + } + if (!nullToAbsent || country != null) { + map['country'] = Variable(country); + } + if (!nullToAbsent || dateTimeOriginal != null) { + map['date_time_original'] = Variable(dateTimeOriginal); + } + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || exposureTime != null) { + map['exposure_time'] = Variable(exposureTime); + } + if (!nullToAbsent || fNumber != null) { + map['f_number'] = Variable(fNumber); + } + if (!nullToAbsent || fileSize != null) { + map['file_size'] = Variable(fileSize); + } + if (!nullToAbsent || focalLength != null) { + map['focal_length'] = Variable(focalLength); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + if (!nullToAbsent || iso != null) { + map['iso'] = Variable(iso); + } + if (!nullToAbsent || make != null) { + map['make'] = Variable(make); + } + if (!nullToAbsent || model != null) { + map['model'] = Variable(model); + } + if (!nullToAbsent || lens != null) { + map['lens'] = Variable(lens); + } + if (!nullToAbsent || orientation != null) { + map['orientation'] = Variable(orientation); + } + if (!nullToAbsent || timeZone != null) { + map['time_zone'] = Variable(timeZone); + } + if (!nullToAbsent || rating != null) { + map['rating'] = Variable(rating); + } + if (!nullToAbsent || projectionType != null) { + map['projection_type'] = Variable(projectionType); + } + return map; + } + + factory RemoteExifEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteExifEntityData( + assetId: serializer.fromJson(json['assetId']), + city: serializer.fromJson(json['city']), + state: serializer.fromJson(json['state']), + country: serializer.fromJson(json['country']), + dateTimeOriginal: serializer.fromJson( + json['dateTimeOriginal'], + ), + description: serializer.fromJson(json['description']), + height: serializer.fromJson(json['height']), + width: serializer.fromJson(json['width']), + exposureTime: serializer.fromJson(json['exposureTime']), + fNumber: serializer.fromJson(json['fNumber']), + fileSize: serializer.fromJson(json['fileSize']), + focalLength: serializer.fromJson(json['focalLength']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + iso: serializer.fromJson(json['iso']), + make: serializer.fromJson(json['make']), + model: serializer.fromJson(json['model']), + lens: serializer.fromJson(json['lens']), + orientation: serializer.fromJson(json['orientation']), + timeZone: serializer.fromJson(json['timeZone']), + rating: serializer.fromJson(json['rating']), + projectionType: serializer.fromJson(json['projectionType']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'city': serializer.toJson(city), + 'state': serializer.toJson(state), + 'country': serializer.toJson(country), + 'dateTimeOriginal': serializer.toJson(dateTimeOriginal), + 'description': serializer.toJson(description), + 'height': serializer.toJson(height), + 'width': serializer.toJson(width), + 'exposureTime': serializer.toJson(exposureTime), + 'fNumber': serializer.toJson(fNumber), + 'fileSize': serializer.toJson(fileSize), + 'focalLength': serializer.toJson(focalLength), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'iso': serializer.toJson(iso), + 'make': serializer.toJson(make), + 'model': serializer.toJson(model), + 'lens': serializer.toJson(lens), + 'orientation': serializer.toJson(orientation), + 'timeZone': serializer.toJson(timeZone), + 'rating': serializer.toJson(rating), + 'projectionType': serializer.toJson(projectionType), + }; + } + + RemoteExifEntityData copyWith({ + String? assetId, + Value city = const Value.absent(), + Value state = const Value.absent(), + Value country = const Value.absent(), + Value dateTimeOriginal = const Value.absent(), + Value description = const Value.absent(), + Value height = const Value.absent(), + Value width = const Value.absent(), + Value exposureTime = const Value.absent(), + Value fNumber = const Value.absent(), + Value fileSize = const Value.absent(), + Value focalLength = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value iso = const Value.absent(), + Value make = const Value.absent(), + Value model = const Value.absent(), + Value lens = const Value.absent(), + Value orientation = const Value.absent(), + Value timeZone = const Value.absent(), + Value rating = const Value.absent(), + Value projectionType = const Value.absent(), + }) => RemoteExifEntityData( + assetId: assetId ?? this.assetId, + city: city.present ? city.value : this.city, + state: state.present ? state.value : this.state, + country: country.present ? country.value : this.country, + dateTimeOriginal: dateTimeOriginal.present + ? dateTimeOriginal.value + : this.dateTimeOriginal, + description: description.present ? description.value : this.description, + height: height.present ? height.value : this.height, + width: width.present ? width.value : this.width, + exposureTime: exposureTime.present ? exposureTime.value : this.exposureTime, + fNumber: fNumber.present ? fNumber.value : this.fNumber, + fileSize: fileSize.present ? fileSize.value : this.fileSize, + focalLength: focalLength.present ? focalLength.value : this.focalLength, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + iso: iso.present ? iso.value : this.iso, + make: make.present ? make.value : this.make, + model: model.present ? model.value : this.model, + lens: lens.present ? lens.value : this.lens, + orientation: orientation.present ? orientation.value : this.orientation, + timeZone: timeZone.present ? timeZone.value : this.timeZone, + rating: rating.present ? rating.value : this.rating, + projectionType: projectionType.present + ? projectionType.value + : this.projectionType, + ); + RemoteExifEntityData copyWithCompanion(RemoteExifEntityCompanion data) { + return RemoteExifEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + city: data.city.present ? data.city.value : this.city, + state: data.state.present ? data.state.value : this.state, + country: data.country.present ? data.country.value : this.country, + dateTimeOriginal: data.dateTimeOriginal.present + ? data.dateTimeOriginal.value + : this.dateTimeOriginal, + description: data.description.present + ? data.description.value + : this.description, + height: data.height.present ? data.height.value : this.height, + width: data.width.present ? data.width.value : this.width, + exposureTime: data.exposureTime.present + ? data.exposureTime.value + : this.exposureTime, + fNumber: data.fNumber.present ? data.fNumber.value : this.fNumber, + fileSize: data.fileSize.present ? data.fileSize.value : this.fileSize, + focalLength: data.focalLength.present + ? data.focalLength.value + : this.focalLength, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + iso: data.iso.present ? data.iso.value : this.iso, + make: data.make.present ? data.make.value : this.make, + model: data.model.present ? data.model.value : this.model, + lens: data.lens.present ? data.lens.value : this.lens, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone, + rating: data.rating.present ? data.rating.value : this.rating, + projectionType: data.projectionType.present + ? data.projectionType.value + : this.projectionType, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityData(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType, + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteExifEntityData && + other.assetId == this.assetId && + other.city == this.city && + other.state == this.state && + other.country == this.country && + other.dateTimeOriginal == this.dateTimeOriginal && + other.description == this.description && + other.height == this.height && + other.width == this.width && + other.exposureTime == this.exposureTime && + other.fNumber == this.fNumber && + other.fileSize == this.fileSize && + other.focalLength == this.focalLength && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.iso == this.iso && + other.make == this.make && + other.model == this.model && + other.lens == this.lens && + other.orientation == this.orientation && + other.timeZone == this.timeZone && + other.rating == this.rating && + other.projectionType == this.projectionType); +} + +class RemoteExifEntityCompanion extends UpdateCompanion { + final Value assetId; + final Value city; + final Value state; + final Value country; + final Value dateTimeOriginal; + final Value description; + final Value height; + final Value width; + final Value exposureTime; + final Value fNumber; + final Value fileSize; + final Value focalLength; + final Value latitude; + final Value longitude; + final Value iso; + final Value make; + final Value model; + final Value lens; + final Value orientation; + final Value timeZone; + final Value rating; + final Value projectionType; + const RemoteExifEntityCompanion({ + this.assetId = const Value.absent(), + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }); + RemoteExifEntityCompanion.insert({ + required String assetId, + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? city, + Expression? state, + Expression? country, + Expression? dateTimeOriginal, + Expression? description, + Expression? height, + Expression? width, + Expression? exposureTime, + Expression? fNumber, + Expression? fileSize, + Expression? focalLength, + Expression? latitude, + Expression? longitude, + Expression? iso, + Expression? make, + Expression? model, + Expression? lens, + Expression? orientation, + Expression? timeZone, + Expression? rating, + Expression? projectionType, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (city != null) 'city': city, + if (state != null) 'state': state, + if (country != null) 'country': country, + if (dateTimeOriginal != null) 'date_time_original': dateTimeOriginal, + if (description != null) 'description': description, + if (height != null) 'height': height, + if (width != null) 'width': width, + if (exposureTime != null) 'exposure_time': exposureTime, + if (fNumber != null) 'f_number': fNumber, + if (fileSize != null) 'file_size': fileSize, + if (focalLength != null) 'focal_length': focalLength, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (iso != null) 'iso': iso, + if (make != null) 'make': make, + if (model != null) 'model': model, + if (lens != null) 'lens': lens, + if (orientation != null) 'orientation': orientation, + if (timeZone != null) 'time_zone': timeZone, + if (rating != null) 'rating': rating, + if (projectionType != null) 'projection_type': projectionType, + }); + } + + RemoteExifEntityCompanion copyWith({ + Value? assetId, + Value? city, + Value? state, + Value? country, + Value? dateTimeOriginal, + Value? description, + Value? height, + Value? width, + Value? exposureTime, + Value? fNumber, + Value? fileSize, + Value? focalLength, + Value? latitude, + Value? longitude, + Value? iso, + Value? make, + Value? model, + Value? lens, + Value? orientation, + Value? timeZone, + Value? rating, + Value? projectionType, + }) { + return RemoteExifEntityCompanion( + assetId: assetId ?? this.assetId, + city: city ?? this.city, + state: state ?? this.state, + country: country ?? this.country, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + description: description ?? this.description, + height: height ?? this.height, + width: width ?? this.width, + exposureTime: exposureTime ?? this.exposureTime, + fNumber: fNumber ?? this.fNumber, + fileSize: fileSize ?? this.fileSize, + focalLength: focalLength ?? this.focalLength, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + iso: iso ?? this.iso, + make: make ?? this.make, + model: model ?? this.model, + lens: lens ?? this.lens, + orientation: orientation ?? this.orientation, + timeZone: timeZone ?? this.timeZone, + rating: rating ?? this.rating, + projectionType: projectionType ?? this.projectionType, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (city.present) { + map['city'] = Variable(city.value); + } + if (state.present) { + map['state'] = Variable(state.value); + } + if (country.present) { + map['country'] = Variable(country.value); + } + if (dateTimeOriginal.present) { + map['date_time_original'] = Variable(dateTimeOriginal.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (exposureTime.present) { + map['exposure_time'] = Variable(exposureTime.value); + } + if (fNumber.present) { + map['f_number'] = Variable(fNumber.value); + } + if (fileSize.present) { + map['file_size'] = Variable(fileSize.value); + } + if (focalLength.present) { + map['focal_length'] = Variable(focalLength.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (iso.present) { + map['iso'] = Variable(iso.value); + } + if (make.present) { + map['make'] = Variable(make.value); + } + if (model.present) { + map['model'] = Variable(model.value); + } + if (lens.present) { + map['lens'] = Variable(lens.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (timeZone.present) { + map['time_zone'] = Variable(timeZone.value); + } + if (rating.present) { + map['rating'] = Variable(rating.value); + } + if (projectionType.present) { + map['projection_type'] = Variable(projectionType.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE', + ), + ); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + RemoteAlbumAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + ); + } + + @override + RemoteAlbumAssetEntity createAlias(String alias) { + return RemoteAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + const RemoteAlbumAssetEntityData({ + required this.assetId, + required this.albumId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + return map; + } + + factory RemoteAlbumAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + RemoteAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + RemoteAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + RemoteAlbumAssetEntityData copyWithCompanion( + RemoteAlbumAssetEntityCompanion data, + ) { + return RemoteAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class RemoteAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + const RemoteAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + }); + RemoteAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + RemoteAlbumAssetEntityCompanion copyWith({ + Value? assetId, + Value? albumId, + }) { + return RemoteAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn role = GeneratedColumn( + 'role', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [albumId, userId, role]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_user_entity'; + @override + Set get $primaryKey => {albumId, userId}; + @override + RemoteAlbumUserEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumUserEntityData( + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_id'], + )!, + role: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}role'], + )!, + ); + } + + @override + RemoteAlbumUserEntity createAlias(String alias) { + return RemoteAlbumUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumUserEntityData extends DataClass + implements Insertable { + final String albumId; + final String userId; + final int role; + const RemoteAlbumUserEntityData({ + required this.albumId, + required this.userId, + required this.role, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['album_id'] = Variable(albumId); + map['user_id'] = Variable(userId); + map['role'] = Variable(role); + return map; + } + + factory RemoteAlbumUserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumUserEntityData( + albumId: serializer.fromJson(json['albumId']), + userId: serializer.fromJson(json['userId']), + role: serializer.fromJson(json['role']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'albumId': serializer.toJson(albumId), + 'userId': serializer.toJson(userId), + 'role': serializer.toJson(role), + }; + } + + RemoteAlbumUserEntityData copyWith({ + String? albumId, + String? userId, + int? role, + }) => RemoteAlbumUserEntityData( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + RemoteAlbumUserEntityData copyWithCompanion( + RemoteAlbumUserEntityCompanion data, + ) { + return RemoteAlbumUserEntityData( + albumId: data.albumId.present ? data.albumId.value : this.albumId, + userId: data.userId.present ? data.userId.value : this.userId, + role: data.role.present ? data.role.value : this.role, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityData(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(albumId, userId, role); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumUserEntityData && + other.albumId == this.albumId && + other.userId == this.userId && + other.role == this.role); +} + +class RemoteAlbumUserEntityCompanion + extends UpdateCompanion { + final Value albumId; + final Value userId; + final Value role; + const RemoteAlbumUserEntityCompanion({ + this.albumId = const Value.absent(), + this.userId = const Value.absent(), + this.role = const Value.absent(), + }); + RemoteAlbumUserEntityCompanion.insert({ + required String albumId, + required String userId, + required int role, + }) : albumId = Value(albumId), + userId = Value(userId), + role = Value(role); + static Insertable custom({ + Expression? albumId, + Expression? userId, + Expression? role, + }) { + return RawValuesInsertable({ + if (albumId != null) 'album_id': albumId, + if (userId != null) 'user_id': userId, + if (role != null) 'role': role, + }); + } + + RemoteAlbumUserEntityCompanion copyWith({ + Value? albumId, + Value? userId, + Value? role, + }) { + return RemoteAlbumUserEntityCompanion( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityCompanion(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } +} + +class MemoryEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn data = GeneratedColumn( + 'data', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isSaved = GeneratedColumn( + 'is_saved', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_saved" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn memoryAt = GeneratedColumn( + 'memory_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + late final GeneratedColumn seenAt = GeneratedColumn( + 'seen_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn showAt = GeneratedColumn( + 'show_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn hideAt = GeneratedColumn( + 'hide_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_entity'; + @override + Set get $primaryKey => {id}; + @override + MemoryEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + data: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}data'], + )!, + isSaved: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_saved'], + )!, + memoryAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}memory_at'], + )!, + seenAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}seen_at'], + ), + showAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}show_at'], + ), + hideAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}hide_at'], + ), + ); + } + + @override + MemoryEntity createAlias(String alias) { + return MemoryEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String ownerId; + final int type; + final String data; + final bool isSaved; + final DateTime memoryAt; + final DateTime? seenAt; + final DateTime? showAt; + final DateTime? hideAt; + const MemoryEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.ownerId, + required this.type, + required this.data, + required this.isSaved, + required this.memoryAt, + this.seenAt, + this.showAt, + this.hideAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['owner_id'] = Variable(ownerId); + map['type'] = Variable(type); + map['data'] = Variable(data); + map['is_saved'] = Variable(isSaved); + map['memory_at'] = Variable(memoryAt); + if (!nullToAbsent || seenAt != null) { + map['seen_at'] = Variable(seenAt); + } + if (!nullToAbsent || showAt != null) { + map['show_at'] = Variable(showAt); + } + if (!nullToAbsent || hideAt != null) { + map['hide_at'] = Variable(hideAt); + } + return map; + } + + factory MemoryEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + ownerId: serializer.fromJson(json['ownerId']), + type: serializer.fromJson(json['type']), + data: serializer.fromJson(json['data']), + isSaved: serializer.fromJson(json['isSaved']), + memoryAt: serializer.fromJson(json['memoryAt']), + seenAt: serializer.fromJson(json['seenAt']), + showAt: serializer.fromJson(json['showAt']), + hideAt: serializer.fromJson(json['hideAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'ownerId': serializer.toJson(ownerId), + 'type': serializer.toJson(type), + 'data': serializer.toJson(data), + 'isSaved': serializer.toJson(isSaved), + 'memoryAt': serializer.toJson(memoryAt), + 'seenAt': serializer.toJson(seenAt), + 'showAt': serializer.toJson(showAt), + 'hideAt': serializer.toJson(hideAt), + }; + } + + MemoryEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + String? ownerId, + int? type, + String? data, + bool? isSaved, + DateTime? memoryAt, + Value seenAt = const Value.absent(), + Value showAt = const Value.absent(), + Value hideAt = const Value.absent(), + }) => MemoryEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt.present ? seenAt.value : this.seenAt, + showAt: showAt.present ? showAt.value : this.showAt, + hideAt: hideAt.present ? hideAt.value : this.hideAt, + ); + MemoryEntityData copyWithCompanion(MemoryEntityCompanion data) { + return MemoryEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + type: data.type.present ? data.type.value : this.type, + data: data.data.present ? data.data.value : this.data, + isSaved: data.isSaved.present ? data.isSaved.value : this.isSaved, + memoryAt: data.memoryAt.present ? data.memoryAt.value : this.memoryAt, + seenAt: data.seenAt.present ? data.seenAt.value : this.seenAt, + showAt: data.showAt.present ? data.showAt.value : this.showAt, + hideAt: data.hideAt.present ? data.hideAt.value : this.hideAt, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.ownerId == this.ownerId && + other.type == this.type && + other.data == this.data && + other.isSaved == this.isSaved && + other.memoryAt == this.memoryAt && + other.seenAt == this.seenAt && + other.showAt == this.showAt && + other.hideAt == this.hideAt); +} + +class MemoryEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value ownerId; + final Value type; + final Value data; + final Value isSaved; + final Value memoryAt; + final Value seenAt; + final Value showAt; + final Value hideAt; + const MemoryEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.type = const Value.absent(), + this.data = const Value.absent(), + this.isSaved = const Value.absent(), + this.memoryAt = const Value.absent(), + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }); + MemoryEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + required String ownerId, + required int type, + required String data, + this.isSaved = const Value.absent(), + required DateTime memoryAt, + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + type = Value(type), + data = Value(data), + memoryAt = Value(memoryAt); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? ownerId, + Expression? type, + Expression? data, + Expression? isSaved, + Expression? memoryAt, + Expression? seenAt, + Expression? showAt, + Expression? hideAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (ownerId != null) 'owner_id': ownerId, + if (type != null) 'type': type, + if (data != null) 'data': data, + if (isSaved != null) 'is_saved': isSaved, + if (memoryAt != null) 'memory_at': memoryAt, + if (seenAt != null) 'seen_at': seenAt, + if (showAt != null) 'show_at': showAt, + if (hideAt != null) 'hide_at': hideAt, + }); + } + + MemoryEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? ownerId, + Value? type, + Value? data, + Value? isSaved, + Value? memoryAt, + Value? seenAt, + Value? showAt, + Value? hideAt, + }) { + return MemoryEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt ?? this.seenAt, + showAt: showAt ?? this.showAt, + hideAt: hideAt ?? this.hideAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (data.present) { + map['data'] = Variable(data.value); + } + if (isSaved.present) { + map['is_saved'] = Variable(isSaved.value); + } + if (memoryAt.present) { + map['memory_at'] = Variable(memoryAt.value); + } + if (seenAt.present) { + map['seen_at'] = Variable(seenAt.value); + } + if (showAt.present) { + map['show_at'] = Variable(showAt.value); + } + if (hideAt.present) { + map['hide_at'] = Variable(hideAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } +} + +class MemoryAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn memoryId = GeneratedColumn( + 'memory_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES memory_entity (id) ON DELETE CASCADE', + ), + ); + @override + List get $columns => [assetId, memoryId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_asset_entity'; + @override + Set get $primaryKey => {assetId, memoryId}; + @override + MemoryAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + memoryId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}memory_id'], + )!, + ); + } + + @override + MemoryAssetEntity createAlias(String alias) { + return MemoryAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String memoryId; + const MemoryAssetEntityData({required this.assetId, required this.memoryId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['memory_id'] = Variable(memoryId); + return map; + } + + factory MemoryAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + memoryId: serializer.fromJson(json['memoryId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'memoryId': serializer.toJson(memoryId), + }; + } + + MemoryAssetEntityData copyWith({String? assetId, String? memoryId}) => + MemoryAssetEntityData( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + MemoryAssetEntityData copyWithCompanion(MemoryAssetEntityCompanion data) { + return MemoryAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + memoryId: data.memoryId.present ? data.memoryId.value : this.memoryId, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, memoryId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryAssetEntityData && + other.assetId == this.assetId && + other.memoryId == this.memoryId); +} + +class MemoryAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value memoryId; + const MemoryAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.memoryId = const Value.absent(), + }); + MemoryAssetEntityCompanion.insert({ + required String assetId, + required String memoryId, + }) : assetId = Value(assetId), + memoryId = Value(memoryId); + static Insertable custom({ + Expression? assetId, + Expression? memoryId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (memoryId != null) 'memory_id': memoryId, + }); + } + + MemoryAssetEntityCompanion copyWith({ + Value? assetId, + Value? memoryId, + }) { + return MemoryAssetEntityCompanion( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (memoryId.present) { + map['memory_id'] = Variable(memoryId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } +} + +class PersonEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PersonEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn faceAssetId = GeneratedColumn( + 'face_asset_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + ); + late final GeneratedColumn isHidden = GeneratedColumn( + 'is_hidden', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_hidden" IN (0, 1))', + ), + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn birthDate = GeneratedColumn( + 'birth_date', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + isFavorite, + isHidden, + color, + birthDate, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'person_entity'; + @override + Set get $primaryKey => {id}; + @override + PersonEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PersonEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + faceAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}face_asset_id'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + isHidden: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_hidden'], + )!, + color: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}color'], + ), + birthDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}birth_date'], + ), + ); + } + + @override + PersonEntity createAlias(String alias) { + return PersonEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PersonEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String name; + final String? faceAssetId; + final bool isFavorite; + final bool isHidden; + final String? color; + final DateTime? birthDate; + const PersonEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.name, + this.faceAssetId, + required this.isFavorite, + required this.isHidden, + this.color, + this.birthDate, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['name'] = Variable(name); + if (!nullToAbsent || faceAssetId != null) { + map['face_asset_id'] = Variable(faceAssetId); + } + map['is_favorite'] = Variable(isFavorite); + map['is_hidden'] = Variable(isHidden); + if (!nullToAbsent || color != null) { + map['color'] = Variable(color); + } + if (!nullToAbsent || birthDate != null) { + map['birth_date'] = Variable(birthDate); + } + return map; + } + + factory PersonEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PersonEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + name: serializer.fromJson(json['name']), + faceAssetId: serializer.fromJson(json['faceAssetId']), + isFavorite: serializer.fromJson(json['isFavorite']), + isHidden: serializer.fromJson(json['isHidden']), + color: serializer.fromJson(json['color']), + birthDate: serializer.fromJson(json['birthDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'name': serializer.toJson(name), + 'faceAssetId': serializer.toJson(faceAssetId), + 'isFavorite': serializer.toJson(isFavorite), + 'isHidden': serializer.toJson(isHidden), + 'color': serializer.toJson(color), + 'birthDate': serializer.toJson(birthDate), + }; + } + + PersonEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? name, + Value faceAssetId = const Value.absent(), + bool? isFavorite, + bool? isHidden, + Value color = const Value.absent(), + Value birthDate = const Value.absent(), + }) => PersonEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId.present ? faceAssetId.value : this.faceAssetId, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color.present ? color.value : this.color, + birthDate: birthDate.present ? birthDate.value : this.birthDate, + ); + PersonEntityData copyWithCompanion(PersonEntityCompanion data) { + return PersonEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + name: data.name.present ? data.name.value : this.name, + faceAssetId: data.faceAssetId.present + ? data.faceAssetId.value + : this.faceAssetId, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden, + color: data.color.present ? data.color.value : this.color, + birthDate: data.birthDate.present ? data.birthDate.value : this.birthDate, + ); + } + + @override + String toString() { + return (StringBuffer('PersonEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + isFavorite, + isHidden, + color, + birthDate, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PersonEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.name == this.name && + other.faceAssetId == this.faceAssetId && + other.isFavorite == this.isFavorite && + other.isHidden == this.isHidden && + other.color == this.color && + other.birthDate == this.birthDate); +} + +class PersonEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value name; + final Value faceAssetId; + final Value isFavorite; + final Value isHidden; + final Value color; + final Value birthDate; + const PersonEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.name = const Value.absent(), + this.faceAssetId = const Value.absent(), + this.isFavorite = const Value.absent(), + this.isHidden = const Value.absent(), + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }); + PersonEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String name, + this.faceAssetId = const Value.absent(), + required bool isFavorite, + required bool isHidden, + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + name = Value(name), + isFavorite = Value(isFavorite), + isHidden = Value(isHidden); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? name, + Expression? faceAssetId, + Expression? isFavorite, + Expression? isHidden, + Expression? color, + Expression? birthDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (name != null) 'name': name, + if (faceAssetId != null) 'face_asset_id': faceAssetId, + if (isFavorite != null) 'is_favorite': isFavorite, + if (isHidden != null) 'is_hidden': isHidden, + if (color != null) 'color': color, + if (birthDate != null) 'birth_date': birthDate, + }); + } + + PersonEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? name, + Value? faceAssetId, + Value? isFavorite, + Value? isHidden, + Value? color, + Value? birthDate, + }) { + return PersonEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId ?? this.faceAssetId, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color ?? this.color, + birthDate: birthDate ?? this.birthDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (faceAssetId.present) { + map['face_asset_id'] = Variable(faceAssetId.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (isHidden.present) { + map['is_hidden'] = Variable(isHidden.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (birthDate.present) { + map['birth_date'] = Variable(birthDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PersonEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } +} + +class AssetFaceEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AssetFaceEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn personId = GeneratedColumn( + 'person_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES person_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn imageWidth = GeneratedColumn( + 'image_width', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn imageHeight = GeneratedColumn( + 'image_height', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxX1 = GeneratedColumn( + 'bounding_box_x1', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxY1 = GeneratedColumn( + 'bounding_box_y1', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxX2 = GeneratedColumn( + 'bounding_box_x2', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxY2 = GeneratedColumn( + 'bounding_box_y2', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn sourceType = GeneratedColumn( + 'source_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'asset_face_entity'; + @override + Set get $primaryKey => {id}; + @override + AssetFaceEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AssetFaceEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + personId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}person_id'], + ), + imageWidth: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}image_width'], + )!, + imageHeight: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}image_height'], + )!, + boundingBoxX1: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_x1'], + )!, + boundingBoxY1: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_y1'], + )!, + boundingBoxX2: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_x2'], + )!, + boundingBoxY2: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_y2'], + )!, + sourceType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_type'], + )!, + ); + } + + @override + AssetFaceEntity createAlias(String alias) { + return AssetFaceEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AssetFaceEntityData extends DataClass + implements Insertable { + final String id; + final String assetId; + final String? personId; + final int imageWidth; + final int imageHeight; + final int boundingBoxX1; + final int boundingBoxY1; + final int boundingBoxX2; + final int boundingBoxY2; + final String sourceType; + const AssetFaceEntityData({ + required this.id, + required this.assetId, + this.personId, + required this.imageWidth, + required this.imageHeight, + required this.boundingBoxX1, + required this.boundingBoxY1, + required this.boundingBoxX2, + required this.boundingBoxY2, + required this.sourceType, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || personId != null) { + map['person_id'] = Variable(personId); + } + map['image_width'] = Variable(imageWidth); + map['image_height'] = Variable(imageHeight); + map['bounding_box_x1'] = Variable(boundingBoxX1); + map['bounding_box_y1'] = Variable(boundingBoxY1); + map['bounding_box_x2'] = Variable(boundingBoxX2); + map['bounding_box_y2'] = Variable(boundingBoxY2); + map['source_type'] = Variable(sourceType); + return map; + } + + factory AssetFaceEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AssetFaceEntityData( + id: serializer.fromJson(json['id']), + assetId: serializer.fromJson(json['assetId']), + personId: serializer.fromJson(json['personId']), + imageWidth: serializer.fromJson(json['imageWidth']), + imageHeight: serializer.fromJson(json['imageHeight']), + boundingBoxX1: serializer.fromJson(json['boundingBoxX1']), + boundingBoxY1: serializer.fromJson(json['boundingBoxY1']), + boundingBoxX2: serializer.fromJson(json['boundingBoxX2']), + boundingBoxY2: serializer.fromJson(json['boundingBoxY2']), + sourceType: serializer.fromJson(json['sourceType']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'assetId': serializer.toJson(assetId), + 'personId': serializer.toJson(personId), + 'imageWidth': serializer.toJson(imageWidth), + 'imageHeight': serializer.toJson(imageHeight), + 'boundingBoxX1': serializer.toJson(boundingBoxX1), + 'boundingBoxY1': serializer.toJson(boundingBoxY1), + 'boundingBoxX2': serializer.toJson(boundingBoxX2), + 'boundingBoxY2': serializer.toJson(boundingBoxY2), + 'sourceType': serializer.toJson(sourceType), + }; + } + + AssetFaceEntityData copyWith({ + String? id, + String? assetId, + Value personId = const Value.absent(), + int? imageWidth, + int? imageHeight, + int? boundingBoxX1, + int? boundingBoxY1, + int? boundingBoxX2, + int? boundingBoxY2, + String? sourceType, + }) => AssetFaceEntityData( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + personId: personId.present ? personId.value : this.personId, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1, + boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1, + boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, + boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, + sourceType: sourceType ?? this.sourceType, + ); + AssetFaceEntityData copyWithCompanion(AssetFaceEntityCompanion data) { + return AssetFaceEntityData( + id: data.id.present ? data.id.value : this.id, + assetId: data.assetId.present ? data.assetId.value : this.assetId, + personId: data.personId.present ? data.personId.value : this.personId, + imageWidth: data.imageWidth.present + ? data.imageWidth.value + : this.imageWidth, + imageHeight: data.imageHeight.present + ? data.imageHeight.value + : this.imageHeight, + boundingBoxX1: data.boundingBoxX1.present + ? data.boundingBoxX1.value + : this.boundingBoxX1, + boundingBoxY1: data.boundingBoxY1.present + ? data.boundingBoxY1.value + : this.boundingBoxY1, + boundingBoxX2: data.boundingBoxX2.present + ? data.boundingBoxX2.value + : this.boundingBoxX2, + boundingBoxY2: data.boundingBoxY2.present + ? data.boundingBoxY2.value + : this.boundingBoxY2, + sourceType: data.sourceType.present + ? data.sourceType.value + : this.sourceType, + ); + } + + @override + String toString() { + return (StringBuffer('AssetFaceEntityData(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('personId: $personId, ') + ..write('imageWidth: $imageWidth, ') + ..write('imageHeight: $imageHeight, ') + ..write('boundingBoxX1: $boundingBoxX1, ') + ..write('boundingBoxY1: $boundingBoxY1, ') + ..write('boundingBoxX2: $boundingBoxX2, ') + ..write('boundingBoxY2: $boundingBoxY2, ') + ..write('sourceType: $sourceType') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AssetFaceEntityData && + other.id == this.id && + other.assetId == this.assetId && + other.personId == this.personId && + other.imageWidth == this.imageWidth && + other.imageHeight == this.imageHeight && + other.boundingBoxX1 == this.boundingBoxX1 && + other.boundingBoxY1 == this.boundingBoxY1 && + other.boundingBoxX2 == this.boundingBoxX2 && + other.boundingBoxY2 == this.boundingBoxY2 && + other.sourceType == this.sourceType); +} + +class AssetFaceEntityCompanion extends UpdateCompanion { + final Value id; + final Value assetId; + final Value personId; + final Value imageWidth; + final Value imageHeight; + final Value boundingBoxX1; + final Value boundingBoxY1; + final Value boundingBoxX2; + final Value boundingBoxY2; + final Value sourceType; + const AssetFaceEntityCompanion({ + this.id = const Value.absent(), + this.assetId = const Value.absent(), + this.personId = const Value.absent(), + this.imageWidth = const Value.absent(), + this.imageHeight = const Value.absent(), + this.boundingBoxX1 = const Value.absent(), + this.boundingBoxY1 = const Value.absent(), + this.boundingBoxX2 = const Value.absent(), + this.boundingBoxY2 = const Value.absent(), + this.sourceType = const Value.absent(), + }); + AssetFaceEntityCompanion.insert({ + required String id, + required String assetId, + this.personId = const Value.absent(), + required int imageWidth, + required int imageHeight, + required int boundingBoxX1, + required int boundingBoxY1, + required int boundingBoxX2, + required int boundingBoxY2, + required String sourceType, + }) : id = Value(id), + assetId = Value(assetId), + imageWidth = Value(imageWidth), + imageHeight = Value(imageHeight), + boundingBoxX1 = Value(boundingBoxX1), + boundingBoxY1 = Value(boundingBoxY1), + boundingBoxX2 = Value(boundingBoxX2), + boundingBoxY2 = Value(boundingBoxY2), + sourceType = Value(sourceType); + static Insertable custom({ + Expression? id, + Expression? assetId, + Expression? personId, + Expression? imageWidth, + Expression? imageHeight, + Expression? boundingBoxX1, + Expression? boundingBoxY1, + Expression? boundingBoxX2, + Expression? boundingBoxY2, + Expression? sourceType, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (assetId != null) 'asset_id': assetId, + if (personId != null) 'person_id': personId, + if (imageWidth != null) 'image_width': imageWidth, + if (imageHeight != null) 'image_height': imageHeight, + if (boundingBoxX1 != null) 'bounding_box_x1': boundingBoxX1, + if (boundingBoxY1 != null) 'bounding_box_y1': boundingBoxY1, + if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2, + if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2, + if (sourceType != null) 'source_type': sourceType, + }); + } + + AssetFaceEntityCompanion copyWith({ + Value? id, + Value? assetId, + Value? personId, + Value? imageWidth, + Value? imageHeight, + Value? boundingBoxX1, + Value? boundingBoxY1, + Value? boundingBoxX2, + Value? boundingBoxY2, + Value? sourceType, + }) { + return AssetFaceEntityCompanion( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + personId: personId ?? this.personId, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1, + boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1, + boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, + boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, + sourceType: sourceType ?? this.sourceType, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (personId.present) { + map['person_id'] = Variable(personId.value); + } + if (imageWidth.present) { + map['image_width'] = Variable(imageWidth.value); + } + if (imageHeight.present) { + map['image_height'] = Variable(imageHeight.value); + } + if (boundingBoxX1.present) { + map['bounding_box_x1'] = Variable(boundingBoxX1.value); + } + if (boundingBoxY1.present) { + map['bounding_box_y1'] = Variable(boundingBoxY1.value); + } + if (boundingBoxX2.present) { + map['bounding_box_x2'] = Variable(boundingBoxX2.value); + } + if (boundingBoxY2.present) { + map['bounding_box_y2'] = Variable(boundingBoxY2.value); + } + if (sourceType.present) { + map['source_type'] = Variable(sourceType.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AssetFaceEntityCompanion(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('personId: $personId, ') + ..write('imageWidth: $imageWidth, ') + ..write('imageHeight: $imageHeight, ') + ..write('boundingBoxX1: $boundingBoxX1, ') + ..write('boundingBoxY1: $boundingBoxY1, ') + ..write('boundingBoxX2: $boundingBoxX2, ') + ..write('boundingBoxY2: $boundingBoxY2, ') + ..write('sourceType: $sourceType') + ..write(')')) + .toString(); + } +} + +class StoreEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StoreEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn stringValue = GeneratedColumn( + 'string_value', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn intValue = GeneratedColumn( + 'int_value', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [id, stringValue, intValue]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'store_entity'; + @override + Set get $primaryKey => {id}; + @override + StoreEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StoreEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + stringValue: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}string_value'], + ), + intValue: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}int_value'], + ), + ); + } + + @override + StoreEntity createAlias(String alias) { + return StoreEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StoreEntityData extends DataClass implements Insertable { + final int id; + final String? stringValue; + final int? intValue; + const StoreEntityData({required this.id, this.stringValue, this.intValue}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || stringValue != null) { + map['string_value'] = Variable(stringValue); + } + if (!nullToAbsent || intValue != null) { + map['int_value'] = Variable(intValue); + } + return map; + } + + factory StoreEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StoreEntityData( + id: serializer.fromJson(json['id']), + stringValue: serializer.fromJson(json['stringValue']), + intValue: serializer.fromJson(json['intValue']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'stringValue': serializer.toJson(stringValue), + 'intValue': serializer.toJson(intValue), + }; + } + + StoreEntityData copyWith({ + int? id, + Value stringValue = const Value.absent(), + Value intValue = const Value.absent(), + }) => StoreEntityData( + id: id ?? this.id, + stringValue: stringValue.present ? stringValue.value : this.stringValue, + intValue: intValue.present ? intValue.value : this.intValue, + ); + StoreEntityData copyWithCompanion(StoreEntityCompanion data) { + return StoreEntityData( + id: data.id.present ? data.id.value : this.id, + stringValue: data.stringValue.present + ? data.stringValue.value + : this.stringValue, + intValue: data.intValue.present ? data.intValue.value : this.intValue, + ); + } + + @override + String toString() { + return (StringBuffer('StoreEntityData(') + ..write('id: $id, ') + ..write('stringValue: $stringValue, ') + ..write('intValue: $intValue') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, stringValue, intValue); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StoreEntityData && + other.id == this.id && + other.stringValue == this.stringValue && + other.intValue == this.intValue); +} + +class StoreEntityCompanion extends UpdateCompanion { + final Value id; + final Value stringValue; + final Value intValue; + const StoreEntityCompanion({ + this.id = const Value.absent(), + this.stringValue = const Value.absent(), + this.intValue = const Value.absent(), + }); + StoreEntityCompanion.insert({ + required int id, + this.stringValue = const Value.absent(), + this.intValue = const Value.absent(), + }) : id = Value(id); + static Insertable custom({ + Expression? id, + Expression? stringValue, + Expression? intValue, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (stringValue != null) 'string_value': stringValue, + if (intValue != null) 'int_value': intValue, + }); + } + + StoreEntityCompanion copyWith({ + Value? id, + Value? stringValue, + Value? intValue, + }) { + return StoreEntityCompanion( + id: id ?? this.id, + stringValue: stringValue ?? this.stringValue, + intValue: intValue ?? this.intValue, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (stringValue.present) { + map['string_value'] = Variable(stringValue.value); + } + if (intValue.present) { + map['int_value'] = Variable(intValue.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StoreEntityCompanion(') + ..write('id: $id, ') + ..write('stringValue: $stringValue, ') + ..write('intValue: $intValue') + ..write(')')) + .toString(); + } +} + +class TrashedLocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TrashedLocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'trashed_local_asset_entity'; + @override + Set get $primaryKey => {id, albumId}; + @override + TrashedLocalAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TrashedLocalAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}orientation'], + )!, + ); + } + + @override + TrashedLocalAssetEntity createAlias(String alias) { + return TrashedLocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class TrashedLocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String albumId; + final String? checksum; + final bool isFavorite; + final int orientation; + const TrashedLocalAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.albumId, + this.checksum, + required this.isFavorite, + required this.orientation, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['album_id'] = Variable(albumId); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + return map; + } + + factory TrashedLocalAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TrashedLocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + albumId: serializer.fromJson(json['albumId']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'albumId': serializer.toJson(albumId), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + }; + } + + TrashedLocalAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? albumId, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation, + }) => TrashedLocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + albumId: albumId ?? this.albumId, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + TrashedLocalAssetEntityData copyWithCompanion( + TrashedLocalAssetEntityCompanion data, + ) { + return TrashedLocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + ); + } + + @override + String toString() { + return (StringBuffer('TrashedLocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('albumId: $albumId, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TrashedLocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.albumId == this.albumId && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation); +} + +class TrashedLocalAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value albumId; + final Value checksum; + final Value isFavorite; + final Value orientation; + const TrashedLocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.albumId = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + }); + TrashedLocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String albumId, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id), + albumId = Value(albumId); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? albumId, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (albumId != null) 'album_id': albumId, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + }); + } + + TrashedLocalAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? albumId, + Value? checksum, + Value? isFavorite, + Value? orientation, + }) { + return TrashedLocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + albumId: albumId ?? this.albumId, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TrashedLocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('albumId: $albumId, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV13 extends GeneratedDatabase { + DatabaseAtV13(QueryExecutor e) : super(e); + late final UserEntity userEntity = UserEntity(this); + late final RemoteAssetEntity remoteAssetEntity = RemoteAssetEntity(this); + late final StackEntity stackEntity = StackEntity(this); + late final LocalAssetEntity localAssetEntity = LocalAssetEntity(this); + late final RemoteAlbumEntity remoteAlbumEntity = RemoteAlbumEntity(this); + late final LocalAlbumEntity localAlbumEntity = LocalAlbumEntity(this); + late final LocalAlbumAssetEntity localAlbumAssetEntity = + LocalAlbumAssetEntity(this); + late final Index idxLocalAssetChecksum = Index( + 'idx_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)', + ); + late final Index idxRemoteAssetOwnerChecksum = Index( + 'idx_remote_asset_owner_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', + ); + late final Index uQRemoteAssetsOwnerChecksum = Index( + 'UQ_remote_assets_owner_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', + ); + late final Index uQRemoteAssetsOwnerLibraryChecksum = Index( + 'UQ_remote_assets_owner_library_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)', + ); + late final Index idxRemoteAssetChecksum = Index( + 'idx_remote_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)', + ); + late final AuthUserEntity authUserEntity = AuthUserEntity(this); + late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); + late final PartnerEntity partnerEntity = PartnerEntity(this); + late final RemoteExifEntity remoteExifEntity = RemoteExifEntity(this); + late final RemoteAlbumAssetEntity remoteAlbumAssetEntity = + RemoteAlbumAssetEntity(this); + late final RemoteAlbumUserEntity remoteAlbumUserEntity = + RemoteAlbumUserEntity(this); + late final MemoryEntity memoryEntity = MemoryEntity(this); + late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); + late final PersonEntity personEntity = PersonEntity(this); + late final AssetFaceEntity assetFaceEntity = AssetFaceEntity(this); + late final StoreEntity storeEntity = StoreEntity(this); + late final TrashedLocalAssetEntity trashedLocalAssetEntity = + TrashedLocalAssetEntity(this); + late final Index idxLatLng = Index( + 'idx_lat_lng', + 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', + ); + late final Index idxTrashedLocalAssetChecksum = Index( + 'idx_trashed_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', + ); + late final Index idxTrashedLocalAssetAlbum = Index( + 'idx_trashed_local_asset_album', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + userEntity, + remoteAssetEntity, + stackEntity, + localAssetEntity, + remoteAlbumEntity, + localAlbumEntity, + localAlbumAssetEntity, + idxLocalAssetChecksum, + idxRemoteAssetOwnerChecksum, + uQRemoteAssetsOwnerChecksum, + uQRemoteAssetsOwnerLibraryChecksum, + idxRemoteAssetChecksum, + authUserEntity, + userMetadataEntity, + partnerEntity, + remoteExifEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + assetFaceEntity, + storeEntity, + trashedLocalAssetEntity, + idxLatLng, + idxTrashedLocalAssetChecksum, + idxTrashedLocalAssetAlbum, + ]; + @override + int get schemaVersion => 13; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/mobile/test/fixtures/sync_stream.stub.dart b/mobile/test/fixtures/sync_stream.stub.dart index 23c750d6d9..523984f966 100644 --- a/mobile/test/fixtures/sync_stream.stub.dart +++ b/mobile/test/fixtures/sync_stream.stub.dart @@ -81,4 +81,67 @@ abstract final class SyncStreamStub { data: SyncMemoryAssetDeleteV1(assetId: "asset-2", memoryId: "memory-1"), ack: "8", ); + + static final assetDeleteV1 = SyncEvent( + type: SyncEntityType.assetDeleteV1, + data: SyncAssetDeleteV1(assetId: "remote-asset"), + ack: "asset-delete-ack", + ); + + static SyncEvent assetTrashed({ + required String id, + required String checksum, + required String ack, + DateTime? trashedAt, + }) { + return _assetV1( + id: id, + checksum: checksum, + deletedAt: trashedAt ?? DateTime(2025, 1, 1), + ack: ack, + ); + } + + static SyncEvent assetModified({ + required String id, + required String checksum, + required String ack, + }) { + return _assetV1( + id: id, + checksum: checksum, + deletedAt: null, + ack: ack, + ); + } + + static SyncEvent _assetV1({ + required String id, + required String checksum, + required DateTime? deletedAt, + required String ack, + }) { + return SyncEvent( + type: SyncEntityType.assetV1, + data: SyncAssetV1( + checksum: checksum, + deletedAt: deletedAt, + duration: '0', + fileCreatedAt: DateTime(2025), + fileModifiedAt: DateTime(2025, 1, 2), + id: id, + isFavorite: false, + libraryId: null, + livePhotoVideoId: null, + localDateTime: DateTime(2025, 1, 3), + originalFileName: '$id.jpg', + ownerId: 'owner', + stackId: null, + thumbhash: null, + type: AssetTypeEnum.IMAGE, + visibility: AssetVisibility.timeline, + ), + ack: ack, + ); + } } diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 44e756e88e..becfafe33d 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/infrastructure/repositories/storage.repository.dar import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; @@ -34,6 +35,8 @@ class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {} +class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {} + class MockStorageRepository extends Mock implements StorageRepository {} class MockDriftBackupRepository extends Mock implements DriftBackupRepository {} diff --git a/mobile/test/mocks/asset_entity.mock.dart b/mobile/test/mocks/asset_entity.mock.dart new file mode 100644 index 0000000000..fdea58315d --- /dev/null +++ b/mobile/test/mocks/asset_entity.mock.dart @@ -0,0 +1,4 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:photo_manager/photo_manager.dart'; + +class MockAssetEntity extends Mock implements AssetEntity {} diff --git a/mobile/test/modules/activity/activity_provider_test.dart b/mobile/test/modules/activity/activity_provider_test.dart index 7964b43cad..84eba62b70 100644 --- a/mobile/test/modules/activity/activity_provider_test.dart +++ b/mobile/test/modules/activity/activity_provider_test.dart @@ -33,6 +33,7 @@ final _activities = [ void main() { late ActivityServiceMock activityMock; late ActivityStatisticsMock activityStatisticsMock; + late ActivityStatisticsMock albumActivityStatisticsMock; late ProviderContainer container; late AlbumActivityProvider provider; late ListenerMock>> listener; @@ -44,17 +45,23 @@ void main() { setUp(() async { activityMock = ActivityServiceMock(); activityStatisticsMock = ActivityStatisticsMock(); + albumActivityStatisticsMock = ActivityStatisticsMock(); + container = TestUtils.createContainer( overrides: [ activityServiceProvider.overrideWith((ref) => activityMock), activityStatisticsProvider('test-album', 'test-asset').overrideWith(() => activityStatisticsMock), + activityStatisticsProvider('test-album').overrideWith(() => albumActivityStatisticsMock), ], ); // Mock values + when(() => activityStatisticsMock.build(any(), any())).thenReturn(0); + when(() => albumActivityStatisticsMock.build(any())).thenReturn(0); when( () => activityMock.getAllActivities('test-album', assetId: 'test-asset'), ).thenAnswer((_) async => [..._activities]); + when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]); // Init and wait for providers future to complete provider = albumActivityProvider('test-album', 'test-asset'); @@ -89,6 +96,10 @@ void main() { () => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'), ).thenAnswer((_) async => AsyncData(like)); + final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); + await container.read(provider.notifier).addLike(); verify(() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset')); @@ -99,6 +110,11 @@ void main() { // Never bump activity count for new likes verifyNever(() => activityStatisticsMock.addActivity()); + verifyNever(() => albumActivityStatisticsMock.addActivity()); + + final albumActivities = container.read(albumProvider).requireValue; + expect(albumActivities, hasLength(5)); + expect(albumActivities, contains(like)); }); test('Like failed', () async { @@ -107,6 +123,10 @@ void main() { () => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset'), ).thenAnswer((_) async => AsyncError(Exception('Mock'), StackTrace.current)); + final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); + await container.read(provider.notifier).addLike(); verify(() => activityMock.addActivity('test-album', ActivityType.like, assetId: 'test-asset')); @@ -114,6 +134,12 @@ void main() { final activities = await container.read(provider.future); expect(activities, hasLength(4)); expect(activities, isNot(contains(like))); + + verifyNever(() => albumActivityStatisticsMock.addActivity()); + + final albumActivities = container.read(albumProvider).requireValue; + expect(albumActivities, hasLength(4)); + expect(albumActivities, isNot(contains(like))); }); }); @@ -130,6 +156,7 @@ void main() { expect(activities, isNot(anyElement(predicate((Activity a) => a.id == '3')))); verifyNever(() => activityStatisticsMock.removeActivity()); + verifyNever(() => albumActivityStatisticsMock.removeActivity()); }); test('Remove Like failed', () async { @@ -140,6 +167,9 @@ void main() { final activities = await container.read(provider.future); expect(activities, hasLength(4)); expect(activities, anyElement(predicate((Activity a) => a.id == '3'))); + + verifyNever(() => activityStatisticsMock.removeActivity()); + verifyNever(() => albumActivityStatisticsMock.removeActivity()); }); test('Comment successfully removed', () async { @@ -151,23 +181,35 @@ void main() { expect(activities, isNot(anyElement(predicate((Activity a) => a.id == '1')))); verify(() => activityStatisticsMock.removeActivity()); + verify(() => albumActivityStatisticsMock.removeActivity()); + }); + + test('Removes activity from album state when asset scoped', () async { + when(() => activityMock.removeActivity('3')).thenAnswer((_) async => true); + when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]); + + final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); + + await container.read(provider.notifier).removeActivity('3'); + + final assetActivities = container.read(provider).requireValue; + final albumActivities = container.read(albumProvider).requireValue; + + expect(assetActivities, hasLength(3)); + expect(assetActivities, isNot(anyElement(predicate((Activity a) => a.id == '3')))); + + expect(albumActivities, hasLength(3)); + expect(albumActivities, isNot(anyElement(predicate((Activity a) => a.id == '3')))); + + verify(() => activityMock.removeActivity('3')); + verifyNever(() => activityStatisticsMock.removeActivity()); + verifyNever(() => albumActivityStatisticsMock.removeActivity()); }); }); group('addComment()', () { - late ActivityStatisticsMock albumActivityStatisticsMock; - - setUp(() { - albumActivityStatisticsMock = ActivityStatisticsMock(); - container = TestUtils.createContainer( - overrides: [ - activityServiceProvider.overrideWith((ref) => activityMock), - activityStatisticsProvider('test-album', 'test-asset').overrideWith(() => activityStatisticsMock), - activityStatisticsProvider('test-album').overrideWith(() => albumActivityStatisticsMock), - ], - ); - }); - test('Comment successfully added', () async { final comment = Activity( id: '5', @@ -178,6 +220,10 @@ void main() { assetId: 'test-asset', ); + final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); + when( () => activityMock.addActivity( 'test-album', @@ -206,6 +252,10 @@ void main() { verify(() => activityStatisticsMock.addActivity()); verify(() => albumActivityStatisticsMock.addActivity()); + + final albumActivities = container.read(albumProvider).requireValue; + expect(albumActivities, hasLength(5)); + expect(albumActivities, contains(comment)); }); test('Comment successfully added without assetId', () async { @@ -225,6 +275,8 @@ void main() { when(() => activityMock.getAllActivities('test-album')).thenAnswer((_) async => [..._activities]); final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); await container.read(albumProvider.notifier).addComment('Test-Comment'); verify( @@ -258,6 +310,10 @@ void main() { ), ).thenAnswer((_) async => AsyncError(Exception('Error'), StackTrace.current)); + final albumProvider = albumActivityProvider('test-album'); + container.read(albumProvider.notifier); + await container.read(albumProvider.future); + await container.read(provider.notifier).addComment('Test-Comment'); final activities = await container.read(provider.future); @@ -266,6 +322,10 @@ void main() { verifyNever(() => activityStatisticsMock.addActivity()); verifyNever(() => albumActivityStatisticsMock.addActivity()); + + final albumActivities = container.read(albumProvider).requireValue; + expect(albumActivities, hasLength(4)); + expect(albumActivities, isNot(contains(comment))); }); }); } diff --git a/mobile/test/services/hash_service_test.dart b/mobile/test/services/hash_service_test.dart index 74b8575e40..9429d434b0 100644 --- a/mobile/test/services/hash_service_test.dart +++ b/mobile/test/services/hash_service_test.dart @@ -12,16 +12,14 @@ import 'package:immich_mobile/infrastructure/repositories/device_asset.repositor import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:photo_manager/photo_manager.dart'; import '../fixtures/asset.stub.dart'; import '../infrastructure/repository.mock.dart'; import '../service.mocks.dart'; +import '../mocks/asset_entity.mock.dart'; class MockAsset extends Mock implements Asset {} -class MockAssetEntity extends Mock implements AssetEntity {} - void main() { late HashService sut; late BackgroundService mockBackgroundService; diff --git a/mobile/test/services/upload.service_test.dart b/mobile/test/services/upload.service_test.dart index b18ad7b7d4..d33126782f 100644 --- a/mobile/test/services/upload.service_test.dart +++ b/mobile/test/services/upload.service_test.dart @@ -12,14 +12,12 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart' import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/upload.service.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:photo_manager/photo_manager.dart'; import '../domain/service.mock.dart'; import '../fixtures/asset.stub.dart'; import '../infrastructure/repository.mock.dart'; import '../repository.mocks.dart'; - -class MockAssetEntity extends Mock implements AssetEntity {} +import '../mocks/asset_entity.mock.dart'; void main() { late UploadService sut; diff --git a/mobile/test/utils/semver_test.dart b/mobile/test/utils/semver_test.dart new file mode 100644 index 0000000000..8f1958a879 --- /dev/null +++ b/mobile/test/utils/semver_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/semver.dart'; + +void main() { + group('SemVer', () { + test('Parses valid semantic version strings correctly', () { + final version = SemVer.fromString('1.2.3'); + expect(version.major, 1); + expect(version.minor, 2); + expect(version.patch, 3); + }); + + test('Throws FormatException for invalid version strings', () { + expect(() => SemVer.fromString('1.2'), throwsFormatException); + expect(() => SemVer.fromString('a.b.c'), throwsFormatException); + expect(() => SemVer.fromString('1.2.3.4'), throwsFormatException); + }); + + test('Compares equal versons correctly', () { + final v1 = SemVer.fromString('1.2.3'); + final v2 = SemVer.fromString('1.2.3'); + expect(v1 == v2, isTrue); + expect(v1 > v2, isFalse); + expect(v1 < v2, isFalse); + }); + + test('Compares major version correctly', () { + final v1 = SemVer.fromString('2.0.0'); + final v2 = SemVer.fromString('1.9.9'); + expect(v1 == v2, isFalse); + expect(v1 > v2, isTrue); + expect(v1 < v2, isFalse); + }); + + test('Compares minor version correctly', () { + final v1 = SemVer.fromString('1.3.0'); + final v2 = SemVer.fromString('1.2.9'); + expect(v1 == v2, isFalse); + expect(v1 > v2, isTrue); + expect(v1 < v2, isFalse); + }); + + test('Compares patch version correctly', () { + final v1 = SemVer.fromString('1.2.4'); + final v2 = SemVer.fromString('1.2.3'); + expect(v1 == v2, isFalse); + expect(v1 > v2, isTrue); + expect(v1 < v2, isFalse); + }); + + test('Gives correct major difference type', () { + final v1 = SemVer.fromString('2.0.0'); + final v2 = SemVer.fromString('1.9.9'); + expect(v1.differenceType(v2), SemVerType.major); + }); + + test('Gives correct minor difference type', () { + final v1 = SemVer.fromString('1.3.0'); + final v2 = SemVer.fromString('1.2.9'); + expect(v1.differenceType(v2), SemVerType.minor); + }); + + test('Gives correct patch difference type', () { + final v1 = SemVer.fromString('1.2.4'); + final v2 = SemVer.fromString('1.2.3'); + expect(v1.differenceType(v2), SemVerType.patch); + }); + + test('Gives null difference type for equal versions', () { + final v1 = SemVer.fromString('1.2.3'); + final v2 = SemVer.fromString('1.2.3'); + expect(v1.differenceType(v2), isNull); + }); + + test('toString returns correct format', () { + final version = SemVer.fromString('1.2.3'); + expect(version.toString(), '1.2.3'); + }); + + test('Parses versions with leading v correctly', () { + final version1 = SemVer.fromString('v1.2.3'); + expect(version1.major, 1); + expect(version1.minor, 2); + expect(version1.patch, 3); + + final version2 = SemVer.fromString('V1.2.3'); + expect(version2.major, 1); + expect(version2.minor, 2); + expect(version2.patch, 3); + }); + }); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f2b225769a..d42aa0baa1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3,6 +3,7 @@ "paths": { "/activities": { "get": { + "description": "Returns a list of activities for the selected asset or album. The activities are returned in sorted order, with the oldest activities appearing first.", "operationId": "getActivities", "parameters": [ { @@ -75,13 +76,29 @@ "api_key": [] } ], + "summary": "List all activities", "tags": [ "Activities" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "activity.read", - "description": "This endpoint requires the `activity.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a like or a comment for an album, or an asset in an album.", "operationId": "createActivity", "parameters": [], "requestBody": { @@ -117,15 +134,31 @@ "api_key": [] } ], + "summary": "Create an activity", "tags": [ "Activities" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "activity.create", - "description": "This endpoint requires the `activity.create` permission." + "x-immich-state": "Stable" } }, "/activities/statistics": { "get": { + "description": "Returns the number of likes and comments for a given album or asset in an album.", "operationId": "getActivityStatistics", "parameters": [ { @@ -170,15 +203,31 @@ "api_key": [] } ], + "summary": "Retrieve activity statistics", "tags": [ "Activities" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "activity.statistics", - "description": "This endpoint requires the `activity.statistics` permission." + "x-immich-state": "Stable" } }, "/activities/{id}": { "delete": { + "description": "Removes a like or comment from a given album or asset in an album.", "operationId": "deleteActivity", "parameters": [ { @@ -207,15 +256,31 @@ "api_key": [] } ], + "summary": "Delete an activity", "tags": [ "Activities" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "activity.delete", - "description": "This endpoint requires the `activity.delete` permission." + "x-immich-state": "Stable" } }, "/admin/auth/unlink-all": { "post": { + "description": "Unlinks all OAuth accounts associated with user accounts in the system.", "operationId": "unlinkAllOAuthAccountsAdmin", "parameters": [], "responses": { @@ -234,16 +299,32 @@ "api_key": [] } ], + "summary": "Unlink all OAuth accounts", "tags": [ - "Auth (admin)" + "Authentication (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminAuth.unlinkAll", - "description": "This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission." + "x-immich-state": "Stable" } }, "/admin/notifications": { "post": { + "description": "Create a new notification for a specific user.", "operationId": "createNotification", "parameters": [], "requestBody": { @@ -279,14 +360,31 @@ "api_key": [] } ], + "summary": "Create a notification", "tags": [ - "Notifications (Admin)" + "Notifications (admin)" ], - "x-immich-admin-only": true + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/admin/notifications/templates/{name}": { "post": { + "description": "Retrieve a preview of the provided email template.", "operationId": "getNotificationTemplateAdmin", "parameters": [ { @@ -331,14 +429,31 @@ "api_key": [] } ], + "summary": "Render email template", "tags": [ - "Notifications (Admin)" + "Notifications (admin)" ], - "x-immich-admin-only": true + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/admin/notifications/test-email": { "post": { + "description": "Send a test email using the provided SMTP configuration.", "operationId": "sendTestEmailAdmin", "parameters": [], "requestBody": { @@ -374,14 +489,31 @@ "api_key": [] } ], + "summary": "Send test email", "tags": [ - "Notifications (Admin)" + "Notifications (admin)" ], - "x-immich-admin-only": true + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/admin/users": { "get": { + "description": "Search for users.", "operationId": "searchUsersAdmin", "parameters": [ { @@ -428,14 +560,30 @@ "api_key": [] } ], + "summary": "Search users", "tags": [ "Users (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminUser.read", - "description": "This endpoint is an admin-only route, and requires the `adminUser.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a new user.", "operationId": "createUserAdmin", "parameters": [], "requestBody": { @@ -471,16 +619,32 @@ "api_key": [] } ], + "summary": "Create a user", "tags": [ "Users (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminUser.create", - "description": "This endpoint is an admin-only route, and requires the `adminUser.create` permission." + "x-immich-state": "Stable" } }, "/admin/users/{id}": { "delete": { + "description": "Delete a user.", "operationId": "deleteUserAdmin", "parameters": [ { @@ -526,14 +690,30 @@ "api_key": [] } ], + "summary": "Delete a user", "tags": [ "Users (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminUser.delete", - "description": "This endpoint is an admin-only route, and requires the `adminUser.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a specific user by their ID.", "operationId": "getUserAdmin", "parameters": [ { @@ -569,14 +749,30 @@ "api_key": [] } ], + "summary": "Retrieve a user", "tags": [ "Users (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminUser.read", - "description": "This endpoint is an admin-only route, and requires the `adminUser.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update an existing user.", "operationId": "updateUserAdmin", "parameters": [ { @@ -622,16 +818,32 @@ "api_key": [] } ], + "summary": "Update a user", "tags": [ "Users (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminUser.update", - "description": "This endpoint is an admin-only route, and requires the `adminUser.update` permission." + "x-immich-state": "Stable" } }, "/admin/users/{id}/preferences": { "get": { + "description": "Retrieve the preferences of a specific user.", "operationId": "getUserPreferencesAdmin", "parameters": [ { @@ -667,14 +879,30 @@ "api_key": [] } ], + "summary": "Retrieve user preferences", "tags": [ "Users (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminUser.read", - "description": "This endpoint is an admin-only route, and requires the `adminUser.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update the preferences of a specific user.", "operationId": "updateUserPreferencesAdmin", "parameters": [ { @@ -720,16 +948,32 @@ "api_key": [] } ], + "summary": "Update user preferences", "tags": [ "Users (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminUser.update", - "description": "This endpoint is an admin-only route, and requires the `adminUser.update` permission." + "x-immich-state": "Stable" } }, "/admin/users/{id}/restore": { "post": { + "description": "Restore a previously deleted user.", "operationId": "restoreUserAdmin", "parameters": [ { @@ -765,16 +1009,32 @@ "api_key": [] } ], + "summary": "Restore a deleted user", "tags": [ "Users (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminUser.delete", - "description": "This endpoint is an admin-only route, and requires the `adminUser.delete` permission." + "x-immich-state": "Stable" } }, "/admin/users/{id}/sessions": { "get": { + "description": "Retrieve all sessions for a specific user.", "operationId": "getUserSessionsAdmin", "parameters": [ { @@ -813,16 +1073,32 @@ "api_key": [] } ], + "summary": "Retrieve user sessions", "tags": [ "Users (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminSession.read", - "description": "This endpoint is an admin-only route, and requires the `adminSession.read` permission." + "x-immich-state": "Stable" } }, "/admin/users/{id}/statistics": { "get": { + "description": "Retrieve asset statistics for a specific user.", "operationId": "getUserStatisticsAdmin", "parameters": [ { @@ -882,16 +1158,32 @@ "api_key": [] } ], + "summary": "Retrieve user statistics", "tags": [ "Users (admin)" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "adminUser.read", - "description": "This endpoint is an admin-only route, and requires the `adminUser.read` permission." + "x-immich-state": "Stable" } }, "/albums": { "get": { + "description": "Retrieve a list of albums available to the authenticated user.", "operationId": "getAllAlbums", "parameters": [ { @@ -939,13 +1231,29 @@ "api_key": [] } ], + "summary": "List all albums", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "album.read", - "description": "This endpoint requires the `album.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a new album. The album can also be created with initial users and assets.", "operationId": "createAlbum", "parameters": [], "requestBody": { @@ -981,15 +1289,31 @@ "api_key": [] } ], + "summary": "Create an album", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "album.create", - "description": "This endpoint requires the `album.create` permission." + "x-immich-state": "Stable" } }, "/albums/assets": { "put": { + "description": "Send a list of asset IDs and album IDs to add each asset to each album.", "operationId": "addAssetsToAlbums", "parameters": [ { @@ -1042,15 +1366,31 @@ "api_key": [] } ], + "summary": "Add assets to albums", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "albumAsset.create", - "description": "This endpoint requires the `albumAsset.create` permission." + "x-immich-state": "Stable" } }, "/albums/statistics": { "get": { + "description": "Returns statistics about the albums available to the authenticated user.", "operationId": "getAlbumStatistics", "parameters": [], "responses": { @@ -1076,15 +1416,31 @@ "api_key": [] } ], + "summary": "Retrieve album statistics", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "album.statistics", - "description": "This endpoint requires the `album.statistics` permission." + "x-immich-state": "Stable" } }, "/albums/{id}": { "delete": { + "description": "Delete a specific album by its ID. Note the album is initially trashed and then immediately scheduled for deletion, but relies on a background job to complete the process.", "operationId": "deleteAlbum", "parameters": [ { @@ -1113,13 +1469,29 @@ "api_key": [] } ], + "summary": "Delete an album", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "album.delete", - "description": "This endpoint requires the `album.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve information about a specific album by its ID.", "operationId": "getAlbumInfo", "parameters": [ { @@ -1179,13 +1551,29 @@ "api_key": [] } ], + "summary": "Retrieve an album", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "album.read", - "description": "This endpoint requires the `album.read` permission." + "x-immich-state": "Stable" }, "patch": { + "description": "Update the information of a specific album by its ID. This endpoint can be used to update the album name, description, sort order, etc. However, it is not used to add or remove assets or users from the album.", "operationId": "updateAlbumInfo", "parameters": [ { @@ -1231,15 +1619,31 @@ "api_key": [] } ], + "summary": "Update an album", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "album.update", - "description": "This endpoint requires the `album.update` permission." + "x-immich-state": "Stable" } }, "/albums/{id}/assets": { "delete": { + "description": "Remove multiple assets from a specific album by its ID.", "operationId": "removeAssetFromAlbum", "parameters": [ { @@ -1288,13 +1692,29 @@ "api_key": [] } ], + "summary": "Remove assets from an album", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "albumAsset.delete", - "description": "This endpoint requires the `albumAsset.delete` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Add multiple assets to a specific album by its ID.", "operationId": "addAssetsToAlbum", "parameters": [ { @@ -1359,15 +1779,31 @@ "api_key": [] } ], + "summary": "Add assets to an album", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "albumAsset.create", - "description": "This endpoint requires the `albumAsset.create` permission." + "x-immich-state": "Stable" } }, "/albums/{id}/user/{userId}": { "delete": { + "description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.", "operationId": "removeUserFromAlbum", "parameters": [ { @@ -1404,13 +1840,29 @@ "api_key": [] } ], + "summary": "Remove user from album", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "albumUser.delete", - "description": "This endpoint requires the `albumUser.delete` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Change the role for a specific user in a specific album.", "operationId": "updateAlbumUser", "parameters": [ { @@ -1457,15 +1909,31 @@ "api_key": [] } ], + "summary": "Update user role", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "albumUser.update", - "description": "This endpoint requires the `albumUser.update` permission." + "x-immich-state": "Stable" } }, "/albums/{id}/users": { "put": { + "description": "Share an album with multiple users. Each user can be given a specific role in the album.", "operationId": "addUsersToAlbum", "parameters": [ { @@ -1511,15 +1979,31 @@ "api_key": [] } ], + "summary": "Share album with users", "tags": [ "Albums" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "albumUser.create", - "description": "This endpoint requires the `albumUser.create` permission." + "x-immich-state": "Stable" } }, "/api-keys": { "get": { + "description": "Retrieve all API keys of the current user.", "operationId": "getApiKeys", "parameters": [], "responses": { @@ -1548,13 +2032,29 @@ "api_key": [] } ], + "summary": "List all API keys", "tags": [ - "API Keys" + "API keys" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } ], "x-immich-permission": "apiKey.read", - "description": "This endpoint requires the `apiKey.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Creates a new API key. It will be limited to the permissions specified.", "operationId": "createApiKey", "parameters": [], "requestBody": { @@ -1590,15 +2090,31 @@ "api_key": [] } ], + "summary": "Create an API key", "tags": [ - "API Keys" + "API keys" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } ], "x-immich-permission": "apiKey.create", - "description": "This endpoint requires the `apiKey.create` permission." + "x-immich-state": "Stable" } }, "/api-keys/me": { "get": { + "description": "Retrieve the API key that is used to access this endpoint.", "operationId": "getMyApiKey", "parameters": [], "responses": { @@ -1624,13 +2140,30 @@ "api_key": [] } ], + "summary": "Retrieve the current API key", "tags": [ - "API Keys" - ] + "API keys" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/api-keys/{id}": { "delete": { + "description": "Deletes an API key identified by its ID. The current user must own this API key.", "operationId": "deleteApiKey", "parameters": [ { @@ -1659,13 +2192,29 @@ "api_key": [] } ], + "summary": "Delete an API key", "tags": [ - "API Keys" + "API keys" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } ], "x-immich-permission": "apiKey.delete", - "description": "This endpoint requires the `apiKey.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve an API key by its ID. The current user must own this API key.", "operationId": "getApiKey", "parameters": [ { @@ -1701,13 +2250,29 @@ "api_key": [] } ], + "summary": "Retrieve an API key", "tags": [ - "API Keys" + "API keys" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } ], "x-immich-permission": "apiKey.read", - "description": "This endpoint requires the `apiKey.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Updates the name and permissions of an API key by its ID. The current user must own this API key.", "operationId": "updateApiKey", "parameters": [ { @@ -1753,15 +2318,31 @@ "api_key": [] } ], + "summary": "Update an API key", "tags": [ - "API Keys" + "API keys" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } ], "x-immich-permission": "apiKey.update", - "description": "This endpoint requires the `apiKey.update` permission." + "x-immich-state": "Stable" } }, "/assets": { "delete": { + "description": "Deletes multiple assets at the same time.", "operationId": "deleteAssets", "parameters": [], "requestBody": { @@ -1790,13 +2371,29 @@ "api_key": [] } ], + "summary": "Delete assets", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.delete", - "description": "This endpoint requires the `asset.delete` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Uploads a new asset to the server.", "operationId": "uploadAsset", "parameters": [ { @@ -1859,13 +2456,29 @@ "api_key": [] } ], + "summary": "Upload asset", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.upload", - "description": "This endpoint requires the `asset.upload` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Updates multiple assets at the same time.", "operationId": "updateAssets", "parameters": [], "requestBody": { @@ -1894,16 +2507,31 @@ "api_key": [] } ], + "summary": "Update assets", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.update", - "description": "This endpoint requires the `asset.update` permission." + "x-immich-state": "Stable" } }, "/assets/bulk-upload-check": { "post": { - "description": "Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.", + "description": "Determine which assets have already been uploaded to the server based on their SHA1 checksums.", "operationId": "checkBulkUpload", "parameters": [], "requestBody": { @@ -1939,15 +2567,31 @@ "api_key": [] } ], - "summary": "checkBulkUpload", + "summary": "Check bulk upload", "tags": [ "Assets" ], - "x-immich-permission": "asset.upload" + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-permission": "asset.upload", + "x-immich-state": "Stable" } }, "/assets/copy": { "put": { + "description": "Copy asset information like albums, tags, etc. from one asset to another.", "operationId": "copyAsset", "parameters": [], "requestBody": { @@ -1976,15 +2620,31 @@ "api_key": [] } ], + "summary": "Copy asset", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.copy", - "description": "This endpoint requires the `asset.copy` permission." + "x-immich-state": "Stable" } }, "/assets/device/{deviceId}": { "get": { + "deprecated": true, "description": "Get all asset of a device that are in the database, ID only.", "operationId": "getAllUserAssetsByDeviceId", "parameters": [ @@ -2023,10 +2683,22 @@ "api_key": [] } ], - "summary": "getAllUserAssetsByDeviceId", + "summary": "Retrieve assets by device ID", "tags": [ - "Assets" - ] + "Assets", + "Deprecated" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" } }, "/assets/exist": { @@ -2067,14 +2739,30 @@ "api_key": [] } ], - "summary": "checkExistingAssets", + "summary": "Check existing assets", "tags": [ "Assets" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/assets/jobs": { "post": { + "description": "Run a specific job on a set of assets.", "operationId": "runAssetJobs", "parameters": [], "requestBody": { @@ -2103,15 +2791,31 @@ "api_key": [] } ], + "summary": "Run an asset job", "tags": [ "Assets" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/assets/random": { "get": { "deprecated": true, - "description": "This property was deprecated in v1.116.0. This endpoint requires the `asset.read` permission.", + "description": "Retrieve a specified number of random assets for the authenticated user.", "operationId": "getRandom", "parameters": [ { @@ -2150,18 +2854,29 @@ "api_key": [] } ], + "summary": "Get random assets", "tags": [ "Assets", "Deprecated" ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.116.0" - }, - "x-immich-permission": "asset.read" + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Deprecated", + "replacementId": "searchAssets" + } + ], + "x-immich-permission": "asset.read", + "x-immich-state": "Deprecated" } }, "/assets/statistics": { "get": { + "description": "Retrieve various statistics about the assets owned by the authenticated user.", "operationId": "getAssetStatistics", "parameters": [ { @@ -2212,15 +2927,31 @@ "api_key": [] } ], + "summary": "Get asset statistics", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.statistics", - "description": "This endpoint requires the `asset.statistics` permission." + "x-immich-state": "Stable" } }, "/assets/{id}": { "get": { + "description": "Retrieve detailed information about a specific asset.", "operationId": "getAssetInfo", "parameters": [ { @@ -2272,13 +3003,29 @@ "api_key": [] } ], + "summary": "Retrieve an asset", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update information of a specific asset.", "operationId": "updateAsset", "parameters": [ { @@ -2324,15 +3071,31 @@ "api_key": [] } ], + "summary": "Update an asset", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.update", - "description": "This endpoint requires the `asset.update` permission." + "x-immich-state": "Stable" } }, "/assets/{id}/metadata": { "get": { + "description": "Retrieve all metadata key-value pairs associated with the specified asset.", "operationId": "getAssetMetadata", "parameters": [ { @@ -2371,13 +3134,29 @@ "api_key": [] } ], + "summary": "Get asset metadata", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update or add metadata key-value pairs for the specified asset.", "operationId": "updateAssetMetadata", "parameters": [ { @@ -2426,15 +3205,31 @@ "api_key": [] } ], + "summary": "Update asset metadata", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.update", - "description": "This endpoint requires the `asset.update` permission." + "x-immich-state": "Stable" } }, "/assets/{id}/metadata/{key}": { "delete": { + "description": "Delete a specific metadata key-value pair associated with the specified asset.", "operationId": "deleteAssetMetadata", "parameters": [ { @@ -2471,13 +3266,29 @@ "api_key": [] } ], + "summary": "Delete asset metadata by key", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.update", - "description": "This endpoint requires the `asset.update` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve the value of a specific metadata key associated with the specified asset.", "operationId": "getAssetMetadataByKey", "parameters": [ { @@ -2521,15 +3332,31 @@ "api_key": [] } ], + "summary": "Retrieve asset metadata by key", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" } }, "/assets/{id}/ocr": { "get": { + "description": "Retrieve all OCR (Optical Character Recognition) data associated with the specified asset.", "operationId": "getAssetOcr", "parameters": [ { @@ -2568,15 +3395,31 @@ "api_key": [] } ], + "summary": "Retrieve asset OCR data", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" } }, "/assets/{id}/original": { "get": { + "description": "Downloads the original file of the specified asset.", "operationId": "downloadAsset", "parameters": [ { @@ -2629,15 +3472,30 @@ "api_key": [] } ], + "summary": "Download original asset", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.download", - "description": "This endpoint requires the `asset.download` permission." + "x-immich-state": "Stable" }, "put": { "deprecated": true, - "description": "This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id. This endpoint requires the `asset.replace` permission.", + "description": "Replace the asset with new file, without changing its id.", "operationId": "replaceAsset", "parameters": [ { @@ -2699,20 +3557,29 @@ "api_key": [] } ], - "summary": "Replace the asset with new file, without changing its id", + "summary": "Replace asset", "tags": [ "Assets", "Deprecated" ], - "x-immich-lifecycle": { - "addedAt": "v1.106.0", - "deprecatedAt": "v1.142.0" - }, - "x-immich-permission": "asset.replace" + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Deprecated", + "replacementId": "copyAsset" + } + ], + "x-immich-permission": "asset.replace", + "x-immich-state": "Deprecated" } }, "/assets/{id}/thumbnail": { "get": { + "description": "Retrieve the thumbnail image for the specified asset.", "operationId": "viewAsset", "parameters": [ { @@ -2773,15 +3640,31 @@ "api_key": [] } ], + "summary": "View asset thumbnail", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.view", - "description": "This endpoint requires the `asset.view` permission." + "x-immich-state": "Stable" } }, "/assets/{id}/video/playback": { "get": { + "description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.", "operationId": "playAssetVideo", "parameters": [ { @@ -2834,15 +3717,31 @@ "api_key": [] } ], + "summary": "Play asset video", "tags": [ "Assets" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.view", - "description": "This endpoint requires the `asset.view` permission." + "x-immich-state": "Stable" } }, "/auth/admin-sign-up": { "post": { + "description": "Create the first admin user in the system.", "operationId": "signUpAdmin", "parameters": [], "requestBody": { @@ -2867,13 +3766,30 @@ "description": "" } }, + "summary": "Register admin", "tags": [ "Authentication" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/auth/change-password": { "post": { + "description": "Change the password of the current user.", "operationId": "changePassword", "parameters": [], "requestBody": { @@ -2909,15 +3825,31 @@ "api_key": [] } ], + "summary": "Change password", "tags": [ "Authentication" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "auth.changePassword", - "description": "This endpoint requires the `auth.changePassword` permission." + "x-immich-state": "Stable" } }, "/auth/login": { "post": { + "description": "Login with username and password and receive a session token.", "operationId": "login", "parameters": [], "requestBody": { @@ -2942,13 +3874,30 @@ "description": "" } }, + "summary": "Login", "tags": [ "Authentication" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/auth/logout": { "post": { + "description": "Logout the current user and invalidate the session token.", "operationId": "logout", "parameters": [], "responses": { @@ -2974,13 +3923,30 @@ "api_key": [] } ], + "summary": "Logout", "tags": [ "Authentication" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/auth/pin-code": { "delete": { + "description": "Reset the pin code for the current user by providing the account password", "operationId": "resetPinCode", "parameters": [], "requestBody": { @@ -3009,13 +3975,29 @@ "api_key": [] } ], + "summary": "Reset pin code", "tags": [ "Authentication" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "pinCode.delete", - "description": "This endpoint requires the `pinCode.delete` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Setup a new pin code for the current user.", "operationId": "setupPinCode", "parameters": [], "requestBody": { @@ -3044,13 +4026,29 @@ "api_key": [] } ], + "summary": "Setup pin code", "tags": [ "Authentication" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "pinCode.create", - "description": "This endpoint requires the `pinCode.create` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Change the pin code for the current user.", "operationId": "changePinCode", "parameters": [], "requestBody": { @@ -3079,15 +4077,31 @@ "api_key": [] } ], + "summary": "Change pin code", "tags": [ "Authentication" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "pinCode.update", - "description": "This endpoint requires the `pinCode.update` permission." + "x-immich-state": "Stable" } }, "/auth/session/lock": { "post": { + "description": "Remove elevated access to locked assets from the current session.", "operationId": "lockAuthSession", "parameters": [], "responses": { @@ -3106,13 +4120,30 @@ "api_key": [] } ], + "summary": "Lock auth session", "tags": [ "Authentication" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/auth/session/unlock": { "post": { + "description": "Temporarily grant the session elevated access to locked assets by providing the correct PIN code.", "operationId": "unlockAuthSession", "parameters": [], "requestBody": { @@ -3141,13 +4172,30 @@ "api_key": [] } ], + "summary": "Unlock auth session", "tags": [ "Authentication" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/auth/status": { "get": { + "description": "Get information about the current session, including whether the user has a password, and if the session can access locked assets.", "operationId": "getAuthStatus", "parameters": [], "responses": { @@ -3173,6 +4221,7 @@ "api_key": [] } ], + "summary": "Retrieve auth status", "tags": [ "Authentication" ] @@ -3180,6 +4229,7 @@ }, "/auth/validateToken": { "post": { + "description": "Validate the current authorization method is still valid.", "operationId": "validateAccessToken", "parameters": [], "responses": { @@ -3205,13 +4255,30 @@ "api_key": [] } ], + "summary": "Validate access token", "tags": [ "Authentication" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/download/archive": { "post": { + "description": "Download a ZIP archive containing the specified assets. The assets must have been previously requested via the \"getDownloadInfo\" endpoint.", "operationId": "downloadArchive", "parameters": [ { @@ -3265,15 +4332,31 @@ "api_key": [] } ], + "summary": "Download asset archive", "tags": [ "Download" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.download", - "description": "This endpoint requires the `asset.download` permission." + "x-immich-state": "Stable" } }, "/download/info": { "post": { + "description": "Retrieve information about how to request a download for the specified assets or album. The response includes groups of assets that can be downloaded together.", "operationId": "getDownloadInfo", "parameters": [ { @@ -3326,15 +4409,31 @@ "api_key": [] } ], + "summary": "Retrieve download information", "tags": [ "Download" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.download", - "description": "This endpoint requires the `asset.download` permission." + "x-immich-state": "Stable" } }, "/duplicates": { "delete": { + "description": "Delete multiple duplicate assets specified by their IDs.", "operationId": "deleteDuplicates", "parameters": [], "requestBody": { @@ -3363,13 +4462,29 @@ "api_key": [] } ], + "summary": "Delete duplicates", "tags": [ "Duplicates" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "duplicate.delete", - "description": "This endpoint requires the `duplicate.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a list of duplicate assets available to the authenticated user.", "operationId": "getAssetDuplicates", "parameters": [], "responses": { @@ -3398,15 +4513,31 @@ "api_key": [] } ], + "summary": "Retrieve duplicates", "tags": [ "Duplicates" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "duplicate.read", - "description": "This endpoint requires the `duplicate.read` permission." + "x-immich-state": "Stable" } }, "/duplicates/{id}": { "delete": { + "description": "Delete a single duplicate asset specified by its ID.", "operationId": "deleteDuplicate", "parameters": [ { @@ -3435,15 +4566,31 @@ "api_key": [] } ], + "summary": "Delete a duplicate", "tags": [ "Duplicates" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "duplicate.delete", - "description": "This endpoint requires the `duplicate.delete` permission." + "x-immich-state": "Stable" } }, "/faces": { "get": { + "description": "Retrieve all faces belonging to an asset.", "operationId": "getFaces", "parameters": [ { @@ -3482,13 +4629,29 @@ "api_key": [] } ], + "summary": "Retrieve faces for asset", "tags": [ "Faces" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "face.read", - "description": "This endpoint requires the `face.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a new face that has not been discovered by facial recognition. The content of the bounding box is considered a face.", "operationId": "createFace", "parameters": [], "requestBody": { @@ -3517,15 +4680,31 @@ "api_key": [] } ], + "summary": "Create a face", "tags": [ "Faces" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "face.create", - "description": "This endpoint requires the `face.create` permission." + "x-immich-state": "Stable" } }, "/faces/{id}": { "delete": { + "description": "Delete a face identified by the id. Optionally can be force deleted.", "operationId": "deleteFace", "parameters": [ { @@ -3564,13 +4743,29 @@ "api_key": [] } ], + "summary": "Delete a face", "tags": [ "Faces" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "face.delete", - "description": "This endpoint requires the `face.delete` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Re-assign the face provided in the body to the person identified by the id in the path parameter.", "operationId": "reassignFacesById", "parameters": [ { @@ -3616,23 +4811,39 @@ "api_key": [] } ], + "summary": "Re-assign a face to another person", "tags": [ "Faces" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "face.update", - "description": "This endpoint requires the `face.update` permission." + "x-immich-state": "Stable" } }, "/jobs": { "get": { - "operationId": "getAllJobsStatus", + "description": "Retrieve the counts of the current queue, as well as the current status.", + "operationId": "getQueuesLegacy", "parameters": [], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AllJobStatusResponseDto" + "$ref": "#/components/schemas/QueuesResponseDto" } } }, @@ -3650,14 +4861,30 @@ "api_key": [] } ], + "summary": "Retrieve queue counts and status", "tags": [ "Jobs" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "job.read", - "description": "This endpoint is an admin-only route, and requires the `job.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup.", "operationId": "createJob", "parameters": [], "requestBody": { @@ -3686,24 +4913,40 @@ "api_key": [] } ], + "summary": "Create a manual job", "tags": [ "Jobs" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "job.create", - "description": "This endpoint is an admin-only route, and requires the `job.create` permission." + "x-immich-state": "Stable" } }, - "/jobs/{id}": { + "/jobs/{name}": { "put": { - "operationId": "sendJobCommand", + "description": "Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.", + "operationId": "runQueueCommandLegacy", "parameters": [ { - "name": "id", + "name": "name", "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/JobName" + "$ref": "#/components/schemas/QueueName" } } ], @@ -3711,7 +4954,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobCommandDto" + "$ref": "#/components/schemas/QueueCommandDto" } } }, @@ -3722,7 +4965,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobStatusDto" + "$ref": "#/components/schemas/QueueResponseDto" } } }, @@ -3740,16 +4983,32 @@ "api_key": [] } ], + "summary": "Run jobs", "tags": [ "Jobs" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "job.create", - "description": "This endpoint is an admin-only route, and requires the `job.create` permission." + "x-immich-state": "Stable" } }, "/libraries": { "get": { + "description": "Retrieve a list of external libraries.", "operationId": "getAllLibraries", "parameters": [], "responses": { @@ -3778,14 +5037,30 @@ "api_key": [] } ], + "summary": "Retrieve libraries", "tags": [ "Libraries" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "library.read", - "description": "This endpoint is an admin-only route, and requires the `library.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a new external library.", "operationId": "createLibrary", "parameters": [], "requestBody": { @@ -3821,16 +5096,32 @@ "api_key": [] } ], + "summary": "Create a library", "tags": [ "Libraries" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "library.create", - "description": "This endpoint is an admin-only route, and requires the `library.create` permission." + "x-immich-state": "Stable" } }, "/libraries/{id}": { "delete": { + "description": "Delete an external library by its ID.", "operationId": "deleteLibrary", "parameters": [ { @@ -3859,14 +5150,30 @@ "api_key": [] } ], + "summary": "Delete a library", "tags": [ "Libraries" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "library.delete", - "description": "This endpoint is an admin-only route, and requires the `library.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve an external library by its ID.", "operationId": "getLibrary", "parameters": [ { @@ -3902,14 +5209,30 @@ "api_key": [] } ], + "summary": "Retrieve a library", "tags": [ "Libraries" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "library.read", - "description": "This endpoint is an admin-only route, and requires the `library.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update an existing external library.", "operationId": "updateLibrary", "parameters": [ { @@ -3955,16 +5278,32 @@ "api_key": [] } ], + "summary": "Update a library", "tags": [ "Libraries" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "library.update", - "description": "This endpoint is an admin-only route, and requires the `library.update` permission." + "x-immich-state": "Stable" } }, "/libraries/{id}/scan": { "post": { + "description": "Queue a scan for the external library to find and import new assets.", "operationId": "scanLibrary", "parameters": [ { @@ -3993,16 +5332,32 @@ "api_key": [] } ], + "summary": "Scan a library", "tags": [ "Libraries" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "library.update", - "description": "This endpoint is an admin-only route, and requires the `library.update` permission." + "x-immich-state": "Stable" } }, "/libraries/{id}/statistics": { "get": { + "description": "Retrieve statistics for a specific external library, including number of videos, images, and storage usage.", "operationId": "getLibraryStatistics", "parameters": [ { @@ -4038,16 +5393,32 @@ "api_key": [] } ], + "summary": "Retrieve library statistics", "tags": [ "Libraries" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "library.statistics", - "description": "This endpoint is an admin-only route, and requires the `library.statistics` permission." + "x-immich-state": "Stable" } }, "/libraries/{id}/validate": { "post": { + "description": "Validate the settings of an external library.", "operationId": "validate", "parameters": [ { @@ -4093,32 +5464,33 @@ "api_key": [] } ], + "summary": "Validate library settings", "tags": [ "Libraries" ], - "x-immich-admin-only": true + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/map/markers": { "get": { + "description": "Retrieve a list of latitude and longitude coordinates for every asset with location data.", "operationId": "getMapMarkers", "parameters": [ - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isFavorite", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, { "name": "fileCreatedAfter", "required": false, @@ -4137,6 +5509,22 @@ "type": "string" } }, + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "withPartners", "required": false, @@ -4180,13 +5568,30 @@ "api_key": [] } ], + "summary": "Retrieve map markers", "tags": [ "Map" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/map/reverse-geocode": { "get": { + "description": "Retrieve location information (e.g., city, country) for given latitude and longitude coordinates.", "operationId": "reverseGeocode", "parameters": [ { @@ -4234,13 +5639,30 @@ "api_key": [] } ], + "summary": "Reverse geocode coordinates", "tags": [ "Map" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/memories": { "get": { + "description": "Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly.", "operationId": "searchMemories", "parameters": [ { @@ -4268,6 +5690,24 @@ "type": "boolean" } }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/MemorySearchOrder" + } + }, + { + "name": "size", + "required": false, + "in": "query", + "description": "Number of memories to return", + "schema": { + "minimum": 1, + "type": "integer" + } + }, { "name": "type", "required": false, @@ -4303,13 +5743,29 @@ "api_key": [] } ], + "summary": "Retrieve memories", "tags": [ "Memories" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "memory.read", - "description": "This endpoint requires the `memory.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a new memory by providing a name, description, and a list of asset IDs to include in the memory.", "operationId": "createMemory", "parameters": [], "requestBody": { @@ -4345,15 +5801,31 @@ "api_key": [] } ], + "summary": "Create a memory", "tags": [ "Memories" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "memory.create", - "description": "This endpoint requires the `memory.create` permission." + "x-immich-state": "Stable" } }, "/memories/statistics": { "get": { + "description": "Retrieve statistics about memories, such as total count and other relevant metrics.", "operationId": "memoriesStatistics", "parameters": [ { @@ -4381,6 +5853,24 @@ "type": "boolean" } }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/MemorySearchOrder" + } + }, + { + "name": "size", + "required": false, + "in": "query", + "description": "Number of memories to return", + "schema": { + "minimum": 1, + "type": "integer" + } + }, { "name": "type", "required": false, @@ -4413,15 +5903,31 @@ "api_key": [] } ], + "summary": "Retrieve memories statistics", "tags": [ "Memories" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "memory.statistics", - "description": "This endpoint requires the `memory.statistics` permission." + "x-immich-state": "Stable" } }, "/memories/{id}": { "delete": { + "description": "Delete a specific memory by its ID.", "operationId": "deleteMemory", "parameters": [ { @@ -4450,13 +5956,29 @@ "api_key": [] } ], + "summary": "Delete a memory", "tags": [ "Memories" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "memory.delete", - "description": "This endpoint requires the `memory.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a specific memory by its ID.", "operationId": "getMemory", "parameters": [ { @@ -4492,13 +6014,29 @@ "api_key": [] } ], + "summary": "Retrieve a memory", "tags": [ "Memories" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "memory.read", - "description": "This endpoint requires the `memory.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update an existing memory by its ID.", "operationId": "updateMemory", "parameters": [ { @@ -4544,15 +6082,31 @@ "api_key": [] } ], + "summary": "Update a memory", "tags": [ "Memories" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "memory.update", - "description": "This endpoint requires the `memory.update` permission." + "x-immich-state": "Stable" } }, "/memories/{id}/assets": { "delete": { + "description": "Remove a list of asset IDs from a specific memory.", "operationId": "removeMemoryAssets", "parameters": [ { @@ -4601,13 +6155,29 @@ "api_key": [] } ], + "summary": "Remove assets from a memory", "tags": [ "Memories" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "memoryAsset.delete", - "description": "This endpoint requires the `memoryAsset.delete` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Add a list of asset IDs to a specific memory.", "operationId": "addMemoryAssets", "parameters": [ { @@ -4656,15 +6226,31 @@ "api_key": [] } ], + "summary": "Add assets to a memory", "tags": [ "Memories" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "memoryAsset.create", - "description": "This endpoint requires the `memoryAsset.create` permission." + "x-immich-state": "Stable" } }, "/notifications": { "delete": { + "description": "Delete a list of notifications at once.", "operationId": "deleteNotifications", "parameters": [], "requestBody": { @@ -4693,13 +6279,29 @@ "api_key": [] } ], + "summary": "Delete notifications", "tags": [ "Notifications" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "notification.delete", - "description": "This endpoint requires the `notification.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a list of notifications.", "operationId": "getNotifications", "parameters": [ { @@ -4762,13 +6364,29 @@ "api_key": [] } ], + "summary": "Retrieve notifications", "tags": [ "Notifications" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "notification.read", - "description": "This endpoint requires the `notification.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update a list of notifications. Allows to bulk-set the read status of notifications.", "operationId": "updateNotifications", "parameters": [], "requestBody": { @@ -4797,15 +6415,31 @@ "api_key": [] } ], + "summary": "Update notifications", "tags": [ "Notifications" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "notification.update", - "description": "This endpoint requires the `notification.update` permission." + "x-immich-state": "Stable" } }, "/notifications/{id}": { "delete": { + "description": "Delete a specific notification.", "operationId": "deleteNotification", "parameters": [ { @@ -4834,13 +6468,29 @@ "api_key": [] } ], + "summary": "Delete a notification", "tags": [ "Notifications" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "notification.delete", - "description": "This endpoint requires the `notification.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a specific notification identified by id.", "operationId": "getNotification", "parameters": [ { @@ -4876,13 +6526,29 @@ "api_key": [] } ], + "summary": "Get a notification", "tags": [ "Notifications" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "notification.read", - "description": "This endpoint requires the `notification.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update a specific notification to set its read status.", "operationId": "updateNotification", "parameters": [ { @@ -4928,15 +6594,31 @@ "api_key": [] } ], + "summary": "Update a notification", "tags": [ "Notifications" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "notification.update", - "description": "This endpoint requires the `notification.update` permission." + "x-immich-state": "Stable" } }, "/oauth/authorize": { "post": { + "description": "Initiate the OAuth authorization process.", "operationId": "startOAuth", "parameters": [], "requestBody": { @@ -4961,13 +6643,30 @@ "description": "" } }, + "summary": "Start OAuth", "tags": [ - "OAuth" - ] + "Authentication" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/oauth/callback": { "post": { + "description": "Complete the OAuth authorization process by exchanging the authorization code for a session token.", "operationId": "finishOAuth", "parameters": [], "requestBody": { @@ -4992,13 +6691,30 @@ "description": "" } }, + "summary": "Finish OAuth", "tags": [ - "OAuth" - ] + "Authentication" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/oauth/link": { "post": { + "description": "Link an OAuth account to the authenticated user.", "operationId": "linkOAuthAccount", "parameters": [], "requestBody": { @@ -5034,13 +6750,30 @@ "api_key": [] } ], + "summary": "Link OAuth account", "tags": [ - "OAuth" - ] + "Authentication" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/oauth/mobile-redirect": { "get": { + "description": "Requests to this URL are automatically forwarded to the mobile app, and is used in some cases for OAuth redirecting.", "operationId": "redirectOAuthToMobile", "parameters": [], "responses": { @@ -5048,13 +6781,30 @@ "description": "" } }, + "summary": "Redirect OAuth to mobile", "tags": [ - "OAuth" - ] + "Authentication" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/oauth/unlink": { "post": { + "description": "Unlink the OAuth account from the authenticated user.", "operationId": "unlinkOAuthAccount", "parameters": [], "responses": { @@ -5080,13 +6830,30 @@ "api_key": [] } ], + "summary": "Unlink OAuth account", "tags": [ - "OAuth" - ] + "Authentication" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/partners": { "get": { + "description": "Retrieve a list of partners with whom assets are shared.", "operationId": "getPartners", "parameters": [ { @@ -5124,13 +6891,29 @@ "api_key": [] } ], + "summary": "Retrieve partners", "tags": [ "Partners" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "partner.read", - "description": "This endpoint requires the `partner.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a new partner to share assets with.", "operationId": "createPartner", "parameters": [], "requestBody": { @@ -5166,15 +6949,31 @@ "api_key": [] } ], + "summary": "Create a partner", "tags": [ "Partners" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "partner.create", - "description": "This endpoint requires the `partner.create` permission." + "x-immich-state": "Stable" } }, "/partners/{id}": { "delete": { + "description": "Stop sharing assets with a partner.", "operationId": "removePartner", "parameters": [ { @@ -5203,15 +7002,30 @@ "api_key": [] } ], + "summary": "Remove a partner", "tags": [ "Partners" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "partner.delete", - "description": "This endpoint requires the `partner.delete` permission." + "x-immich-state": "Stable" }, "post": { "deprecated": true, - "description": "This property was deprecated in v1.141.0. This endpoint requires the `partner.create` permission.", + "description": "Create a new partner to share assets with.", "operationId": "createPartnerDeprecated", "parameters": [ { @@ -5247,16 +7061,27 @@ "api_key": [] } ], + "summary": "Create a partner", "tags": [ "Partners", "Deprecated" ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.141.0" - }, - "x-immich-permission": "partner.create" + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Deprecated", + "replacementId": "createPartner" + } + ], + "x-immich-permission": "partner.create", + "x-immich-state": "Deprecated" }, "put": { + "description": "Specify whether a partner's assets should appear in the user's timeline.", "operationId": "updatePartner", "parameters": [ { @@ -5302,15 +7127,31 @@ "api_key": [] } ], + "summary": "Update a partner", "tags": [ "Partners" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "partner.update", - "description": "This endpoint requires the `partner.update` permission." + "x-immich-state": "Stable" } }, "/people": { "delete": { + "description": "Bulk delete a list of people at once.", "operationId": "deletePeople", "parameters": [], "requestBody": { @@ -5339,13 +7180,29 @@ "api_key": [] } ], + "summary": "Delete people", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.delete", - "description": "This endpoint requires the `person.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a list of all people.", "operationId": "getAllPeople", "parameters": [ { @@ -5421,13 +7278,29 @@ "api_key": [] } ], + "summary": "Get all people", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.read", - "description": "This endpoint requires the `person.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a new person that can have multiple faces assigned to them.", "operationId": "createPerson", "parameters": [], "requestBody": { @@ -5463,13 +7336,29 @@ "api_key": [] } ], + "summary": "Create a person", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.create", - "description": "This endpoint requires the `person.create` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Bulk update multiple people at once.", "operationId": "updatePeople", "parameters": [], "requestBody": { @@ -5508,15 +7397,31 @@ "api_key": [] } ], + "summary": "Update people", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.update", - "description": "This endpoint requires the `person.update` permission." + "x-immich-state": "Stable" } }, "/people/{id}": { "delete": { + "description": "Delete an individual person.", "operationId": "deletePerson", "parameters": [ { @@ -5545,13 +7450,29 @@ "api_key": [] } ], + "summary": "Delete person", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.delete", - "description": "This endpoint requires the `person.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a person by id.", "operationId": "getPerson", "parameters": [ { @@ -5587,13 +7508,29 @@ "api_key": [] } ], + "summary": "Get a person", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.read", - "description": "This endpoint requires the `person.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update an individual person.", "operationId": "updatePerson", "parameters": [ { @@ -5639,15 +7576,31 @@ "api_key": [] } ], + "summary": "Update person", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.update", - "description": "This endpoint requires the `person.update` permission." + "x-immich-state": "Stable" } }, "/people/{id}/merge": { "post": { + "description": "Merge a list of people into the person specified in the path parameter.", "operationId": "mergePerson", "parameters": [ { @@ -5696,15 +7649,31 @@ "api_key": [] } ], + "summary": "Merge people", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.merge", - "description": "This endpoint requires the `person.merge` permission." + "x-immich-state": "Stable" } }, "/people/{id}/reassign": { "put": { + "description": "Bulk reassign a list of faces to a different person.", "operationId": "reassignFaces", "parameters": [ { @@ -5753,15 +7722,31 @@ "api_key": [] } ], + "summary": "Reassign faces", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.reassign", - "description": "This endpoint requires the `person.reassign` permission." + "x-immich-state": "Stable" } }, "/people/{id}/statistics": { "get": { + "description": "Retrieve statistics about a specific person.", "operationId": "getPersonStatistics", "parameters": [ { @@ -5797,15 +7782,31 @@ "api_key": [] } ], + "summary": "Get person statistics", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.statistics", - "description": "This endpoint requires the `person.statistics` permission." + "x-immich-state": "Stable" } }, "/people/{id}/thumbnail": { "get": { + "description": "Retrieve the thumbnail file for a person.", "operationId": "getPersonThumbnail", "parameters": [ { @@ -5842,15 +7843,136 @@ "api_key": [] } ], + "summary": "Get person thumbnail", "tags": [ "People" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.read", - "description": "This endpoint requires the `person.read` permission." + "x-immich-state": "Stable" + } + }, + "/plugins": { + "get": { + "description": "Retrieve a list of plugins available to the authenticated user.", + "operationId": "getPlugins", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PluginResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all plugins", + "tags": [ + "Plugins" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "plugin.read", + "x-immich-state": "Alpha" + } + }, + "/plugins/{id}": { + "get": { + "description": "Retrieve information about a specific plugin by its ID.", + "operationId": "getPlugin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve a plugin", + "tags": [ + "Plugins" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "plugin.read", + "x-immich-state": "Alpha" } }, "/search/cities": { "get": { + "description": "Retrieve a list of assets with each asset belonging to a different city. This endpoint is used on the places pages to show a single thumbnail for each city the user has assets in.", "operationId": "getAssetsByCity", "parameters": [], "responses": { @@ -5879,15 +8001,31 @@ "api_key": [] } ], + "summary": "Retrieve assets by city", "tags": [ "Search" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" } }, "/search/explore": { "get": { + "description": "Retrieve data for the explore section, such as popular people and places.", "operationId": "getExploreData", "parameters": [], "responses": { @@ -5916,15 +8054,31 @@ "api_key": [] } ], + "summary": "Retrieve explore data", "tags": [ "Search" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" } }, "/search/large-assets": { "post": { + "description": "Search for assets that are considered large based on specified criteria.", "operationId": "searchLargeAssets", "parameters": [ { @@ -6243,15 +8397,31 @@ "api_key": [] } ], + "summary": "Search large assets", "tags": [ "Search" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" } }, "/search/metadata": { "post": { + "description": "Search for assets based on various metadata criteria.", "operationId": "searchAssets", "parameters": [], "requestBody": { @@ -6287,15 +8457,31 @@ "api_key": [] } ], + "summary": "Search assets by metadata", "tags": [ "Search" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" } }, "/search/person": { "get": { + "description": "Search for people by name.", "operationId": "searchPerson", "parameters": [ { @@ -6341,15 +8527,31 @@ "api_key": [] } ], + "summary": "Search people", "tags": [ "Search" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "person.read", - "description": "This endpoint requires the `person.read` permission." + "x-immich-state": "Stable" } }, "/search/places": { "get": { + "description": "Search for places by name.", "operationId": "searchPlaces", "parameters": [ { @@ -6387,15 +8589,31 @@ "api_key": [] } ], + "summary": "Search places", "tags": [ "Search" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" } }, "/search/random": { "post": { + "description": "Retrieve a random selection of assets based on the provided criteria.", "operationId": "searchRandom", "parameters": [], "requestBody": { @@ -6434,15 +8652,31 @@ "api_key": [] } ], + "summary": "Search random assets", "tags": [ "Search" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" } }, "/search/smart": { "post": { + "description": "Perform a smart search for assets by using machine learning vectors to determine relevance.", "operationId": "searchSmart", "parameters": [], "requestBody": { @@ -6478,15 +8712,31 @@ "api_key": [] } ], + "summary": "Smart asset search", "tags": [ "Search" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" } }, "/search/statistics": { "post": { + "description": "Retrieve statistical data about assets based on search criteria, such as the total matching count.", "operationId": "searchAssetStatistics", "parameters": [], "requestBody": { @@ -6522,15 +8772,31 @@ "api_key": [] } ], + "summary": "Search asset statistics", "tags": [ "Search" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.statistics", - "description": "This endpoint requires the `asset.statistics` permission." + "x-immich-state": "Stable" } }, "/search/suggestions": { "get": { + "description": "Retrieve search suggestions based on partial input. This endpoint is used for typeahead search features.", "operationId": "getSearchSuggestions", "parameters": [ { @@ -6545,7 +8811,17 @@ "name": "includeNull", "required": false, "in": "query", - "description": "This property was added in v111.0.0", + "x-immich-history": [ + { + "version": "v1.111.0", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable", "schema": { "type": "boolean" } @@ -6617,15 +8893,31 @@ "api_key": [] } ], + "summary": "Retrieve search suggestions", "tags": [ "Search" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Stable" } }, "/server/about": { "get": { + "description": "Retrieve a list of information about the server.", "operationId": "getAboutInfo", "parameters": [], "responses": { @@ -6651,15 +8943,31 @@ "api_key": [] } ], + "summary": "Get server information", "tags": [ "Server" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "server.about", - "description": "This endpoint requires the `server.about` permission." + "x-immich-state": "Stable" } }, "/server/apk-links": { "get": { + "description": "Retrieve links to the APKs for the current server version.", "operationId": "getApkLinks", "parameters": [], "responses": { @@ -6685,15 +8993,31 @@ "api_key": [] } ], + "summary": "Get APK links", "tags": [ "Server" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "server.apkLinks", - "description": "This endpoint requires the `server.apkLinks` permission." + "x-immich-state": "Stable" } }, "/server/config": { "get": { + "description": "Retrieve the current server configuration.", "operationId": "getServerConfig", "parameters": [], "responses": { @@ -6708,13 +9032,30 @@ "description": "" } }, + "summary": "Get config", "tags": [ "Server" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/server/features": { "get": { + "description": "Retrieve available features supported by this server.", "operationId": "getServerFeatures", "parameters": [], "responses": { @@ -6729,13 +9070,30 @@ "description": "" } }, + "summary": "Get features", "tags": [ "Server" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/server/license": { "delete": { + "description": "Delete the currently set server product key.", "operationId": "deleteServerLicense", "parameters": [], "responses": { @@ -6754,14 +9112,30 @@ "api_key": [] } ], + "summary": "Delete server product key", "tags": [ "Server" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "serverLicense.delete", - "description": "This endpoint is an admin-only route, and requires the `serverLicense.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve information about whether the server currently has a product key registered.", "operationId": "getServerLicense", "parameters": [], "responses": { @@ -6790,14 +9164,30 @@ "api_key": [] } ], + "summary": "Get product key", "tags": [ "Server" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "serverLicense.read", - "description": "This endpoint is an admin-only route, and requires the `serverLicense.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Validate and set the server product key if successful.", "operationId": "setServerLicense", "parameters": [], "requestBody": { @@ -6833,16 +9223,32 @@ "api_key": [] } ], + "summary": "Set server product key", "tags": [ "Server" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "serverLicense.update", - "description": "This endpoint is an admin-only route, and requires the `serverLicense.update` permission." + "x-immich-state": "Stable" } }, "/server/media-types": { "get": { + "description": "Retrieve all media types supported by the server.", "operationId": "getSupportedMediaTypes", "parameters": [], "responses": { @@ -6857,13 +9263,30 @@ "description": "" } }, + "summary": "Get supported media types", "tags": [ "Server" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/server/ping": { "get": { + "description": "Pong", "operationId": "pingServer", "parameters": [], "responses": { @@ -6878,13 +9301,30 @@ "description": "" } }, + "summary": "Ping", "tags": [ "Server" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/server/statistics": { "get": { + "description": "Retrieve statistics about the entire Immich instance such as asset counts.", "operationId": "getServerStatistics", "parameters": [], "responses": { @@ -6910,16 +9350,32 @@ "api_key": [] } ], + "summary": "Get statistics", "tags": [ "Server" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "server.statistics", - "description": "This endpoint is an admin-only route, and requires the `server.statistics` permission." + "x-immich-state": "Stable" } }, "/server/storage": { "get": { + "description": "Retrieve the current storage utilization information of the server.", "operationId": "getStorage", "parameters": [], "responses": { @@ -6945,15 +9401,31 @@ "api_key": [] } ], + "summary": "Get storage", "tags": [ "Server" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "server.storage", - "description": "This endpoint requires the `server.storage` permission." + "x-immich-state": "Stable" } }, "/server/theme": { "get": { + "description": "Retrieve the custom CSS, if existent.", "operationId": "getTheme", "parameters": [], "responses": { @@ -6968,13 +9440,30 @@ "description": "" } }, + "summary": "Get theme", "tags": [ "Server" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/server/version": { "get": { + "description": "Retrieve the current server version in semantic versioning (semver) format.", "operationId": "getServerVersion", "parameters": [], "responses": { @@ -6989,13 +9478,30 @@ "description": "" } }, + "summary": "Get server version", "tags": [ "Server" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/server/version-check": { "get": { + "description": "Retrieve information about the last time the version check ran.", "operationId": "getVersionCheck", "parameters": [], "responses": { @@ -7021,15 +9527,31 @@ "api_key": [] } ], + "summary": "Get version check status", "tags": [ "Server" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "server.versionCheck", - "description": "This endpoint requires the `server.versionCheck` permission." + "x-immich-state": "Stable" } }, "/server/version-history": { "get": { + "description": "Retrieve a list of past versions the server has been on.", "operationId": "getVersionHistory", "parameters": [], "responses": { @@ -7047,13 +9569,30 @@ "description": "" } }, + "summary": "Get version history", "tags": [ "Server" - ] + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/sessions": { "delete": { + "description": "Delete all sessions for the user. This will not delete the current session.", "operationId": "deleteAllSessions", "parameters": [], "responses": { @@ -7072,13 +9611,29 @@ "api_key": [] } ], + "summary": "Delete all sessions", "tags": [ "Sessions" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "session.delete", - "description": "This endpoint requires the `session.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a list of sessions for the user.", "operationId": "getSessions", "parameters": [], "responses": { @@ -7107,13 +9662,29 @@ "api_key": [] } ], + "summary": "Retrieve sessions", "tags": [ "Sessions" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "session.read", - "description": "This endpoint requires the `session.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a session as a child to the current session. This endpoint is used for casting.", "operationId": "createSession", "parameters": [], "requestBody": { @@ -7149,15 +9720,31 @@ "api_key": [] } ], + "summary": "Create a session", "tags": [ "Sessions" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "session.create", - "description": "This endpoint requires the `session.create` permission." + "x-immich-state": "Stable" } }, "/sessions/{id}": { "delete": { + "description": "Delete a specific session by id.", "operationId": "deleteSession", "parameters": [ { @@ -7186,13 +9773,29 @@ "api_key": [] } ], + "summary": "Delete a session", "tags": [ "Sessions" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "session.delete", - "description": "This endpoint requires the `session.delete` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update a specific session identified by id.", "operationId": "updateSession", "parameters": [ { @@ -7238,15 +9841,31 @@ "api_key": [] } ], + "summary": "Update a session", "tags": [ "Sessions" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "session.update", - "description": "This endpoint requires the `session.update` permission." + "x-immich-state": "Stable" } }, "/sessions/{id}/lock": { "post": { + "description": "Lock a specific session by id.", "operationId": "lockSession", "parameters": [ { @@ -7275,15 +9894,31 @@ "api_key": [] } ], + "summary": "Lock a session", "tags": [ "Sessions" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "session.lock", - "description": "This endpoint requires the `session.lock` permission." + "x-immich-state": "Stable" } }, "/shared-links": { "get": { + "description": "Retrieve a list of all shared links.", "operationId": "getAllSharedLinks", "parameters": [ { @@ -7322,13 +9957,29 @@ "api_key": [] } ], + "summary": "Retrieve all shared links", "tags": [ - "Shared Links" + "Shared links" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } ], "x-immich-permission": "sharedLink.read", - "description": "This endpoint requires the `sharedLink.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a new shared link.", "operationId": "createSharedLink", "parameters": [], "requestBody": { @@ -7364,17 +10015,41 @@ "api_key": [] } ], + "summary": "Create a shared link", "tags": [ - "Shared Links" + "Shared links" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } ], "x-immich-permission": "sharedLink.create", - "description": "This endpoint requires the `sharedLink.create` permission." + "x-immich-state": "Stable" } }, "/shared-links/me": { "get": { + "description": "Retrieve the current shared link associated with authentication method.", "operationId": "getMySharedLink", "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "password", "required": false, @@ -7384,22 +10059,6 @@ "type": "string" } }, - { - "name": "token", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, { "name": "slug", "required": false, @@ -7407,6 +10066,14 @@ "schema": { "type": "string" } + }, + { + "name": "token", + "required": false, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { @@ -7432,13 +10099,30 @@ "api_key": [] } ], + "summary": "Retrieve current shared link", "tags": [ - "Shared Links" - ] + "Shared links" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/shared-links/{id}": { "delete": { + "description": "Delete a specific shared link by its ID.", "operationId": "removeSharedLink", "parameters": [ { @@ -7467,13 +10151,29 @@ "api_key": [] } ], + "summary": "Delete a shared link", "tags": [ - "Shared Links" + "Shared links" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } ], "x-immich-permission": "sharedLink.delete", - "description": "This endpoint requires the `sharedLink.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a specific shared link by its ID.", "operationId": "getSharedLinkById", "parameters": [ { @@ -7509,13 +10209,29 @@ "api_key": [] } ], + "summary": "Retrieve a shared link", "tags": [ - "Shared Links" + "Shared links" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } ], "x-immich-permission": "sharedLink.read", - "description": "This endpoint requires the `sharedLink.read` permission." + "x-immich-state": "Stable" }, "patch": { + "description": "Update an existing shared link by its ID.", "operationId": "updateSharedLink", "parameters": [ { @@ -7561,15 +10277,31 @@ "api_key": [] } ], + "summary": "Update a shared link", "tags": [ - "Shared Links" + "Shared links" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } ], "x-immich-permission": "sharedLink.update", - "description": "This endpoint requires the `sharedLink.update` permission." + "x-immich-state": "Stable" } }, "/shared-links/{id}/assets": { "delete": { + "description": "Remove assets from a specific shared link by its ID. This endpoint is only relevant for shared link of type individual.", "operationId": "removeSharedLinkAssets", "parameters": [ { @@ -7634,11 +10366,28 @@ "api_key": [] } ], + "summary": "Remove assets from a shared link", "tags": [ - "Shared Links" - ] + "Shared links" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" }, "put": { + "description": "Add assets to a specific shared link by its ID. This endpoint is only relevant for shared link of type individual.", "operationId": "addSharedLinkAssets", "parameters": [ { @@ -7703,13 +10452,30 @@ "api_key": [] } ], + "summary": "Add assets to a shared link", "tags": [ - "Shared Links" - ] + "Shared links" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/stacks": { "delete": { + "description": "Delete multiple stacks by providing a list of stack IDs.", "operationId": "deleteStacks", "parameters": [], "requestBody": { @@ -7738,13 +10504,29 @@ "api_key": [] } ], + "summary": "Delete stacks", "tags": [ "Stacks" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "stack.delete", - "description": "This endpoint requires the `stack.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a list of stacks.", "operationId": "searchStacks", "parameters": [ { @@ -7783,13 +10565,29 @@ "api_key": [] } ], + "summary": "Retrieve stacks", "tags": [ "Stacks" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "stack.read", - "description": "This endpoint requires the `stack.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a new stack by providing a name and a list of asset IDs to include in the stack. If any of the provided asset IDs are primary assets of an existing stack, the existing stack will be merged into the newly created stack.", "operationId": "createStack", "parameters": [], "requestBody": { @@ -7825,15 +10623,31 @@ "api_key": [] } ], + "summary": "Create a stack", "tags": [ "Stacks" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "stack.create", - "description": "This endpoint requires the `stack.create` permission." + "x-immich-state": "Stable" } }, "/stacks/{id}": { "delete": { + "description": "Delete a specific stack by its ID.", "operationId": "deleteStack", "parameters": [ { @@ -7862,13 +10676,29 @@ "api_key": [] } ], + "summary": "Delete a stack", "tags": [ "Stacks" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "stack.delete", - "description": "This endpoint requires the `stack.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a specific stack by its ID.", "operationId": "getStack", "parameters": [ { @@ -7904,13 +10734,29 @@ "api_key": [] } ], + "summary": "Retrieve a stack", "tags": [ "Stacks" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "stack.read", - "description": "This endpoint requires the `stack.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update an existing stack by its ID.", "operationId": "updateStack", "parameters": [ { @@ -7956,15 +10802,31 @@ "api_key": [] } ], + "summary": "Update a stack", "tags": [ "Stacks" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "stack.update", - "description": "This endpoint requires the `stack.update` permission." + "x-immich-state": "Stable" } }, "/stacks/{id}/assets/{assetId}": { "delete": { + "description": "Remove a specific asset from a stack by providing the stack ID and asset ID.", "operationId": "removeAssetFromStack", "parameters": [ { @@ -8002,15 +10864,31 @@ "api_key": [] } ], + "summary": "Remove an asset from a stack", "tags": [ "Stacks" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "stack.update", - "description": "This endpoint requires the `stack.update` permission." + "x-immich-state": "Stable" } }, "/sync/ack": { "delete": { + "description": "Delete specific synchronization acknowledgments.", "operationId": "deleteSyncAck", "parameters": [], "requestBody": { @@ -8039,13 +10917,29 @@ "api_key": [] } ], + "summary": "Delete acknowledgements", "tags": [ "Sync" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "syncCheckpoint.delete", - "description": "This endpoint requires the `syncCheckpoint.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve the synchronization acknowledgments for the current session.", "operationId": "getSyncAck", "parameters": [], "responses": { @@ -8074,13 +10968,29 @@ "api_key": [] } ], + "summary": "Retrieve acknowledgements", "tags": [ "Sync" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "syncCheckpoint.read", - "description": "This endpoint requires the `syncCheckpoint.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Send a list of synchronization acknowledgements to confirm that the latest changes have been received.", "operationId": "sendSyncAck", "parameters": [], "requestBody": { @@ -8109,15 +11019,32 @@ "api_key": [] } ], + "summary": "Acknowledge changes", "tags": [ "Sync" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "syncCheckpoint.update", - "description": "This endpoint requires the `syncCheckpoint.update` permission." + "x-immich-state": "Stable" } }, "/sync/delta-sync": { "post": { + "deprecated": true, + "description": "Retrieve changed assets since the last sync for the authenticated user.", "operationId": "getDeltaSync", "parameters": [], "requestBody": { @@ -8153,13 +11080,28 @@ "api_key": [] } ], + "summary": "Get delta sync for user", "tags": [ - "Sync" - ] + "Sync", + "Deprecated" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" } }, "/sync/full-sync": { "post": { + "deprecated": true, + "description": "Retrieve all assets for a full synchronization for the authenticated user.", "operationId": "getFullSyncForUser", "parameters": [], "requestBody": { @@ -8198,13 +11140,27 @@ "api_key": [] } ], + "summary": "Get full sync for user", "tags": [ - "Sync" - ] + "Sync", + "Deprecated" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" } }, "/sync/stream": { "post": { + "description": "Retrieve a JSON lines streamed response of changes for synchronization. This endpoint is used by the mobile app to efficiently stay up to date with changes.", "operationId": "getSyncStream", "parameters": [], "requestBody": { @@ -8233,15 +11189,31 @@ "api_key": [] } ], + "summary": "Stream sync changes", "tags": [ "Sync" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "sync.stream", - "description": "This endpoint requires the `sync.stream` permission." + "x-immich-state": "Stable" } }, "/system-config": { "get": { + "description": "Retrieve the current system configuration.", "operationId": "getConfig", "parameters": [], "responses": { @@ -8267,14 +11239,30 @@ "api_key": [] } ], + "summary": "Get system configuration", "tags": [ - "System Config" + "System config" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "systemConfig.read", - "description": "This endpoint is an admin-only route, and requires the `systemConfig.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update the system configuration with a new system configuration.", "operationId": "updateConfig", "parameters": [], "requestBody": { @@ -8310,16 +11298,32 @@ "api_key": [] } ], + "summary": "Update system configuration", "tags": [ - "System Config" + "System config" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "systemConfig.update", - "description": "This endpoint is an admin-only route, and requires the `systemConfig.update` permission." + "x-immich-state": "Stable" } }, "/system-config/defaults": { "get": { + "description": "Retrieve the default values for the system configuration.", "operationId": "getConfigDefaults", "parameters": [], "responses": { @@ -8345,16 +11349,32 @@ "api_key": [] } ], + "summary": "Get system configuration defaults", "tags": [ - "System Config" + "System config" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "systemConfig.read", - "description": "This endpoint is an admin-only route, and requires the `systemConfig.read` permission." + "x-immich-state": "Stable" } }, "/system-config/storage-template-options": { "get": { + "description": "Retrieve exemplary storage template options.", "operationId": "getStorageTemplateOptions", "parameters": [], "responses": { @@ -8380,16 +11400,32 @@ "api_key": [] } ], + "summary": "Get storage template options", "tags": [ - "System Config" + "System config" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "systemConfig.read", - "description": "This endpoint is an admin-only route, and requires the `systemConfig.read` permission." + "x-immich-state": "Stable" } }, "/system-metadata/admin-onboarding": { "get": { + "description": "Retrieve the current admin onboarding status.", "operationId": "getAdminOnboarding", "parameters": [], "responses": { @@ -8415,14 +11451,30 @@ "api_key": [] } ], + "summary": "Retrieve admin onboarding", "tags": [ - "System Metadata" + "System metadata" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "systemMetadata.read", - "description": "This endpoint is an admin-only route, and requires the `systemMetadata.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Update the admin onboarding status.", "operationId": "updateAdminOnboarding", "parameters": [], "requestBody": { @@ -8451,16 +11503,32 @@ "api_key": [] } ], + "summary": "Update admin onboarding", "tags": [ - "System Metadata" + "System metadata" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "systemMetadata.update", - "description": "This endpoint is an admin-only route, and requires the `systemMetadata.update` permission." + "x-immich-state": "Stable" } }, "/system-metadata/reverse-geocoding-state": { "get": { + "description": "Retrieve the current state of the reverse geocoding import.", "operationId": "getReverseGeocodingState", "parameters": [], "responses": { @@ -8486,16 +11554,32 @@ "api_key": [] } ], + "summary": "Retrieve reverse geocoding state", "tags": [ - "System Metadata" + "System metadata" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "systemMetadata.read", - "description": "This endpoint is an admin-only route, and requires the `systemMetadata.read` permission." + "x-immich-state": "Stable" } }, "/system-metadata/version-check-state": { "get": { + "description": "Retrieve the current state of the version check process.", "operationId": "getVersionCheckState", "parameters": [], "responses": { @@ -8521,16 +11605,32 @@ "api_key": [] } ], + "summary": "Retrieve version check state", "tags": [ - "System Metadata" + "System metadata" ], "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "systemMetadata.read", - "description": "This endpoint is an admin-only route, and requires the `systemMetadata.read` permission." + "x-immich-state": "Stable" } }, "/tags": { "get": { + "description": "Retrieve a list of all tags.", "operationId": "getAllTags", "parameters": [], "responses": { @@ -8559,13 +11659,29 @@ "api_key": [] } ], + "summary": "Retrieve tags", "tags": [ "Tags" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "tag.read", - "description": "This endpoint requires the `tag.read` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Create a new tag by providing a name and optional color.", "operationId": "createTag", "parameters": [], "requestBody": { @@ -8601,13 +11717,29 @@ "api_key": [] } ], + "summary": "Create a tag", "tags": [ "Tags" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "tag.create", - "description": "This endpoint requires the `tag.create` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Create or update multiple tags in a single request.", "operationId": "upsertTags", "parameters": [], "requestBody": { @@ -8646,15 +11778,31 @@ "api_key": [] } ], + "summary": "Upsert tags", "tags": [ "Tags" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "tag.create", - "description": "This endpoint requires the `tag.create` permission." + "x-immich-state": "Stable" } }, "/tags/assets": { "put": { + "description": "Add multiple tags to multiple assets in a single request.", "operationId": "bulkTagAssets", "parameters": [], "requestBody": { @@ -8690,15 +11838,31 @@ "api_key": [] } ], + "summary": "Tag assets", "tags": [ "Tags" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "tag.asset", - "description": "This endpoint requires the `tag.asset` permission." + "x-immich-state": "Stable" } }, "/tags/{id}": { "delete": { + "description": "Delete a specific tag by its ID.", "operationId": "deleteTag", "parameters": [ { @@ -8727,13 +11891,29 @@ "api_key": [] } ], + "summary": "Delete a tag", "tags": [ "Tags" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "tag.delete", - "description": "This endpoint requires the `tag.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve a specific tag by its ID.", "operationId": "getTagById", "parameters": [ { @@ -8769,13 +11949,29 @@ "api_key": [] } ], + "summary": "Retrieve a tag", "tags": [ "Tags" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "tag.read", - "description": "This endpoint requires the `tag.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update an existing tag identified by its ID.", "operationId": "updateTag", "parameters": [ { @@ -8821,15 +12017,31 @@ "api_key": [] } ], + "summary": "Update a tag", "tags": [ "Tags" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "tag.update", - "description": "This endpoint requires the `tag.update` permission." + "x-immich-state": "Stable" } }, "/tags/{id}/assets": { "delete": { + "description": "Remove a tag from all the specified assets.", "operationId": "untagAssets", "parameters": [ { @@ -8878,13 +12090,29 @@ "api_key": [] } ], + "summary": "Untag assets", "tags": [ "Tags" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "tag.asset", - "description": "This endpoint requires the `tag.asset` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Add a tag to all the specified assets.", "operationId": "tagAssets", "parameters": [ { @@ -8933,15 +12161,31 @@ "api_key": [] } ], + "summary": "Tag assets", "tags": [ "Tags" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "tag.asset", - "description": "This endpoint requires the `tag.asset` permission." + "x-immich-state": "Stable" } }, "/timeline/bucket": { "get": { + "description": "Retrieve a string of all asset ids in a given time bucket.", "operationId": "getTimeBucket", "parameters": [ { @@ -9097,15 +12341,27 @@ "api_key": [] } ], + "summary": "Get time bucket", "tags": [ "Timeline" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Internal" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Internal" } }, "/timeline/buckets": { "get": { + "description": "Retrieve a list of all minimal time buckets.", "operationId": "getTimeBuckets", "parameters": [ { @@ -9254,15 +12510,27 @@ "api_key": [] } ], + "summary": "Get time buckets", "tags": [ "Timeline" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Internal" + } + ], "x-immich-permission": "asset.read", - "description": "This endpoint requires the `asset.read` permission." + "x-immich-state": "Internal" } }, "/trash/empty": { "post": { + "description": "Permanently delete all items in the trash.", "operationId": "emptyTrash", "parameters": [], "responses": { @@ -9288,15 +12556,31 @@ "api_key": [] } ], + "summary": "Empty trash", "tags": [ "Trash" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.delete", - "description": "This endpoint requires the `asset.delete` permission." + "x-immich-state": "Stable" } }, "/trash/restore": { "post": { + "description": "Restore all items in the trash.", "operationId": "restoreTrash", "parameters": [], "responses": { @@ -9322,15 +12606,31 @@ "api_key": [] } ], + "summary": "Restore trash", "tags": [ "Trash" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.delete", - "description": "This endpoint requires the `asset.delete` permission." + "x-immich-state": "Stable" } }, "/trash/restore/assets": { "post": { + "description": "Restore specific assets from the trash.", "operationId": "restoreAssets", "parameters": [], "requestBody": { @@ -9366,15 +12666,31 @@ "api_key": [] } ], + "summary": "Restore assets", "tags": [ "Trash" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "asset.delete", - "description": "This endpoint requires the `asset.delete` permission." + "x-immich-state": "Stable" } }, "/users": { "get": { + "description": "Retrieve a list of all users on the server.", "operationId": "searchUsers", "parameters": [], "responses": { @@ -9403,15 +12719,31 @@ "api_key": [] } ], + "summary": "Get all users", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "user.read", - "description": "This endpoint requires the `user.read` permission." + "x-immich-state": "Stable" } }, "/users/me": { "get": { + "description": "Retrieve information about the user making the API request.", "operationId": "getMyUser", "parameters": [], "responses": { @@ -9437,13 +12769,29 @@ "api_key": [] } ], + "summary": "Get current user", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "user.read", - "description": "This endpoint requires the `user.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update the current user making teh API request.", "operationId": "updateMyUser", "parameters": [], "requestBody": { @@ -9479,15 +12827,31 @@ "api_key": [] } ], + "summary": "Update current user", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "user.update", - "description": "This endpoint requires the `user.update` permission." + "x-immich-state": "Stable" } }, "/users/me/license": { "delete": { + "description": "Delete the registered product key for the current user.", "operationId": "deleteUserLicense", "parameters": [], "responses": { @@ -9506,13 +12870,29 @@ "api_key": [] } ], + "summary": "Delete user product key", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userLicense.delete", - "description": "This endpoint requires the `userLicense.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve information about whether the current user has a registered product key.", "operationId": "getUserLicense", "parameters": [], "responses": { @@ -9538,13 +12918,29 @@ "api_key": [] } ], + "summary": "Retrieve user product key", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userLicense.read", - "description": "This endpoint requires the `userLicense.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Register a product key for the current user.", "operationId": "setUserLicense", "parameters": [], "requestBody": { @@ -9580,15 +12976,31 @@ "api_key": [] } ], + "summary": "Set user product key", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userLicense.update", - "description": "This endpoint requires the `userLicense.update` permission." + "x-immich-state": "Stable" } }, "/users/me/onboarding": { "delete": { + "description": "Delete the onboarding status of the current user.", "operationId": "deleteUserOnboarding", "parameters": [], "responses": { @@ -9607,13 +13019,29 @@ "api_key": [] } ], + "summary": "Delete user onboarding", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userOnboarding.delete", - "description": "This endpoint requires the `userOnboarding.delete` permission." + "x-immich-state": "Stable" }, "get": { + "description": "Retrieve the onboarding status of the current user.", "operationId": "getUserOnboarding", "parameters": [], "responses": { @@ -9639,13 +13067,29 @@ "api_key": [] } ], + "summary": "Retrieve user onboarding", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userOnboarding.read", - "description": "This endpoint requires the `userOnboarding.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update the onboarding status of the current user.", "operationId": "setUserOnboarding", "parameters": [], "requestBody": { @@ -9681,15 +13125,31 @@ "api_key": [] } ], + "summary": "Update user onboarding", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userOnboarding.update", - "description": "This endpoint requires the `userOnboarding.update` permission." + "x-immich-state": "Stable" } }, "/users/me/preferences": { "get": { + "description": "Retrieve the preferences for the current user.", "operationId": "getMyPreferences", "parameters": [], "responses": { @@ -9715,13 +13175,29 @@ "api_key": [] } ], + "summary": "Get my preferences", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userPreference.read", - "description": "This endpoint requires the `userPreference.read` permission." + "x-immich-state": "Stable" }, "put": { + "description": "Update the preferences of the current user.", "operationId": "updateMyPreferences", "parameters": [], "requestBody": { @@ -9757,15 +13233,31 @@ "api_key": [] } ], + "summary": "Update my preferences", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userPreference.update", - "description": "This endpoint requires the `userPreference.update` permission." + "x-immich-state": "Stable" } }, "/users/profile-image": { "delete": { + "description": "Delete the profile image of the current user.", "operationId": "deleteProfileImage", "parameters": [], "responses": { @@ -9784,13 +13276,29 @@ "api_key": [] } ], + "summary": "Delete user profile image", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userProfileImage.delete", - "description": "This endpoint requires the `userProfileImage.delete` permission." + "x-immich-state": "Stable" }, "post": { + "description": "Upload and set a new profile image for the current user.", "operationId": "createProfileImage", "parameters": [], "requestBody": { @@ -9827,15 +13335,31 @@ "api_key": [] } ], + "summary": "Create user profile image", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userProfileImage.update", - "description": "This endpoint requires the `userProfileImage.update` permission." + "x-immich-state": "Stable" } }, "/users/{id}": { "get": { + "description": "Retrieve a specific user by their ID.", "operationId": "getUser", "parameters": [ { @@ -9871,15 +13395,31 @@ "api_key": [] } ], + "summary": "Retrieve a user", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "user.read", - "description": "This endpoint requires the `user.read` permission." + "x-immich-state": "Stable" } }, "/users/{id}/profile-image": { "get": { + "description": "Retrieve the profile image file for a user.", "operationId": "getProfileImage", "parameters": [ { @@ -9916,15 +13456,31 @@ "api_key": [] } ], + "summary": "Retrieve user profile image", "tags": [ "Users" ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], "x-immich-permission": "userProfileImage.read", - "description": "This endpoint requires the `userProfileImage.read` permission." + "x-immich-state": "Stable" } }, "/view/folder": { "get": { + "description": "Retrieve assets that are children of a specific folder.", "operationId": "getAssetsByOriginalPath", "parameters": [ { @@ -9962,13 +13518,30 @@ "api_key": [] } ], + "summary": "Retrieve assets by original path", "tags": [ - "View" - ] + "Views" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "/view/folder/unique-paths": { "get": { + "description": "Retrieve a list of unique folder paths from asset original paths.", "operationId": "getUniqueOriginalPaths", "parameters": [], "responses": { @@ -9997,19 +13570,442 @@ "api_key": [] } ], + "summary": "Retrieve unique paths", "tags": [ - "View" - ] + "Views" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Beta" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" + } + }, + "/workflows": { + "get": { + "description": "Retrieve a list of workflows available to the authenticated user.", + "operationId": "getWorkflows", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkflowResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all workflows", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.read", + "x-immich-state": "Alpha" + }, + "post": { + "description": "Create a new workflow, the workflow can also be created with empty filters and actions.", + "operationId": "createWorkflow", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.create", + "x-immich-state": "Alpha" + } + }, + "/workflows/{id}": { + "delete": { + "description": "Delete a workflow by its ID.", + "operationId": "deleteWorkflow", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.delete", + "x-immich-state": "Alpha" + }, + "get": { + "description": "Retrieve information about a specific workflow by its ID.", + "operationId": "getWorkflow", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.read", + "x-immich-state": "Alpha" + }, + "put": { + "description": "Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.", + "operationId": "updateWorkflow", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.update", + "x-immich-state": "Alpha" } } }, "info": { "title": "Immich", "description": "Immich API", - "version": "2.2.2", + "version": "2.2.3", "contact": {} }, - "tags": [], + "tags": [ + { + "name": "Activities", + "description": "An activity is a like or a comment made by a user on an asset or album." + }, + { + "name": "Albums", + "description": "An album is a collection of assets that can be shared with other users or via shared links." + }, + { + "name": "API keys", + "description": "An api key can be used to programmatically access the Immich API." + }, + { + "name": "Assets", + "description": "An asset is an image or video that has been uploaded to Immich." + }, + { + "name": "Authentication", + "description": "Endpoints related to user authentication, including OAuth." + }, + { + "name": "Authentication (admin)", + "description": "Administrative endpoints related to authentication." + }, + { + "name": "Deprecated", + "description": "Deprecated endpoints that are planned for removal in the next major release." + }, + { + "name": "Download", + "description": "Endpoints for downloading assets or collections of assets." + }, + { + "name": "Duplicates", + "description": "Endpoints for managing and identifying duplicate assets." + }, + { + "name": "Faces", + "description": "A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually." + }, + { + "name": "Jobs", + "description": "Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed." + }, + { + "name": "Libraries", + "description": "An external library is made up of input file paths or expressions that are scanned for asset files. Discovered files are automatically imported. Assets much be unique within a library, but can be duplicated across libraries. Each user has a default upload library, and can have one or more external libraries." + }, + { + "name": "Map", + "description": "Map endpoints include supplemental functionality related to geolocation, such as reverse geocoding and retrieving map markers for assets with geolocation data." + }, + { + "name": "Memories", + "description": "A memory is a specialized collection of assets with dedicated viewing implementations in the web and mobile clients. A memory includes fields related to visibility and are automatically generated per user via a background job." + }, + { + "name": "Notifications", + "description": "A notification is a specialized message sent to users to inform them of important events. Currently, these notifications are only shown in the Immich web application." + }, + { + "name": "Notifications (admin)", + "description": "Notification administrative endpoints." + }, + { + "name": "Partners", + "description": "A partner is a link with another user that allows sharing of assets between two users." + }, + { + "name": "People", + "description": "A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job." + }, + { + "name": "Plugins", + "description": "A plugin is an installed module that makes filters and actions available for the workflow feature." + }, + { + "name": "Search", + "description": "Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting." + }, + { + "name": "Server", + "description": "Information about the current server deployment, including version and build information, available features, supported media types, and more." + }, + { + "name": "Sessions", + "description": "A session represents an authenticated login session for a user. Sessions also appear in the web application as \"Authorized devices\"." + }, + { + "name": "Shared links", + "description": "A shared link is a public url that provides access to a specific album, asset, or collection of assets. A shared link can be protected with a password, include a specific slug, allow or disallow downloads, and optionally include an expiration date." + }, + { + "name": "Stacks", + "description": "A stack is a group of related assets. One asset is the \"primary\" asset, and the rest are \"child\" assets. On the main timeline, stack parents are included by default, while child assets are hidden." + }, + { + "name": "Sync", + "description": "A collection of endpoints for the new mobile synchronization implementation." + }, + { + "name": "System config", + "description": "Endpoints to view, modify, and validate the system configuration settings." + }, + { + "name": "System metadata", + "description": "Endpoints to view, modify, and validate the system metadata, which includes information about things like admin onboarding status." + }, + { + "name": "Tags", + "description": "A tag is a user-defined label that can be applied to assets for organizational purposes. Tags can also be hierarchical, allowing for parent-child relationships between tags." + }, + { + "name": "Timeline", + "description": "Specialized endpoints related to the timeline implementation used in the web application. External applications or tools should not use or rely on these endpoints, as they are subject to change without notice." + }, + { + "name": "Trash", + "description": "Endpoints for managing the trash can, which includes assets that have been discarded. Items in the trash are automatically deleted after a configured amount of time." + }, + { + "name": "Users (admin)", + "description": "Administrative endpoints for managing users, including creating, updating, deleting, and restoring users. Also includes endpoints for resetting passwords and PIN codes." + }, + { + "name": "Users", + "description": "Endpoints for viewing and updating the current users, including product key information, profile picture data, onboarding progress, and more." + }, + { + "name": "Views", + "description": "Endpoints for specialized views, such as the folder view." + }, + { + "name": "Workflows", + "description": "A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution." + } + ], "servers": [ { "url": "/api" @@ -10471,77 +14467,6 @@ }, "type": "object" }, - "AllJobStatusResponseDto": { - "properties": { - "backgroundTask": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "backupDatabase": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "duplicateDetection": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "faceDetection": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "facialRecognition": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "library": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "metadataExtraction": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "migration": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "notifications": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "ocr": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "search": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "sidecar": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "smartSearch": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "storageTemplateMigration": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "thumbnailGeneration": { - "$ref": "#/components/schemas/JobStatusDto" - }, - "videoConversion": { - "$ref": "#/components/schemas/JobStatusDto" - } - }, - "required": [ - "backgroundTask", - "backupDatabase", - "duplicateDetection", - "faceDetection", - "facialRecognition", - "library", - "metadataExtraction", - "migration", - "notifications", - "ocr", - "search", - "sidecar", - "smartSearch", - "storageTemplateMigration", - "thumbnailGeneration", - "videoConversion" - ], - "type": "object" - }, "AssetBulkDeleteDto": { "properties": { "force": { @@ -11387,9 +15312,19 @@ }, "libraryId": { "deprecated": true, - "description": "This property was deprecated in v1.106.0", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "livePhotoVideoId": { "nullable": true, @@ -11424,8 +15359,18 @@ }, "resized": { "deprecated": true, - "description": "This property was deprecated in v1.113.0", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v1.113.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "stack": { "allOf": [ @@ -12233,65 +16178,6 @@ ], "type": "string" }, - "JobCommand": { - "enum": [ - "start", - "pause", - "resume", - "empty", - "clear-failed" - ], - "type": "string" - }, - "JobCommandDto": { - "properties": { - "command": { - "allOf": [ - { - "$ref": "#/components/schemas/JobCommand" - } - ] - }, - "force": { - "type": "boolean" - } - }, - "required": [ - "command" - ], - "type": "object" - }, - "JobCountsDto": { - "properties": { - "active": { - "type": "integer" - }, - "completed": { - "type": "integer" - }, - "delayed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "paused": { - "type": "integer" - }, - "waiting": { - "type": "integer" - } - }, - "required": [ - "active", - "completed", - "delayed", - "failed", - "paused", - "waiting" - ], - "type": "object" - }, "JobCreateDto": { "properties": { "name": { @@ -12307,27 +16193,6 @@ ], "type": "object" }, - "JobName": { - "enum": [ - "thumbnailGeneration", - "metadataExtraction", - "videoConversion", - "faceDetection", - "facialRecognition", - "smartSearch", - "duplicateDetection", - "backgroundTask", - "storageTemplateMigration", - "migration", - "search", - "sidecar", - "library", - "notifications", - "backupDatabase", - "ocr" - ], - "type": "string" - }, "JobSettingsDto": { "properties": { "concurrency": { @@ -12340,21 +16205,6 @@ ], "type": "object" }, - "JobStatusDto": { - "properties": { - "jobCounts": { - "$ref": "#/components/schemas/JobCountsDto" - }, - "queueStatus": { - "$ref": "#/components/schemas/QueueStatusDto" - } - }, - "required": [ - "jobCounts", - "queueStatus" - ], - "type": "object" - }, "LibraryResponseDto": { "properties": { "assetCount": { @@ -12646,18 +16496,27 @@ }, "MemoriesResponse": { "properties": { + "duration": { + "default": 5, + "type": "integer" + }, "enabled": { "default": true, "type": "boolean" } }, "required": [ + "duration", "enabled" ], "type": "object" }, "MemoriesUpdate": { "properties": { + "duration": { + "minimum": 1, + "type": "integer" + }, "enabled": { "type": "boolean" } @@ -12771,6 +16630,14 @@ ], "type": "object" }, + "MemorySearchOrder": { + "enum": [ + "asc", + "desc", + "random" + ], + "type": "string" + }, "MemoryStatisticsResponseDto": { "properties": { "total": { @@ -13365,8 +17232,18 @@ "PeopleResponseDto": { "properties": { "hasNextPage": { - "description": "This property was added in v1.110.0", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1.110.0", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" }, "hidden": { "type": "integer" @@ -13528,6 +17405,10 @@ "pinCode.create", "pinCode.update", "pinCode.delete", + "plugin.create", + "plugin.read", + "plugin.update", + "plugin.delete", "server.about", "server.apkLinks", "server.storage", @@ -13577,6 +17458,10 @@ "userProfileImage.read", "userProfileImage.update", "userProfileImage.delete", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", "adminUser.create", "adminUser.read", "adminUser.update", @@ -13620,15 +17505,35 @@ "type": "string" }, "color": { - "description": "This property was added in v1.126.0", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1.126.0", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" }, "id": { "type": "string" }, "isFavorite": { - "description": "This property was added in v1.126.0", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1.126.0", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" }, "isHidden": { "type": "boolean" @@ -13640,9 +17545,19 @@ "type": "string" }, "updatedAt": { - "description": "This property was added in v1.107.0", "format": "date-time", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1.107.0", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "required": [ @@ -13704,8 +17619,18 @@ "type": "string" }, "color": { - "description": "This property was added in v1.126.0", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1.126.0", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" }, "faces": { "items": { @@ -13717,8 +17642,18 @@ "type": "string" }, "isFavorite": { - "description": "This property was added in v1.126.0", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1.126.0", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" }, "isHidden": { "type": "boolean" @@ -13730,9 +17665,19 @@ "type": "string" }, "updatedAt": { - "description": "This property was added in v1.107.0", "format": "date-time", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1.107.0", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + } + ], + "x-immich-state": "Stable" } }, "required": [ @@ -13813,6 +17758,152 @@ ], "type": "object" }, + "PluginActionResponseDto": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "methodName": { + "type": "string" + }, + "pluginId": { + "type": "string" + }, + "schema": { + "nullable": true, + "type": "object" + }, + "supportedContexts": { + "items": { + "$ref": "#/components/schemas/PluginContext" + }, + "type": "array" + }, + "title": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "methodName", + "pluginId", + "schema", + "supportedContexts", + "title" + ], + "type": "object" + }, + "PluginContext": { + "enum": [ + "asset", + "album", + "person" + ], + "type": "string" + }, + "PluginFilterResponseDto": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "methodName": { + "type": "string" + }, + "pluginId": { + "type": "string" + }, + "schema": { + "nullable": true, + "type": "object" + }, + "supportedContexts": { + "items": { + "$ref": "#/components/schemas/PluginContext" + }, + "type": "array" + }, + "title": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "methodName", + "pluginId", + "schema", + "supportedContexts", + "title" + ], + "type": "object" + }, + "PluginResponseDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/PluginActionResponseDto" + }, + "type": "array" + }, + "author": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/PluginFilterResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "actions", + "author", + "createdAt", + "description", + "filters", + "id", + "name", + "title", + "updatedAt", + "version" + ], + "type": "object" + }, + "PluginTriggerType": { + "enum": [ + "AssetCreate", + "PersonRecognized" + ], + "type": "string" + }, "PurchaseResponse": { "properties": { "hideBuyButtonUntil": { @@ -13839,6 +17930,102 @@ }, "type": "object" }, + "QueueCommand": { + "enum": [ + "start", + "pause", + "resume", + "empty", + "clear-failed" + ], + "type": "string" + }, + "QueueCommandDto": { + "properties": { + "command": { + "allOf": [ + { + "$ref": "#/components/schemas/QueueCommand" + } + ] + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "command" + ], + "type": "object" + }, + "QueueName": { + "enum": [ + "thumbnailGeneration", + "metadataExtraction", + "videoConversion", + "faceDetection", + "facialRecognition", + "smartSearch", + "duplicateDetection", + "backgroundTask", + "storageTemplateMigration", + "migration", + "search", + "sidecar", + "library", + "notifications", + "backupDatabase", + "ocr", + "workflow" + ], + "type": "string" + }, + "QueueResponseDto": { + "properties": { + "jobCounts": { + "$ref": "#/components/schemas/QueueStatisticsDto" + }, + "queueStatus": { + "$ref": "#/components/schemas/QueueStatusDto" + } + }, + "required": [ + "jobCounts", + "queueStatus" + ], + "type": "object" + }, + "QueueStatisticsDto": { + "properties": { + "active": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "delayed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "paused": { + "type": "integer" + }, + "waiting": { + "type": "integer" + } + }, + "required": [ + "active", + "completed", + "delayed", + "failed", + "paused", + "waiting" + ], + "type": "object" + }, "QueueStatusDto": { "properties": { "isActive": { @@ -13854,6 +18041,81 @@ ], "type": "object" }, + "QueuesResponseDto": { + "properties": { + "backgroundTask": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "backupDatabase": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "duplicateDetection": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "faceDetection": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "facialRecognition": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "library": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "metadataExtraction": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "migration": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "notifications": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "ocr": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "search": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "sidecar": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "smartSearch": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "storageTemplateMigration": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "thumbnailGeneration": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "videoConversion": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "workflow": { + "$ref": "#/components/schemas/QueueResponseDto" + } + }, + "required": [ + "backgroundTask", + "backupDatabase", + "duplicateDetection", + "faceDetection", + "facialRecognition", + "library", + "metadataExtraction", + "migration", + "notifications", + "ocr", + "search", + "sidecar", + "smartSearch", + "storageTemplateMigration", + "thumbnailGeneration", + "videoConversion", + "workflow" + ], + "type": "object" + }, "RandomSearchDto": { "properties": { "albumIds": { @@ -16700,6 +20962,9 @@ }, "videoConversion": { "$ref": "#/components/schemas/JobSettingsDto" + }, + "workflow": { + "$ref": "#/components/schemas/JobSettingsDto" } }, "required": [ @@ -16714,7 +20979,8 @@ "sidecar", "smartSearch", "thumbnailGeneration", - "videoConversion" + "videoConversion", + "workflow" ], "type": "object" }, @@ -18279,6 +22545,211 @@ "webm" ], "type": "string" + }, + "WorkflowActionItemDto": { + "properties": { + "actionConfig": { + "type": "object" + }, + "actionId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "actionId" + ], + "type": "object" + }, + "WorkflowActionResponseDto": { + "properties": { + "actionConfig": { + "nullable": true, + "type": "object" + }, + "actionId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order": { + "type": "number" + }, + "workflowId": { + "type": "string" + } + }, + "required": [ + "actionConfig", + "actionId", + "id", + "order", + "workflowId" + ], + "type": "object" + }, + "WorkflowCreateDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/WorkflowActionItemDto" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/WorkflowFilterItemDto" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "triggerType": { + "allOf": [ + { + "$ref": "#/components/schemas/PluginTriggerType" + } + ] + } + }, + "required": [ + "actions", + "filters", + "name", + "triggerType" + ], + "type": "object" + }, + "WorkflowFilterItemDto": { + "properties": { + "filterConfig": { + "type": "object" + }, + "filterId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "filterId" + ], + "type": "object" + }, + "WorkflowFilterResponseDto": { + "properties": { + "filterConfig": { + "nullable": true, + "type": "object" + }, + "filterId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order": { + "type": "number" + }, + "workflowId": { + "type": "string" + } + }, + "required": [ + "filterConfig", + "filterId", + "id", + "order", + "workflowId" + ], + "type": "object" + }, + "WorkflowResponseDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/WorkflowActionResponseDto" + }, + "type": "array" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/WorkflowFilterResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "name": { + "nullable": true, + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "triggerType": { + "enum": [ + "AssetCreate", + "PersonRecognized" + ], + "type": "string" + } + }, + "required": [ + "actions", + "createdAt", + "description", + "enabled", + "filters", + "id", + "name", + "ownerId", + "triggerType" + ], + "type": "object" + }, + "WorkflowUpdateDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/WorkflowActionItemDto" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/WorkflowFilterItemDto" + }, + "type": "array" + }, + "name": { + "type": "string" + } + }, + "type": "object" } } } diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 9e62898cee..1756675ddd 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "2.2.2", + "version": "2.2.3", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.18.12", + "@types/node": "^22.19.0", "typescript": "^5.3.3" }, "repository": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f6a68ee8df..0664d26995 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 2.2.2 + * 2.2.3 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -152,6 +152,7 @@ export type FoldersResponse = { sidebarWeb: boolean; }; export type MemoriesResponse = { + duration: number; enabled: boolean; }; export type PeopleResponse = { @@ -209,6 +210,7 @@ export type FoldersUpdate = { sidebarWeb?: boolean; }; export type MemoriesUpdate = { + duration?: number; enabled?: boolean; }; export type PeopleUpdate = { @@ -300,16 +302,13 @@ export type AssetFaceWithoutPersonResponseDto = { }; export type PersonWithFacesResponseDto = { birthDate: string | null; - /** This property was added in v1.126.0 */ color?: string; faces: AssetFaceWithoutPersonResponseDto[]; id: string; - /** This property was added in v1.126.0 */ isFavorite?: boolean; isHidden: boolean; name: string; thumbnailPath: string; - /** This property was added in v1.107.0 */ updatedAt?: string; }; export type AssetStackResponseDto = { @@ -346,7 +345,6 @@ export type AssetResponseDto = { isFavorite: boolean; isOffline: boolean; isTrashed: boolean; - /** This property was deprecated in v1.106.0 */ libraryId?: string | null; livePhotoVideoId?: string | null; /** The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer's local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months. */ @@ -357,7 +355,6 @@ export type AssetResponseDto = { owner?: UserResponseDto; ownerId: string; people?: PersonWithFacesResponseDto[]; - /** This property was deprecated in v1.113.0 */ resized?: boolean; stack?: (AssetStackResponseDto) | null; tags?: TagResponseDto[]; @@ -667,15 +664,12 @@ export type DuplicateResponseDto = { }; export type PersonResponseDto = { birthDate: string | null; - /** This property was added in v1.126.0 */ color?: string; id: string; - /** This property was added in v1.126.0 */ isFavorite?: boolean; isHidden: boolean; name: string; thumbnailPath: string; - /** This property was added in v1.107.0 */ updatedAt?: string; }; export type AssetFaceResponseDto = { @@ -705,7 +699,7 @@ export type AssetFaceDeleteDto = { export type FaceDto = { id: string; }; -export type JobCountsDto = { +export type QueueStatisticsDto = { active: number; completed: number; delayed: number; @@ -717,33 +711,34 @@ export type QueueStatusDto = { isActive: boolean; isPaused: boolean; }; -export type JobStatusDto = { - jobCounts: JobCountsDto; +export type QueueResponseDto = { + jobCounts: QueueStatisticsDto; queueStatus: QueueStatusDto; }; -export type AllJobStatusResponseDto = { - backgroundTask: JobStatusDto; - backupDatabase: JobStatusDto; - duplicateDetection: JobStatusDto; - faceDetection: JobStatusDto; - facialRecognition: JobStatusDto; - library: JobStatusDto; - metadataExtraction: JobStatusDto; - migration: JobStatusDto; - notifications: JobStatusDto; - ocr: JobStatusDto; - search: JobStatusDto; - sidecar: JobStatusDto; - smartSearch: JobStatusDto; - storageTemplateMigration: JobStatusDto; - thumbnailGeneration: JobStatusDto; - videoConversion: JobStatusDto; +export type QueuesResponseDto = { + backgroundTask: QueueResponseDto; + backupDatabase: QueueResponseDto; + duplicateDetection: QueueResponseDto; + faceDetection: QueueResponseDto; + facialRecognition: QueueResponseDto; + library: QueueResponseDto; + metadataExtraction: QueueResponseDto; + migration: QueueResponseDto; + notifications: QueueResponseDto; + ocr: QueueResponseDto; + search: QueueResponseDto; + sidecar: QueueResponseDto; + smartSearch: QueueResponseDto; + storageTemplateMigration: QueueResponseDto; + thumbnailGeneration: QueueResponseDto; + videoConversion: QueueResponseDto; + workflow: QueueResponseDto; }; export type JobCreateDto = { name: ManualJobName; }; -export type JobCommandDto = { - command: JobCommand; +export type QueueCommandDto = { + command: QueueCommand; force?: boolean; }; export type LibraryResponseDto = { @@ -872,7 +867,6 @@ export type PartnerUpdateDto = { inTimeline: boolean; }; export type PeopleResponseDto = { - /** This property was added in v1.110.0 */ hasNextPage?: boolean; hidden: number; people: PersonResponseDto[]; @@ -933,6 +927,36 @@ export type AssetFaceUpdateDto = { export type PersonStatisticsResponseDto = { assets: number; }; +export type PluginActionResponseDto = { + description: string; + id: string; + methodName: string; + pluginId: string; + schema: object | null; + supportedContexts: PluginContext[]; + title: string; +}; +export type PluginFilterResponseDto = { + description: string; + id: string; + methodName: string; + pluginId: string; + schema: object | null; + supportedContexts: PluginContext[]; + title: string; +}; +export type PluginResponseDto = { + actions: PluginActionResponseDto[]; + author: string; + createdAt: string; + description: string; + filters: PluginFilterResponseDto[]; + id: string; + name: string; + title: string; + updatedAt: string; + version: string; +}; export type SearchExploreItem = { data: AssetResponseDto; value: string; @@ -1418,6 +1442,7 @@ export type SystemConfigJobDto = { smartSearch: JobSettingsDto; thumbnailGeneration: JobSettingsDto; videoConversion: JobSettingsDto; + workflow: JobSettingsDto; }; export type SystemConfigLibraryScanDto = { cronExpression: string; @@ -1674,8 +1699,56 @@ export type CreateProfileImageResponseDto = { profileImagePath: string; userId: string; }; +export type WorkflowActionResponseDto = { + actionConfig: object | null; + actionId: string; + id: string; + order: number; + workflowId: string; +}; +export type WorkflowFilterResponseDto = { + filterConfig: object | null; + filterId: string; + id: string; + order: number; + workflowId: string; +}; +export type WorkflowResponseDto = { + actions: WorkflowActionResponseDto[]; + createdAt: string; + description: string; + enabled: boolean; + filters: WorkflowFilterResponseDto[]; + id: string; + name: string | null; + ownerId: string; + triggerType: TriggerType; +}; +export type WorkflowActionItemDto = { + actionConfig?: object; + actionId: string; +}; +export type WorkflowFilterItemDto = { + filterConfig?: object; + filterId: string; +}; +export type WorkflowCreateDto = { + actions: WorkflowActionItemDto[]; + description?: string; + enabled?: boolean; + filters: WorkflowFilterItemDto[]; + name: string; + triggerType: PluginTriggerType; +}; +export type WorkflowUpdateDto = { + actions?: WorkflowActionItemDto[]; + description?: string; + enabled?: boolean; + filters?: WorkflowFilterItemDto[]; + name?: string; +}; /** - * This endpoint requires the `activity.read` permission. + * List all activities */ export function getActivities({ albumId, assetId, level, $type, userId }: { albumId: string; @@ -1698,7 +1771,7 @@ export function getActivities({ albumId, assetId, level, $type, userId }: { })); } /** - * This endpoint requires the `activity.create` permission. + * Create an activity */ export function createActivity({ activityCreateDto }: { activityCreateDto: ActivityCreateDto; @@ -1713,7 +1786,7 @@ export function createActivity({ activityCreateDto }: { }))); } /** - * This endpoint requires the `activity.statistics` permission. + * Retrieve activity statistics */ export function getActivityStatistics({ albumId, assetId }: { albumId: string; @@ -1730,7 +1803,7 @@ export function getActivityStatistics({ albumId, assetId }: { })); } /** - * This endpoint requires the `activity.delete` permission. + * Delete an activity */ export function deleteActivity({ id }: { id: string; @@ -1741,7 +1814,7 @@ export function deleteActivity({ id }: { })); } /** - * This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission. + * Unlink all OAuth accounts */ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/admin/auth/unlink-all", { @@ -1749,6 +1822,9 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) { method: "POST" })); } +/** + * Create a notification + */ export function createNotification({ notificationCreateDto }: { notificationCreateDto: NotificationCreateDto; }, opts?: Oazapfts.RequestOpts) { @@ -1761,6 +1837,9 @@ export function createNotification({ notificationCreateDto }: { body: notificationCreateDto }))); } +/** + * Render email template + */ export function getNotificationTemplateAdmin({ name, templateDto }: { name: string; templateDto: TemplateDto; @@ -1774,6 +1853,9 @@ export function getNotificationTemplateAdmin({ name, templateDto }: { body: templateDto }))); } +/** + * Send test email + */ export function sendTestEmailAdmin({ systemConfigSmtpDto }: { systemConfigSmtpDto: SystemConfigSmtpDto; }, opts?: Oazapfts.RequestOpts) { @@ -1787,7 +1869,7 @@ export function sendTestEmailAdmin({ systemConfigSmtpDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `adminUser.read` permission. + * Search users */ export function searchUsersAdmin({ id, withDeleted }: { id?: string; @@ -1804,7 +1886,7 @@ export function searchUsersAdmin({ id, withDeleted }: { })); } /** - * This endpoint is an admin-only route, and requires the `adminUser.create` permission. + * Create a user */ export function createUserAdmin({ userAdminCreateDto }: { userAdminCreateDto: UserAdminCreateDto; @@ -1819,7 +1901,7 @@ export function createUserAdmin({ userAdminCreateDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `adminUser.delete` permission. + * Delete a user */ export function deleteUserAdmin({ id, userAdminDeleteDto }: { id: string; @@ -1835,7 +1917,7 @@ export function deleteUserAdmin({ id, userAdminDeleteDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `adminUser.read` permission. + * Retrieve a user */ export function getUserAdmin({ id }: { id: string; @@ -1848,7 +1930,7 @@ export function getUserAdmin({ id }: { })); } /** - * This endpoint is an admin-only route, and requires the `adminUser.update` permission. + * Update a user */ export function updateUserAdmin({ id, userAdminUpdateDto }: { id: string; @@ -1864,7 +1946,7 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `adminUser.read` permission. + * Retrieve user preferences */ export function getUserPreferencesAdmin({ id }: { id: string; @@ -1877,7 +1959,7 @@ export function getUserPreferencesAdmin({ id }: { })); } /** - * This endpoint is an admin-only route, and requires the `adminUser.update` permission. + * Update user preferences */ export function updateUserPreferencesAdmin({ id, userPreferencesUpdateDto }: { id: string; @@ -1893,7 +1975,7 @@ export function updateUserPreferencesAdmin({ id, userPreferencesUpdateDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `adminUser.delete` permission. + * Restore a deleted user */ export function restoreUserAdmin({ id }: { id: string; @@ -1907,7 +1989,7 @@ export function restoreUserAdmin({ id }: { })); } /** - * This endpoint is an admin-only route, and requires the `adminSession.read` permission. + * Retrieve user sessions */ export function getUserSessionsAdmin({ id }: { id: string; @@ -1920,7 +2002,7 @@ export function getUserSessionsAdmin({ id }: { })); } /** - * This endpoint is an admin-only route, and requires the `adminUser.read` permission. + * Retrieve user statistics */ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }: { id: string; @@ -1940,7 +2022,7 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility } })); } /** - * This endpoint requires the `album.read` permission. + * List all albums */ export function getAllAlbums({ assetId, shared }: { assetId?: string; @@ -1957,7 +2039,7 @@ export function getAllAlbums({ assetId, shared }: { })); } /** - * This endpoint requires the `album.create` permission. + * Create an album */ export function createAlbum({ createAlbumDto }: { createAlbumDto: CreateAlbumDto; @@ -1972,7 +2054,7 @@ export function createAlbum({ createAlbumDto }: { }))); } /** - * This endpoint requires the `albumAsset.create` permission. + * Add assets to albums */ export function addAssetsToAlbums({ key, slug, albumsAddAssetsDto }: { key?: string; @@ -1992,7 +2074,7 @@ export function addAssetsToAlbums({ key, slug, albumsAddAssetsDto }: { }))); } /** - * This endpoint requires the `album.statistics` permission. + * Retrieve album statistics */ export function getAlbumStatistics(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -2003,7 +2085,7 @@ export function getAlbumStatistics(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `album.delete` permission. + * Delete an album */ export function deleteAlbum({ id }: { id: string; @@ -2014,7 +2096,7 @@ export function deleteAlbum({ id }: { })); } /** - * This endpoint requires the `album.read` permission. + * Retrieve an album */ export function getAlbumInfo({ id, key, slug, withoutAssets }: { id: string; @@ -2034,7 +2116,7 @@ export function getAlbumInfo({ id, key, slug, withoutAssets }: { })); } /** - * This endpoint requires the `album.update` permission. + * Update an album */ export function updateAlbumInfo({ id, updateAlbumDto }: { id: string; @@ -2050,7 +2132,7 @@ export function updateAlbumInfo({ id, updateAlbumDto }: { }))); } /** - * This endpoint requires the `albumAsset.delete` permission. + * Remove assets from an album */ export function removeAssetFromAlbum({ id, bulkIdsDto }: { id: string; @@ -2066,7 +2148,7 @@ export function removeAssetFromAlbum({ id, bulkIdsDto }: { }))); } /** - * This endpoint requires the `albumAsset.create` permission. + * Add assets to an album */ export function addAssetsToAlbum({ id, key, slug, bulkIdsDto }: { id: string; @@ -2087,7 +2169,7 @@ export function addAssetsToAlbum({ id, key, slug, bulkIdsDto }: { }))); } /** - * This endpoint requires the `albumUser.delete` permission. + * Remove user from album */ export function removeUserFromAlbum({ id, userId }: { id: string; @@ -2099,7 +2181,7 @@ export function removeUserFromAlbum({ id, userId }: { })); } /** - * This endpoint requires the `albumUser.update` permission. + * Update user role */ export function updateAlbumUser({ id, userId, updateAlbumUserDto }: { id: string; @@ -2113,7 +2195,7 @@ export function updateAlbumUser({ id, userId, updateAlbumUserDto }: { }))); } /** - * This endpoint requires the `albumUser.create` permission. + * Share album with users */ export function addUsersToAlbum({ id, addUsersDto }: { id: string; @@ -2129,7 +2211,7 @@ export function addUsersToAlbum({ id, addUsersDto }: { }))); } /** - * This endpoint requires the `apiKey.read` permission. + * List all API keys */ export function getApiKeys(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -2140,7 +2222,7 @@ export function getApiKeys(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `apiKey.create` permission. + * Create an API key */ export function createApiKey({ apiKeyCreateDto }: { apiKeyCreateDto: ApiKeyCreateDto; @@ -2154,6 +2236,9 @@ export function createApiKey({ apiKeyCreateDto }: { body: apiKeyCreateDto }))); } +/** + * Retrieve the current API key + */ export function getMyApiKey(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2163,7 +2248,7 @@ export function getMyApiKey(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `apiKey.delete` permission. + * Delete an API key */ export function deleteApiKey({ id }: { id: string; @@ -2174,7 +2259,7 @@ export function deleteApiKey({ id }: { })); } /** - * This endpoint requires the `apiKey.read` permission. + * Retrieve an API key */ export function getApiKey({ id }: { id: string; @@ -2187,7 +2272,7 @@ export function getApiKey({ id }: { })); } /** - * This endpoint requires the `apiKey.update` permission. + * Update an API key */ export function updateApiKey({ id, apiKeyUpdateDto }: { id: string; @@ -2203,7 +2288,7 @@ export function updateApiKey({ id, apiKeyUpdateDto }: { }))); } /** - * This endpoint requires the `asset.delete` permission. + * Delete assets */ export function deleteAssets({ assetBulkDeleteDto }: { assetBulkDeleteDto: AssetBulkDeleteDto; @@ -2215,7 +2300,7 @@ export function deleteAssets({ assetBulkDeleteDto }: { }))); } /** - * This endpoint requires the `asset.upload` permission. + * Upload asset */ export function uploadAsset({ key, slug, xImmichChecksum, assetMediaCreateDto }: { key?: string; @@ -2239,7 +2324,7 @@ export function uploadAsset({ key, slug, xImmichChecksum, assetMediaCreateDto }: }))); } /** - * This endpoint requires the `asset.update` permission. + * Update assets */ export function updateAssets({ assetBulkUpdateDto }: { assetBulkUpdateDto: AssetBulkUpdateDto; @@ -2251,7 +2336,7 @@ export function updateAssets({ assetBulkUpdateDto }: { }))); } /** - * checkBulkUpload + * Check bulk upload */ export function checkBulkUpload({ assetBulkUploadCheckDto }: { assetBulkUploadCheckDto: AssetBulkUploadCheckDto; @@ -2266,7 +2351,7 @@ export function checkBulkUpload({ assetBulkUploadCheckDto }: { }))); } /** - * This endpoint requires the `asset.copy` permission. + * Copy asset */ export function copyAsset({ assetCopyDto }: { assetCopyDto: AssetCopyDto; @@ -2278,7 +2363,7 @@ export function copyAsset({ assetCopyDto }: { }))); } /** - * getAllUserAssetsByDeviceId + * Retrieve assets by device ID */ export function getAllUserAssetsByDeviceId({ deviceId }: { deviceId: string; @@ -2291,7 +2376,7 @@ export function getAllUserAssetsByDeviceId({ deviceId }: { })); } /** - * checkExistingAssets + * Check existing assets */ export function checkExistingAssets({ checkExistingAssetsDto }: { checkExistingAssetsDto: CheckExistingAssetsDto; @@ -2305,6 +2390,9 @@ export function checkExistingAssets({ checkExistingAssetsDto }: { body: checkExistingAssetsDto }))); } +/** + * Run an asset job + */ export function runAssetJobs({ assetJobsDto }: { assetJobsDto: AssetJobsDto; }, opts?: Oazapfts.RequestOpts) { @@ -2315,7 +2403,7 @@ export function runAssetJobs({ assetJobsDto }: { }))); } /** - * This property was deprecated in v1.116.0. This endpoint requires the `asset.read` permission. + * Get random assets */ export function getRandom({ count }: { count?: number; @@ -2330,7 +2418,7 @@ export function getRandom({ count }: { })); } /** - * This endpoint requires the `asset.statistics` permission. + * Get asset statistics */ export function getAssetStatistics({ isFavorite, isTrashed, visibility }: { isFavorite?: boolean; @@ -2349,7 +2437,7 @@ export function getAssetStatistics({ isFavorite, isTrashed, visibility }: { })); } /** - * This endpoint requires the `asset.read` permission. + * Retrieve an asset */ export function getAssetInfo({ id, key, slug }: { id: string; @@ -2367,7 +2455,7 @@ export function getAssetInfo({ id, key, slug }: { })); } /** - * This endpoint requires the `asset.update` permission. + * Update an asset */ export function updateAsset({ id, updateAssetDto }: { id: string; @@ -2383,7 +2471,7 @@ export function updateAsset({ id, updateAssetDto }: { }))); } /** - * This endpoint requires the `asset.read` permission. + * Get asset metadata */ export function getAssetMetadata({ id }: { id: string; @@ -2396,7 +2484,7 @@ export function getAssetMetadata({ id }: { })); } /** - * This endpoint requires the `asset.update` permission. + * Update asset metadata */ export function updateAssetMetadata({ id, assetMetadataUpsertDto }: { id: string; @@ -2412,7 +2500,7 @@ export function updateAssetMetadata({ id, assetMetadataUpsertDto }: { }))); } /** - * This endpoint requires the `asset.update` permission. + * Delete asset metadata by key */ export function deleteAssetMetadata({ id, key }: { id: string; @@ -2424,7 +2512,7 @@ export function deleteAssetMetadata({ id, key }: { })); } /** - * This endpoint requires the `asset.read` permission. + * Retrieve asset metadata by key */ export function getAssetMetadataByKey({ id, key }: { id: string; @@ -2438,7 +2526,7 @@ export function getAssetMetadataByKey({ id, key }: { })); } /** - * This endpoint requires the `asset.read` permission. + * Retrieve asset OCR data */ export function getAssetOcr({ id }: { id: string; @@ -2451,7 +2539,7 @@ export function getAssetOcr({ id }: { })); } /** - * This endpoint requires the `asset.download` permission. + * Download original asset */ export function downloadAsset({ id, key, slug }: { id: string; @@ -2469,7 +2557,7 @@ export function downloadAsset({ id, key, slug }: { })); } /** - * Replace the asset with new file, without changing its id + * Replace asset */ export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: { id: string; @@ -2490,7 +2578,7 @@ export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: { }))); } /** - * This endpoint requires the `asset.view` permission. + * View asset thumbnail */ export function viewAsset({ id, key, size, slug }: { id: string; @@ -2510,7 +2598,7 @@ export function viewAsset({ id, key, size, slug }: { })); } /** - * This endpoint requires the `asset.view` permission. + * Play asset video */ export function playAssetVideo({ id, key, slug }: { id: string; @@ -2527,6 +2615,9 @@ export function playAssetVideo({ id, key, slug }: { ...opts })); } +/** + * Register admin + */ export function signUpAdmin({ signUpDto }: { signUpDto: SignUpDto; }, opts?: Oazapfts.RequestOpts) { @@ -2540,7 +2631,7 @@ export function signUpAdmin({ signUpDto }: { }))); } /** - * This endpoint requires the `auth.changePassword` permission. + * Change password */ export function changePassword({ changePasswordDto }: { changePasswordDto: ChangePasswordDto; @@ -2554,6 +2645,9 @@ export function changePassword({ changePasswordDto }: { body: changePasswordDto }))); } +/** + * Login + */ export function login({ loginCredentialDto }: { loginCredentialDto: LoginCredentialDto; }, opts?: Oazapfts.RequestOpts) { @@ -2566,6 +2660,9 @@ export function login({ loginCredentialDto }: { body: loginCredentialDto }))); } +/** + * Logout + */ export function logout(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2576,7 +2673,7 @@ export function logout(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `pinCode.delete` permission. + * Reset pin code */ export function resetPinCode({ pinCodeResetDto }: { pinCodeResetDto: PinCodeResetDto; @@ -2588,7 +2685,7 @@ export function resetPinCode({ pinCodeResetDto }: { }))); } /** - * This endpoint requires the `pinCode.create` permission. + * Setup pin code */ export function setupPinCode({ pinCodeSetupDto }: { pinCodeSetupDto: PinCodeSetupDto; @@ -2600,7 +2697,7 @@ export function setupPinCode({ pinCodeSetupDto }: { }))); } /** - * This endpoint requires the `pinCode.update` permission. + * Change pin code */ export function changePinCode({ pinCodeChangeDto }: { pinCodeChangeDto: PinCodeChangeDto; @@ -2611,12 +2708,18 @@ export function changePinCode({ pinCodeChangeDto }: { body: pinCodeChangeDto }))); } +/** + * Lock auth session + */ export function lockAuthSession(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/auth/session/lock", { ...opts, method: "POST" })); } +/** + * Unlock auth session + */ export function unlockAuthSession({ sessionUnlockDto }: { sessionUnlockDto: SessionUnlockDto; }, opts?: Oazapfts.RequestOpts) { @@ -2626,6 +2729,9 @@ export function unlockAuthSession({ sessionUnlockDto }: { body: sessionUnlockDto }))); } +/** + * Retrieve auth status + */ export function getAuthStatus(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2634,6 +2740,9 @@ export function getAuthStatus(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * Validate access token + */ export function validateAccessToken(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2644,7 +2753,7 @@ export function validateAccessToken(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `asset.download` permission. + * Download asset archive */ export function downloadArchive({ key, slug, assetIdsDto }: { key?: string; @@ -2664,7 +2773,7 @@ export function downloadArchive({ key, slug, assetIdsDto }: { }))); } /** - * This endpoint requires the `asset.download` permission. + * Retrieve download information */ export function getDownloadInfo({ key, slug, downloadInfoDto }: { key?: string; @@ -2684,7 +2793,7 @@ export function getDownloadInfo({ key, slug, downloadInfoDto }: { }))); } /** - * This endpoint requires the `duplicate.delete` permission. + * Delete duplicates */ export function deleteDuplicates({ bulkIdsDto }: { bulkIdsDto: BulkIdsDto; @@ -2696,7 +2805,7 @@ export function deleteDuplicates({ bulkIdsDto }: { }))); } /** - * This endpoint requires the `duplicate.read` permission. + * Retrieve duplicates */ export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -2707,7 +2816,7 @@ export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `duplicate.delete` permission. + * Delete a duplicate */ export function deleteDuplicate({ id }: { id: string; @@ -2718,7 +2827,7 @@ export function deleteDuplicate({ id }: { })); } /** - * This endpoint requires the `face.read` permission. + * Retrieve faces for asset */ export function getFaces({ id }: { id: string; @@ -2733,7 +2842,7 @@ export function getFaces({ id }: { })); } /** - * This endpoint requires the `face.create` permission. + * Create a face */ export function createFace({ assetFaceCreateDto }: { assetFaceCreateDto: AssetFaceCreateDto; @@ -2745,7 +2854,7 @@ export function createFace({ assetFaceCreateDto }: { }))); } /** - * This endpoint requires the `face.delete` permission. + * Delete a face */ export function deleteFace({ id, assetFaceDeleteDto }: { id: string; @@ -2758,7 +2867,7 @@ export function deleteFace({ id, assetFaceDeleteDto }: { }))); } /** - * This endpoint requires the `face.update` permission. + * Re-assign a face to another person */ export function reassignFacesById({ id, faceDto }: { id: string; @@ -2774,18 +2883,18 @@ export function reassignFacesById({ id, faceDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `job.read` permission. + * Retrieve queue counts and status */ -export function getAllJobsStatus(opts?: Oazapfts.RequestOpts) { +export function getQueuesLegacy(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AllJobStatusResponseDto; + data: QueuesResponseDto; }>("/jobs", { ...opts })); } /** - * This endpoint is an admin-only route, and requires the `job.create` permission. + * Create a manual job */ export function createJob({ jobCreateDto }: { jobCreateDto: JobCreateDto; @@ -2797,23 +2906,23 @@ export function createJob({ jobCreateDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `job.create` permission. + * Run jobs */ -export function sendJobCommand({ id, jobCommandDto }: { - id: JobName; - jobCommandDto: JobCommandDto; +export function runQueueCommandLegacy({ name, queueCommandDto }: { + name: QueueName; + queueCommandDto: QueueCommandDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: JobStatusDto; - }>(`/jobs/${encodeURIComponent(id)}`, oazapfts.json({ + data: QueueResponseDto; + }>(`/jobs/${encodeURIComponent(name)}`, oazapfts.json({ ...opts, method: "PUT", - body: jobCommandDto + body: queueCommandDto }))); } /** - * This endpoint is an admin-only route, and requires the `library.read` permission. + * Retrieve libraries */ export function getAllLibraries(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -2824,7 +2933,7 @@ export function getAllLibraries(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint is an admin-only route, and requires the `library.create` permission. + * Create a library */ export function createLibrary({ createLibraryDto }: { createLibraryDto: CreateLibraryDto; @@ -2839,7 +2948,7 @@ export function createLibrary({ createLibraryDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `library.delete` permission. + * Delete a library */ export function deleteLibrary({ id }: { id: string; @@ -2850,7 +2959,7 @@ export function deleteLibrary({ id }: { })); } /** - * This endpoint is an admin-only route, and requires the `library.read` permission. + * Retrieve a library */ export function getLibrary({ id }: { id: string; @@ -2863,7 +2972,7 @@ export function getLibrary({ id }: { })); } /** - * This endpoint is an admin-only route, and requires the `library.update` permission. + * Update a library */ export function updateLibrary({ id, updateLibraryDto }: { id: string; @@ -2879,7 +2988,7 @@ export function updateLibrary({ id, updateLibraryDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `library.update` permission. + * Scan a library */ export function scanLibrary({ id }: { id: string; @@ -2890,7 +2999,7 @@ export function scanLibrary({ id }: { })); } /** - * This endpoint is an admin-only route, and requires the `library.statistics` permission. + * Retrieve library statistics */ export function getLibraryStatistics({ id }: { id: string; @@ -2902,6 +3011,9 @@ export function getLibraryStatistics({ id }: { ...opts })); } +/** + * Validate library settings + */ export function validate({ id, validateLibraryDto }: { id: string; validateLibraryDto: ValidateLibraryDto; @@ -2915,11 +3027,14 @@ export function validate({ id, validateLibraryDto }: { body: validateLibraryDto }))); } -export function getMapMarkers({ isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore, withPartners, withSharedAlbums }: { - isArchived?: boolean; - isFavorite?: boolean; +/** + * Retrieve map markers + */ +export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners, withSharedAlbums }: { fileCreatedAfter?: string; fileCreatedBefore?: string; + isArchived?: boolean; + isFavorite?: boolean; withPartners?: boolean; withSharedAlbums?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -2927,16 +3042,19 @@ export function getMapMarkers({ isArchived, isFavorite, fileCreatedAfter, fileCr status: 200; data: MapMarkerResponseDto[]; }>(`/map/markers${QS.query(QS.explode({ - isArchived, - isFavorite, fileCreatedAfter, fileCreatedBefore, + isArchived, + isFavorite, withPartners, withSharedAlbums }))}`, { ...opts })); } +/** + * Reverse geocode coordinates + */ export function reverseGeocode({ lat, lon }: { lat: number; lon: number; @@ -2952,12 +3070,14 @@ export function reverseGeocode({ lat, lon }: { })); } /** - * This endpoint requires the `memory.read` permission. + * Retrieve memories */ -export function searchMemories({ $for, isSaved, isTrashed, $type }: { +export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: { $for?: string; isSaved?: boolean; isTrashed?: boolean; + order?: MemorySearchOrder; + size?: number; $type?: MemoryType; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -2967,13 +3087,15 @@ export function searchMemories({ $for, isSaved, isTrashed, $type }: { "for": $for, isSaved, isTrashed, + order, + size, "type": $type }))}`, { ...opts })); } /** - * This endpoint requires the `memory.create` permission. + * Create a memory */ export function createMemory({ memoryCreateDto }: { memoryCreateDto: MemoryCreateDto; @@ -2988,12 +3110,14 @@ export function createMemory({ memoryCreateDto }: { }))); } /** - * This endpoint requires the `memory.statistics` permission. + * Retrieve memories statistics */ -export function memoriesStatistics({ $for, isSaved, isTrashed, $type }: { +export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: { $for?: string; isSaved?: boolean; isTrashed?: boolean; + order?: MemorySearchOrder; + size?: number; $type?: MemoryType; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3003,13 +3127,15 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, $type }: { "for": $for, isSaved, isTrashed, + order, + size, "type": $type }))}`, { ...opts })); } /** - * This endpoint requires the `memory.delete` permission. + * Delete a memory */ export function deleteMemory({ id }: { id: string; @@ -3020,7 +3146,7 @@ export function deleteMemory({ id }: { })); } /** - * This endpoint requires the `memory.read` permission. + * Retrieve a memory */ export function getMemory({ id }: { id: string; @@ -3033,7 +3159,7 @@ export function getMemory({ id }: { })); } /** - * This endpoint requires the `memory.update` permission. + * Update a memory */ export function updateMemory({ id, memoryUpdateDto }: { id: string; @@ -3049,7 +3175,7 @@ export function updateMemory({ id, memoryUpdateDto }: { }))); } /** - * This endpoint requires the `memoryAsset.delete` permission. + * Remove assets from a memory */ export function removeMemoryAssets({ id, bulkIdsDto }: { id: string; @@ -3065,7 +3191,7 @@ export function removeMemoryAssets({ id, bulkIdsDto }: { }))); } /** - * This endpoint requires the `memoryAsset.create` permission. + * Add assets to a memory */ export function addMemoryAssets({ id, bulkIdsDto }: { id: string; @@ -3081,7 +3207,7 @@ export function addMemoryAssets({ id, bulkIdsDto }: { }))); } /** - * This endpoint requires the `notification.delete` permission. + * Delete notifications */ export function deleteNotifications({ notificationDeleteAllDto }: { notificationDeleteAllDto: NotificationDeleteAllDto; @@ -3093,7 +3219,7 @@ export function deleteNotifications({ notificationDeleteAllDto }: { }))); } /** - * This endpoint requires the `notification.read` permission. + * Retrieve notifications */ export function getNotifications({ id, level, $type, unread }: { id?: string; @@ -3114,7 +3240,7 @@ export function getNotifications({ id, level, $type, unread }: { })); } /** - * This endpoint requires the `notification.update` permission. + * Update notifications */ export function updateNotifications({ notificationUpdateAllDto }: { notificationUpdateAllDto: NotificationUpdateAllDto; @@ -3126,7 +3252,7 @@ export function updateNotifications({ notificationUpdateAllDto }: { }))); } /** - * This endpoint requires the `notification.delete` permission. + * Delete a notification */ export function deleteNotification({ id }: { id: string; @@ -3137,7 +3263,7 @@ export function deleteNotification({ id }: { })); } /** - * This endpoint requires the `notification.read` permission. + * Get a notification */ export function getNotification({ id }: { id: string; @@ -3150,7 +3276,7 @@ export function getNotification({ id }: { })); } /** - * This endpoint requires the `notification.update` permission. + * Update a notification */ export function updateNotification({ id, notificationUpdateDto }: { id: string; @@ -3165,6 +3291,9 @@ export function updateNotification({ id, notificationUpdateDto }: { body: notificationUpdateDto }))); } +/** + * Start OAuth + */ export function startOAuth({ oAuthConfigDto }: { oAuthConfigDto: OAuthConfigDto; }, opts?: Oazapfts.RequestOpts) { @@ -3177,6 +3306,9 @@ export function startOAuth({ oAuthConfigDto }: { body: oAuthConfigDto }))); } +/** + * Finish OAuth + */ export function finishOAuth({ oAuthCallbackDto }: { oAuthCallbackDto: OAuthCallbackDto; }, opts?: Oazapfts.RequestOpts) { @@ -3189,6 +3321,9 @@ export function finishOAuth({ oAuthCallbackDto }: { body: oAuthCallbackDto }))); } +/** + * Link OAuth account + */ export function linkOAuthAccount({ oAuthCallbackDto }: { oAuthCallbackDto: OAuthCallbackDto; }, opts?: Oazapfts.RequestOpts) { @@ -3201,11 +3336,17 @@ export function linkOAuthAccount({ oAuthCallbackDto }: { body: oAuthCallbackDto }))); } +/** + * Redirect OAuth to mobile + */ export function redirectOAuthToMobile(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/oauth/mobile-redirect", { ...opts })); } +/** + * Unlink OAuth account + */ export function unlinkOAuthAccount(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3216,7 +3357,7 @@ export function unlinkOAuthAccount(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `partner.read` permission. + * Retrieve partners */ export function getPartners({ direction }: { direction: PartnerDirection; @@ -3231,7 +3372,7 @@ export function getPartners({ direction }: { })); } /** - * This endpoint requires the `partner.create` permission. + * Create a partner */ export function createPartner({ partnerCreateDto }: { partnerCreateDto: PartnerCreateDto; @@ -3246,7 +3387,7 @@ export function createPartner({ partnerCreateDto }: { }))); } /** - * This endpoint requires the `partner.delete` permission. + * Remove a partner */ export function removePartner({ id }: { id: string; @@ -3257,7 +3398,7 @@ export function removePartner({ id }: { })); } /** - * This property was deprecated in v1.141.0. This endpoint requires the `partner.create` permission. + * Create a partner */ export function createPartnerDeprecated({ id }: { id: string; @@ -3271,7 +3412,7 @@ export function createPartnerDeprecated({ id }: { })); } /** - * This endpoint requires the `partner.update` permission. + * Update a partner */ export function updatePartner({ id, partnerUpdateDto }: { id: string; @@ -3287,7 +3428,7 @@ export function updatePartner({ id, partnerUpdateDto }: { }))); } /** - * This endpoint requires the `person.delete` permission. + * Delete people */ export function deletePeople({ bulkIdsDto }: { bulkIdsDto: BulkIdsDto; @@ -3299,7 +3440,7 @@ export function deletePeople({ bulkIdsDto }: { }))); } /** - * This endpoint requires the `person.read` permission. + * Get all people */ export function getAllPeople({ closestAssetId, closestPersonId, page, size, withHidden }: { closestAssetId?: string; @@ -3322,7 +3463,7 @@ export function getAllPeople({ closestAssetId, closestPersonId, page, size, with })); } /** - * This endpoint requires the `person.create` permission. + * Create a person */ export function createPerson({ personCreateDto }: { personCreateDto: PersonCreateDto; @@ -3337,7 +3478,7 @@ export function createPerson({ personCreateDto }: { }))); } /** - * This endpoint requires the `person.update` permission. + * Update people */ export function updatePeople({ peopleUpdateDto }: { peopleUpdateDto: PeopleUpdateDto; @@ -3352,7 +3493,7 @@ export function updatePeople({ peopleUpdateDto }: { }))); } /** - * This endpoint requires the `person.delete` permission. + * Delete person */ export function deletePerson({ id }: { id: string; @@ -3363,7 +3504,7 @@ export function deletePerson({ id }: { })); } /** - * This endpoint requires the `person.read` permission. + * Get a person */ export function getPerson({ id }: { id: string; @@ -3376,7 +3517,7 @@ export function getPerson({ id }: { })); } /** - * This endpoint requires the `person.update` permission. + * Update person */ export function updatePerson({ id, personUpdateDto }: { id: string; @@ -3392,7 +3533,7 @@ export function updatePerson({ id, personUpdateDto }: { }))); } /** - * This endpoint requires the `person.merge` permission. + * Merge people */ export function mergePerson({ id, mergePersonDto }: { id: string; @@ -3408,7 +3549,7 @@ export function mergePerson({ id, mergePersonDto }: { }))); } /** - * This endpoint requires the `person.reassign` permission. + * Reassign faces */ export function reassignFaces({ id, assetFaceUpdateDto }: { id: string; @@ -3424,7 +3565,7 @@ export function reassignFaces({ id, assetFaceUpdateDto }: { }))); } /** - * This endpoint requires the `person.statistics` permission. + * Get person statistics */ export function getPersonStatistics({ id }: { id: string; @@ -3437,7 +3578,7 @@ export function getPersonStatistics({ id }: { })); } /** - * This endpoint requires the `person.read` permission. + * Get person thumbnail */ export function getPersonThumbnail({ id }: { id: string; @@ -3450,7 +3591,31 @@ export function getPersonThumbnail({ id }: { })); } /** - * This endpoint requires the `asset.read` permission. + * List all plugins + */ +export function getPlugins(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PluginResponseDto[]; + }>("/plugins", { + ...opts + })); +} +/** + * Retrieve a plugin + */ +export function getPlugin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PluginResponseDto; + }>(`/plugins/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Retrieve assets by city */ export function getAssetsByCity(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3461,7 +3626,7 @@ export function getAssetsByCity(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `asset.read` permission. + * Retrieve explore data */ export function getExploreData(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3472,7 +3637,7 @@ export function getExploreData(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `asset.read` permission. + * Search large assets */ export function searchLargeAssets({ albumIds, city, country, createdAfter, createdBefore, deviceId, isEncoded, isFavorite, isMotion, isNotInAlbum, isOffline, lensModel, libraryId, make, minFileSize, model, ocr, personIds, rating, size, state, tagIds, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, visibility, withDeleted, withExif }: { albumIds?: string[]; @@ -3550,7 +3715,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat })); } /** - * This endpoint requires the `asset.read` permission. + * Search assets by metadata */ export function searchAssets({ metadataSearchDto }: { metadataSearchDto: MetadataSearchDto; @@ -3565,7 +3730,7 @@ export function searchAssets({ metadataSearchDto }: { }))); } /** - * This endpoint requires the `person.read` permission. + * Search people */ export function searchPerson({ name, withHidden }: { name: string; @@ -3582,7 +3747,7 @@ export function searchPerson({ name, withHidden }: { })); } /** - * This endpoint requires the `asset.read` permission. + * Search places */ export function searchPlaces({ name }: { name: string; @@ -3597,7 +3762,7 @@ export function searchPlaces({ name }: { })); } /** - * This endpoint requires the `asset.read` permission. + * Search random assets */ export function searchRandom({ randomSearchDto }: { randomSearchDto: RandomSearchDto; @@ -3612,7 +3777,7 @@ export function searchRandom({ randomSearchDto }: { }))); } /** - * This endpoint requires the `asset.read` permission. + * Smart asset search */ export function searchSmart({ smartSearchDto }: { smartSearchDto: SmartSearchDto; @@ -3627,7 +3792,7 @@ export function searchSmart({ smartSearchDto }: { }))); } /** - * This endpoint requires the `asset.statistics` permission. + * Search asset statistics */ export function searchAssetStatistics({ statisticsSearchDto }: { statisticsSearchDto: StatisticsSearchDto; @@ -3642,7 +3807,7 @@ export function searchAssetStatistics({ statisticsSearchDto }: { }))); } /** - * This endpoint requires the `asset.read` permission. + * Retrieve search suggestions */ export function getSearchSuggestions({ country, includeNull, lensModel, make, model, state, $type }: { country?: string; @@ -3669,7 +3834,7 @@ export function getSearchSuggestions({ country, includeNull, lensModel, make, mo })); } /** - * This endpoint requires the `server.about` permission. + * Get server information */ export function getAboutInfo(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3680,7 +3845,7 @@ export function getAboutInfo(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `server.apkLinks` permission. + * Get APK links */ export function getApkLinks(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3690,6 +3855,9 @@ export function getApkLinks(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * Get config + */ export function getServerConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3698,6 +3866,9 @@ export function getServerConfig(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * Get features + */ export function getServerFeatures(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3707,7 +3878,7 @@ export function getServerFeatures(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint is an admin-only route, and requires the `serverLicense.delete` permission. + * Delete server product key */ export function deleteServerLicense(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/server/license", { @@ -3716,7 +3887,7 @@ export function deleteServerLicense(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint is an admin-only route, and requires the `serverLicense.read` permission. + * Get product key */ export function getServerLicense(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3729,7 +3900,7 @@ export function getServerLicense(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint is an admin-only route, and requires the `serverLicense.update` permission. + * Set server product key */ export function setServerLicense({ licenseKeyDto }: { licenseKeyDto: LicenseKeyDto; @@ -3743,6 +3914,9 @@ export function setServerLicense({ licenseKeyDto }: { body: licenseKeyDto }))); } +/** + * Get supported media types + */ export function getSupportedMediaTypes(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3751,6 +3925,9 @@ export function getSupportedMediaTypes(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * Ping + */ export function pingServer(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3760,7 +3937,7 @@ export function pingServer(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint is an admin-only route, and requires the `server.statistics` permission. + * Get statistics */ export function getServerStatistics(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3771,7 +3948,7 @@ export function getServerStatistics(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `server.storage` permission. + * Get storage */ export function getStorage(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3781,6 +3958,9 @@ export function getStorage(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * Get theme + */ export function getTheme(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3789,6 +3969,9 @@ export function getTheme(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * Get server version + */ export function getServerVersion(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3798,7 +3981,7 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `server.versionCheck` permission. + * Get version check status */ export function getVersionCheck(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3808,6 +3991,9 @@ export function getVersionCheck(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * Get version history + */ export function getVersionHistory(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3817,7 +4003,7 @@ export function getVersionHistory(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `session.delete` permission. + * Delete all sessions */ export function deleteAllSessions(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/sessions", { @@ -3826,7 +4012,7 @@ export function deleteAllSessions(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `session.read` permission. + * Retrieve sessions */ export function getSessions(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3837,7 +4023,7 @@ export function getSessions(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `session.create` permission. + * Create a session */ export function createSession({ sessionCreateDto }: { sessionCreateDto: SessionCreateDto; @@ -3852,7 +4038,7 @@ export function createSession({ sessionCreateDto }: { }))); } /** - * This endpoint requires the `session.delete` permission. + * Delete a session */ export function deleteSession({ id }: { id: string; @@ -3863,7 +4049,7 @@ export function deleteSession({ id }: { })); } /** - * This endpoint requires the `session.update` permission. + * Update a session */ export function updateSession({ id, sessionUpdateDto }: { id: string; @@ -3879,7 +4065,7 @@ export function updateSession({ id, sessionUpdateDto }: { }))); } /** - * This endpoint requires the `session.lock` permission. + * Lock a session */ export function lockSession({ id }: { id: string; @@ -3890,7 +4076,7 @@ export function lockSession({ id }: { })); } /** - * This endpoint requires the `sharedLink.read` permission. + * Retrieve all shared links */ export function getAllSharedLinks({ albumId }: { albumId?: string; @@ -3905,7 +4091,7 @@ export function getAllSharedLinks({ albumId }: { })); } /** - * This endpoint requires the `sharedLink.create` permission. + * Create a shared link */ export function createSharedLink({ sharedLinkCreateDto }: { sharedLinkCreateDto: SharedLinkCreateDto; @@ -3919,26 +4105,29 @@ export function createSharedLink({ sharedLinkCreateDto }: { body: sharedLinkCreateDto }))); } -export function getMySharedLink({ password, token, key, slug }: { - password?: string; - token?: string; +/** + * Retrieve current shared link + */ +export function getMySharedLink({ key, password, slug, token }: { key?: string; + password?: string; slug?: string; + token?: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: SharedLinkResponseDto; }>(`/shared-links/me${QS.query(QS.explode({ - password, - token, key, - slug + password, + slug, + token }))}`, { ...opts })); } /** - * This endpoint requires the `sharedLink.delete` permission. + * Delete a shared link */ export function removeSharedLink({ id }: { id: string; @@ -3949,7 +4138,7 @@ export function removeSharedLink({ id }: { })); } /** - * This endpoint requires the `sharedLink.read` permission. + * Retrieve a shared link */ export function getSharedLinkById({ id }: { id: string; @@ -3962,7 +4151,7 @@ export function getSharedLinkById({ id }: { })); } /** - * This endpoint requires the `sharedLink.update` permission. + * Update a shared link */ export function updateSharedLink({ id, sharedLinkEditDto }: { id: string; @@ -3977,6 +4166,9 @@ export function updateSharedLink({ id, sharedLinkEditDto }: { body: sharedLinkEditDto }))); } +/** + * Remove assets from a shared link + */ export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: { id: string; key?: string; @@ -3995,6 +4187,9 @@ export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: { body: assetIdsDto }))); } +/** + * Add assets to a shared link + */ export function addSharedLinkAssets({ id, key, slug, assetIdsDto }: { id: string; key?: string; @@ -4014,7 +4209,7 @@ export function addSharedLinkAssets({ id, key, slug, assetIdsDto }: { }))); } /** - * This endpoint requires the `stack.delete` permission. + * Delete stacks */ export function deleteStacks({ bulkIdsDto }: { bulkIdsDto: BulkIdsDto; @@ -4026,7 +4221,7 @@ export function deleteStacks({ bulkIdsDto }: { }))); } /** - * This endpoint requires the `stack.read` permission. + * Retrieve stacks */ export function searchStacks({ primaryAssetId }: { primaryAssetId?: string; @@ -4041,7 +4236,7 @@ export function searchStacks({ primaryAssetId }: { })); } /** - * This endpoint requires the `stack.create` permission. + * Create a stack */ export function createStack({ stackCreateDto }: { stackCreateDto: StackCreateDto; @@ -4056,7 +4251,7 @@ export function createStack({ stackCreateDto }: { }))); } /** - * This endpoint requires the `stack.delete` permission. + * Delete a stack */ export function deleteStack({ id }: { id: string; @@ -4067,7 +4262,7 @@ export function deleteStack({ id }: { })); } /** - * This endpoint requires the `stack.read` permission. + * Retrieve a stack */ export function getStack({ id }: { id: string; @@ -4080,7 +4275,7 @@ export function getStack({ id }: { })); } /** - * This endpoint requires the `stack.update` permission. + * Update a stack */ export function updateStack({ id, stackUpdateDto }: { id: string; @@ -4096,7 +4291,7 @@ export function updateStack({ id, stackUpdateDto }: { }))); } /** - * This endpoint requires the `stack.update` permission. + * Remove an asset from a stack */ export function removeAssetFromStack({ assetId, id }: { assetId: string; @@ -4108,7 +4303,7 @@ export function removeAssetFromStack({ assetId, id }: { })); } /** - * This endpoint requires the `syncCheckpoint.delete` permission. + * Delete acknowledgements */ export function deleteSyncAck({ syncAckDeleteDto }: { syncAckDeleteDto: SyncAckDeleteDto; @@ -4120,7 +4315,7 @@ export function deleteSyncAck({ syncAckDeleteDto }: { }))); } /** - * This endpoint requires the `syncCheckpoint.read` permission. + * Retrieve acknowledgements */ export function getSyncAck(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4131,7 +4326,7 @@ export function getSyncAck(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `syncCheckpoint.update` permission. + * Acknowledge changes */ export function sendSyncAck({ syncAckSetDto }: { syncAckSetDto: SyncAckSetDto; @@ -4142,6 +4337,9 @@ export function sendSyncAck({ syncAckSetDto }: { body: syncAckSetDto }))); } +/** + * Get delta sync for user + */ export function getDeltaSync({ assetDeltaSyncDto }: { assetDeltaSyncDto: AssetDeltaSyncDto; }, opts?: Oazapfts.RequestOpts) { @@ -4154,6 +4352,9 @@ export function getDeltaSync({ assetDeltaSyncDto }: { body: assetDeltaSyncDto }))); } +/** + * Get full sync for user + */ export function getFullSyncForUser({ assetFullSyncDto }: { assetFullSyncDto: AssetFullSyncDto; }, opts?: Oazapfts.RequestOpts) { @@ -4167,7 +4368,7 @@ export function getFullSyncForUser({ assetFullSyncDto }: { }))); } /** - * This endpoint requires the `sync.stream` permission. + * Stream sync changes */ export function getSyncStream({ syncStreamDto }: { syncStreamDto: SyncStreamDto; @@ -4179,7 +4380,7 @@ export function getSyncStream({ syncStreamDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `systemConfig.read` permission. + * Get system configuration */ export function getConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4190,7 +4391,7 @@ export function getConfig(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint is an admin-only route, and requires the `systemConfig.update` permission. + * Update system configuration */ export function updateConfig({ systemConfigDto }: { systemConfigDto: SystemConfigDto; @@ -4205,7 +4406,7 @@ export function updateConfig({ systemConfigDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `systemConfig.read` permission. + * Get system configuration defaults */ export function getConfigDefaults(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4216,7 +4417,7 @@ export function getConfigDefaults(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint is an admin-only route, and requires the `systemConfig.read` permission. + * Get storage template options */ export function getStorageTemplateOptions(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4227,7 +4428,7 @@ export function getStorageTemplateOptions(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint is an admin-only route, and requires the `systemMetadata.read` permission. + * Retrieve admin onboarding */ export function getAdminOnboarding(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4238,7 +4439,7 @@ export function getAdminOnboarding(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint is an admin-only route, and requires the `systemMetadata.update` permission. + * Update admin onboarding */ export function updateAdminOnboarding({ adminOnboardingUpdateDto }: { adminOnboardingUpdateDto: AdminOnboardingUpdateDto; @@ -4250,7 +4451,7 @@ export function updateAdminOnboarding({ adminOnboardingUpdateDto }: { }))); } /** - * This endpoint is an admin-only route, and requires the `systemMetadata.read` permission. + * Retrieve reverse geocoding state */ export function getReverseGeocodingState(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4261,7 +4462,7 @@ export function getReverseGeocodingState(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint is an admin-only route, and requires the `systemMetadata.read` permission. + * Retrieve version check state */ export function getVersionCheckState(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4272,7 +4473,7 @@ export function getVersionCheckState(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `tag.read` permission. + * Retrieve tags */ export function getAllTags(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4283,7 +4484,7 @@ export function getAllTags(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `tag.create` permission. + * Create a tag */ export function createTag({ tagCreateDto }: { tagCreateDto: TagCreateDto; @@ -4298,7 +4499,7 @@ export function createTag({ tagCreateDto }: { }))); } /** - * This endpoint requires the `tag.create` permission. + * Upsert tags */ export function upsertTags({ tagUpsertDto }: { tagUpsertDto: TagUpsertDto; @@ -4313,7 +4514,7 @@ export function upsertTags({ tagUpsertDto }: { }))); } /** - * This endpoint requires the `tag.asset` permission. + * Tag assets */ export function bulkTagAssets({ tagBulkAssetsDto }: { tagBulkAssetsDto: TagBulkAssetsDto; @@ -4328,7 +4529,7 @@ export function bulkTagAssets({ tagBulkAssetsDto }: { }))); } /** - * This endpoint requires the `tag.delete` permission. + * Delete a tag */ export function deleteTag({ id }: { id: string; @@ -4339,7 +4540,7 @@ export function deleteTag({ id }: { })); } /** - * This endpoint requires the `tag.read` permission. + * Retrieve a tag */ export function getTagById({ id }: { id: string; @@ -4352,7 +4553,7 @@ export function getTagById({ id }: { })); } /** - * This endpoint requires the `tag.update` permission. + * Update a tag */ export function updateTag({ id, tagUpdateDto }: { id: string; @@ -4368,7 +4569,7 @@ export function updateTag({ id, tagUpdateDto }: { }))); } /** - * This endpoint requires the `tag.asset` permission. + * Untag assets */ export function untagAssets({ id, bulkIdsDto }: { id: string; @@ -4384,7 +4585,7 @@ export function untagAssets({ id, bulkIdsDto }: { }))); } /** - * This endpoint requires the `tag.asset` permission. + * Tag assets */ export function tagAssets({ id, bulkIdsDto }: { id: string; @@ -4400,7 +4601,7 @@ export function tagAssets({ id, bulkIdsDto }: { }))); } /** - * This endpoint requires the `asset.read` permission. + * Get time bucket */ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; @@ -4441,7 +4642,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers })); } /** - * This endpoint requires the `asset.read` permission. + * Get time buckets */ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; @@ -4480,7 +4681,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per })); } /** - * This endpoint requires the `asset.delete` permission. + * Empty trash */ export function emptyTrash(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4492,7 +4693,7 @@ export function emptyTrash(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `asset.delete` permission. + * Restore trash */ export function restoreTrash(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4504,7 +4705,7 @@ export function restoreTrash(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `asset.delete` permission. + * Restore assets */ export function restoreAssets({ bulkIdsDto }: { bulkIdsDto: BulkIdsDto; @@ -4519,7 +4720,7 @@ export function restoreAssets({ bulkIdsDto }: { }))); } /** - * This endpoint requires the `user.read` permission. + * Get all users */ export function searchUsers(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4530,7 +4731,7 @@ export function searchUsers(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `user.read` permission. + * Get current user */ export function getMyUser(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4541,7 +4742,7 @@ export function getMyUser(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `user.update` permission. + * Update current user */ export function updateMyUser({ userUpdateMeDto }: { userUpdateMeDto: UserUpdateMeDto; @@ -4556,7 +4757,7 @@ export function updateMyUser({ userUpdateMeDto }: { }))); } /** - * This endpoint requires the `userLicense.delete` permission. + * Delete user product key */ export function deleteUserLicense(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/users/me/license", { @@ -4565,7 +4766,7 @@ export function deleteUserLicense(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `userLicense.read` permission. + * Retrieve user product key */ export function getUserLicense(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4576,7 +4777,7 @@ export function getUserLicense(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `userLicense.update` permission. + * Set user product key */ export function setUserLicense({ licenseKeyDto }: { licenseKeyDto: LicenseKeyDto; @@ -4591,7 +4792,7 @@ export function setUserLicense({ licenseKeyDto }: { }))); } /** - * This endpoint requires the `userOnboarding.delete` permission. + * Delete user onboarding */ export function deleteUserOnboarding(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/users/me/onboarding", { @@ -4600,7 +4801,7 @@ export function deleteUserOnboarding(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `userOnboarding.read` permission. + * Retrieve user onboarding */ export function getUserOnboarding(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4611,7 +4812,7 @@ export function getUserOnboarding(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `userOnboarding.update` permission. + * Update user onboarding */ export function setUserOnboarding({ onboardingDto }: { onboardingDto: OnboardingDto; @@ -4626,7 +4827,7 @@ export function setUserOnboarding({ onboardingDto }: { }))); } /** - * This endpoint requires the `userPreference.read` permission. + * Get my preferences */ export function getMyPreferences(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -4637,7 +4838,7 @@ export function getMyPreferences(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `userPreference.update` permission. + * Update my preferences */ export function updateMyPreferences({ userPreferencesUpdateDto }: { userPreferencesUpdateDto: UserPreferencesUpdateDto; @@ -4652,7 +4853,7 @@ export function updateMyPreferences({ userPreferencesUpdateDto }: { }))); } /** - * This endpoint requires the `userProfileImage.delete` permission. + * Delete user profile image */ export function deleteProfileImage(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/users/profile-image", { @@ -4661,7 +4862,7 @@ export function deleteProfileImage(opts?: Oazapfts.RequestOpts) { })); } /** - * This endpoint requires the `userProfileImage.update` permission. + * Create user profile image */ export function createProfileImage({ createProfileImageDto }: { createProfileImageDto: CreateProfileImageDto; @@ -4676,7 +4877,7 @@ export function createProfileImage({ createProfileImageDto }: { }))); } /** - * This endpoint requires the `user.read` permission. + * Retrieve a user */ export function getUser({ id }: { id: string; @@ -4689,7 +4890,7 @@ export function getUser({ id }: { })); } /** - * This endpoint requires the `userProfileImage.read` permission. + * Retrieve user profile image */ export function getProfileImage({ id }: { id: string; @@ -4701,6 +4902,9 @@ export function getProfileImage({ id }: { ...opts })); } +/** + * Retrieve assets by original path + */ export function getAssetsByOriginalPath({ path }: { path: string; }, opts?: Oazapfts.RequestOpts) { @@ -4713,6 +4917,9 @@ export function getAssetsByOriginalPath({ path }: { ...opts })); } +/** + * Retrieve unique paths + */ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -4721,6 +4928,72 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * List all workflows + */ +export function getWorkflows(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto[]; + }>("/workflows", { + ...opts + })); +} +/** + * Create a workflow + */ +export function createWorkflow({ workflowCreateDto }: { + workflowCreateDto: WorkflowCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: WorkflowResponseDto; + }>("/workflows", oazapfts.json({ + ...opts, + method: "POST", + body: workflowCreateDto + }))); +} +/** + * Delete a workflow + */ +export function deleteWorkflow({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/workflows/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve a workflow + */ +export function getWorkflow({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto; + }>(`/workflows/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Update a workflow + */ +export function updateWorkflow({ id, workflowUpdateDto }: { + id: string; + workflowUpdateDto: WorkflowUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto; + }>(`/workflows/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: workflowUpdateDto + }))); +} export enum ReactionLevel { Album = "album", Asset = "asset" @@ -4873,6 +5146,10 @@ export enum Permission { PinCodeCreate = "pinCode.create", PinCodeUpdate = "pinCode.update", PinCodeDelete = "pinCode.delete", + PluginCreate = "plugin.create", + PluginRead = "plugin.read", + PluginUpdate = "plugin.update", + PluginDelete = "plugin.delete", ServerAbout = "server.about", ServerApkLinks = "server.apkLinks", ServerStorage = "server.storage", @@ -4922,6 +5199,10 @@ export enum Permission { UserProfileImageRead = "userProfileImage.read", UserProfileImageUpdate = "userProfileImage.update", UserProfileImageDelete = "userProfileImage.delete", + WorkflowCreate = "workflow.create", + WorkflowRead = "workflow.read", + WorkflowUpdate = "workflow.update", + WorkflowDelete = "workflow.delete", AdminUserCreate = "adminUser.create", AdminUserRead = "adminUser.read", AdminUserUpdate = "adminUser.update", @@ -4964,7 +5245,7 @@ export enum ManualJobName { MemoryCreate = "memory-create", BackupDatabase = "backup-database" } -export enum JobName { +export enum QueueName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", VideoConversion = "videoConversion", @@ -4980,15 +5261,21 @@ export enum JobName { Library = "library", Notifications = "notifications", BackupDatabase = "backupDatabase", - Ocr = "ocr" + Ocr = "ocr", + Workflow = "workflow" } -export enum JobCommand { +export enum QueueCommand { Start = "start", Pause = "pause", Resume = "resume", Empty = "empty", ClearFailed = "clear-failed" } +export enum MemorySearchOrder { + Asc = "asc", + Desc = "desc", + Random = "random" +} export enum MemoryType { OnThisDay = "on_this_day" } @@ -4996,6 +5283,11 @@ export enum PartnerDirection { SharedBy = "shared-by", SharedWith = "shared-with" } +export enum PluginContext { + Asset = "asset", + Album = "album", + Person = "person" +} export enum SearchSuggestionType { Country = "country", State = "state", @@ -5147,3 +5439,11 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } +export enum TriggerType { + AssetCreate = "AssetCreate", + PersonRecognized = "PersonRecognized" +} +export enum PluginTriggerType { + AssetCreate = "AssetCreate", + PersonRecognized = "PersonRecognized" +} diff --git a/package.json b/package.json index 4c262de2b9..d08ea46edf 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "description": "Monorepo for Immich", "private": true, - "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8", + "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd", "engines": { "pnpm": ">=10.0.0" } diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 0000000000..76add878f8 --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/plugins/LICENSE b/plugins/LICENSE new file mode 100644 index 0000000000..53f0fa6953 --- /dev/null +++ b/plugins/LICENSE @@ -0,0 +1,26 @@ +Copyright 2024, The Extism Authors. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/plugins/esbuild.js b/plugins/esbuild.js new file mode 100644 index 0000000000..04cb6e85aa --- /dev/null +++ b/plugins/esbuild.js @@ -0,0 +1,12 @@ +const esbuild = require('esbuild'); + +esbuild + .build({ + entryPoints: ['src/index.ts'], + outdir: 'dist', + bundle: true, + sourcemap: true, + minify: false, // might want to use true for production build + format: 'cjs', // needs to be CJS for now + target: ['es2020'] // don't go over es2020 because quickjs doesn't support it + }) \ No newline at end of file diff --git a/plugins/manifest.json b/plugins/manifest.json new file mode 100644 index 0000000000..1172530c1e --- /dev/null +++ b/plugins/manifest.json @@ -0,0 +1,127 @@ +{ + "name": "immich-core", + "version": "2.0.0", + "title": "Immich Core", + "description": "Core workflow capabilities for Immich", + "author": "Immich Team", + + "wasm": { + "path": "dist/plugin.wasm" + }, + + "filters": [ + { + "methodName": "filterFileName", + "title": "Filter by filename", + "description": "Filter assets by filename pattern using text matching or regular expressions", + "supportedContexts": ["asset"], + "schema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Text or regex pattern to match against filename" + }, + "matchType": { + "type": "string", + "enum": ["contains", "regex", "exact"], + "default": "contains", + "description": "Type of pattern matching to perform" + }, + "caseSensitive": { + "type": "boolean", + "default": false, + "description": "Whether matching should be case-sensitive" + } + }, + "required": ["pattern"] + } + }, + { + "methodName": "filterFileType", + "title": "Filter by file type", + "description": "Filter assets by file type", + "supportedContexts": ["asset"], + "schema": { + "type": "object", + "properties": { + "fileTypes": { + "type": "array", + "items": { + "type": "string", + "enum": ["IMAGE", "VIDEO"] + }, + "description": "Allowed file types" + } + }, + "required": ["fileTypes"] + } + }, + { + "methodName": "filterPerson", + "title": "Filter by person", + "description": "Filter by detected person", + "supportedContexts": ["person"], + "schema": { + "type": "object", + "properties": { + "personIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of person to match" + }, + "matchAny": { + "type": "boolean", + "default": true, + "description": "Match any name (true) or require all names (false)" + } + }, + "required": ["personIds"] + } + } + ], + + "actions": [ + { + "methodName": "actionArchive", + "title": "Archive", + "description": "Move the asset to archive", + "supportedContexts": ["asset"], + "schema": {} + }, + { + "methodName": "actionFavorite", + "title": "Favorite", + "description": "Mark the asset as favorite or unfavorite", + "supportedContexts": ["asset"], + "schema": { + "type": "object", + "properties": { + "favorite": { + "type": "boolean", + "default": true, + "description": "Set favorite (true) or unfavorite (false)" + } + } + } + }, + { + "methodName": "actionAddToAlbum", + "title": "Add to Album", + "description": "Add the item to a specified album", + "supportedContexts": ["asset", "person"], + "schema": { + "type": "object", + "properties": { + "albumId": { + "type": "string", + "description": "Target album ID" + } + }, + "required": ["albumId"] + } + } + ] +} diff --git a/plugins/mise.toml b/plugins/mise.toml new file mode 100644 index 0000000000..c1001e574b --- /dev/null +++ b/plugins/mise.toml @@ -0,0 +1,11 @@ +[tools] +"github:extism/cli" = "1.6.3" +"github:webassembly/binaryen" = "version_124" +"github:extism/js-pdk" = "1.5.1" + +[tasks.install] +run = "pnpm install --frozen-lockfile" + +[tasks.build] +depends = ["install"] +run = "pnpm run build" diff --git a/plugins/package-lock.json b/plugins/package-lock.json new file mode 100644 index 0000000000..3b0f0b34cb --- /dev/null +++ b/plugins/package-lock.json @@ -0,0 +1,443 @@ +{ + "name": "js-pdk-template", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "js-pdk-template", + "version": "1.0.0", + "license": "BSD-3-Clause", + "devDependencies": { + "@extism/js-pdk": "^1.0.1", + "esbuild": "^0.19.6", + "typescript": "^5.3.2" + } + }, + "node_modules/@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==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@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==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@extism/js-pdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@extism/js-pdk/-/js-pdk-1.0.1.tgz", + "integrity": "sha512-YJWfHGeOuJnQw4V8NPNHvbSr6S8iDd2Ga6VEukwlRP7tu62ozTxIgokYw8i+rajD/16zz/gK0KYARBpm2qPAmQ==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@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" + } + }, + "node_modules/typescript": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", + "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/plugins/package.json b/plugins/package.json new file mode 100644 index 0000000000..ab6b2f8435 --- /dev/null +++ b/plugins/package.json @@ -0,0 +1,19 @@ +{ + "name": "plugins", + "version": "1.0.0", + "description": "", + "main": "src/index.ts", + "scripts": { + "build": "pnpm build:tsc && pnpm build:wasm", + "build:tsc": "tsc --noEmit && node esbuild.js", + "build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm" + }, + "keywords": [], + "author": "", + "license": "AGPL-3.0", + "devDependencies": { + "@extism/js-pdk": "^1.0.1", + "esbuild": "^0.19.6", + "typescript": "^5.3.2" + } +} diff --git a/plugins/src/index.d.ts b/plugins/src/index.d.ts new file mode 100644 index 0000000000..7f805aafe6 --- /dev/null +++ b/plugins/src/index.d.ts @@ -0,0 +1,12 @@ +declare module 'main' { + export function filterFileName(): I32; + export function actionAddToAlbum(): I32; + export function actionArchive(): I32; +} + +declare module 'extism:host' { + interface user { + updateAsset(ptr: PTR): I32; + addAssetToAlbum(ptr: PTR): I32; + } +} diff --git a/plugins/src/index.ts b/plugins/src/index.ts new file mode 100644 index 0000000000..9566c02cd8 --- /dev/null +++ b/plugins/src/index.ts @@ -0,0 +1,71 @@ +const { updateAsset, addAssetToAlbum } = Host.getFunctions(); + +function parseInput() { + return JSON.parse(Host.inputString()); +} + +function returnOutput(output: any) { + Host.outputString(JSON.stringify(output)); + return 0; +} + +export function filterFileName() { + const input = parseInput(); + const { data, config } = input; + const { pattern, matchType = 'contains', caseSensitive = false } = config; + + const fileName = data.asset.originalFileName || data.asset.fileName || ''; + const searchName = caseSensitive ? fileName : fileName.toLowerCase(); + const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); + + let passed = false; + + if (matchType === 'exact') { + passed = searchName === searchPattern; + } else if (matchType === 'regex') { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(searchPattern, flags); + passed = regex.test(fileName); + } else { + // contains + passed = searchName.includes(searchPattern); + } + + return returnOutput({ passed }); +} + +export function actionAddToAlbum() { + const input = parseInput(); + const { authToken, config, data } = input; + const { albumId } = config; + + const ptr = Memory.fromString( + JSON.stringify({ + authToken, + assetId: data.asset.id, + albumId: albumId, + }) + ); + + addAssetToAlbum(ptr.offset); + ptr.free(); + + return returnOutput({ success: true }); +} + +export function actionArchive() { + const input = parseInput(); + const { authToken, data } = input; + const ptr = Memory.fromString( + JSON.stringify({ + authToken, + id: data.asset.id, + visibility: 'archive', + }) + ); + + updateAsset(ptr.offset); + ptr.free(); + + return returnOutput({ success: true }); +} diff --git a/plugins/tsconfig.json b/plugins/tsconfig.json new file mode 100644 index 0000000000..86c9e766bf --- /dev/null +++ b/plugins/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2020", // Specify ECMAScript target version + "module": "commonjs", // Specify module code generation + "lib": [ + "es2020" + ], // Specify a list of library files to be included in the compilation + "types": [ + "@extism/js-pdk", + "./src/index.d.ts" + ], // Specify a list of type definition files to be included in the compilation + "strict": true, // Enable all strict type-checking options + "esModuleInterop": true, // Enables compatibility with Babel-style module imports + "skipLibCheck": true, // Skip type checking of declaration files + "allowJs": true, // Allow JavaScript files to be compiled + "noEmit": true // Do not emit outputs (no .js or .d.ts files) + }, + "include": [ + "src/**/*.ts" // Include all TypeScript files in src directory + ], + "exclude": [ + "node_modules" // Exclude the node_modules directory + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6faccf6ab6..c0e4b5ea78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: canvas: 2.11.2 sharp: ^0.34.4 -packageExtensionsChecksum: sha256-DAYr0FTkvKYnvBH4muAER9UE1FVGKhqfRU4/QwA2xPQ= +packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54= pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0= @@ -63,11 +63,11 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^22.18.12 - version: 22.18.13 + specifier: ^22.19.0 + version: 22.19.0 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -109,16 +109,16 @@ importers: version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 - version: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite-tsconfig-paths: specifier: ^5.0.0 - version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) yaml: specifier: ^2.3.1 version: 2.8.1 @@ -211,14 +211,14 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^22.18.12 - version: 22.18.13 + specifier: ^22.19.0 + version: 22.19.0 '@types/oidc-provider': specifier: ^9.0.0 version: 9.5.0 '@types/pg': specifier: ^8.15.1 - version: 8.15.5 + version: 8.15.6 '@types/pngjs': specifier: ^6.0.4 version: 6.0.5 @@ -239,7 +239,7 @@ importers: version: 60.0.0(eslint@9.38.0(jiti@2.6.1)) exiftool-vendored: specifier: ^31.1.0 - version: 31.1.0 + version: 31.3.0 globals: specifier: ^16.0.0 version: 16.4.0 @@ -284,7 +284,7 @@ importers: version: 5.2.1(encoding@0.1.13) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) open-api/typescript-sdk: dependencies: @@ -293,38 +293,53 @@ importers: version: 1.0.4 devDependencies: '@types/node': - specifier: ^22.18.12 - version: 22.18.13 + specifier: ^22.19.0 + version: 22.19.0 typescript: specifier: ^5.3.3 version: 5.9.3 + plugins: + devDependencies: + '@extism/js-pdk': + specifier: ^1.0.1 + version: 1.1.1 + esbuild: + specifier: ^0.19.6 + version: 0.19.12 + typescript: + specifier: ^5.3.2 + version: 5.9.3 + server: dependencies: + '@extism/extism': + specifier: 2.0.0-rc13 + version: 2.0.0-rc13 '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(bullmq@5.61.2) + version: 11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(bullmq@5.62.1) '@nestjs/common': specifier: ^11.0.4 - version: 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.4 - version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.4 - version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) + version: 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) '@nestjs/platform-socket.io': specifier: ^11.0.4 - version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.7)(rxjs@7.8.2) + version: 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.8)(rxjs@7.8.2) '@nestjs/schedule': specifier: ^6.0.0 - version: 6.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) + version: 6.0.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) '@nestjs/swagger': specifier: ^11.0.2 - version: 11.2.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + version: 11.2.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) '@nestjs/websockets': specifier: ^11.0.4 - version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-socket.io@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-socket.io@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -367,6 +382,9 @@ importers: '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.5) + ajv: + specifier: ^8.17.1 + version: 8.17.1 archiver: specifier: ^7.0.0 version: 7.0.1 @@ -381,7 +399,7 @@ importers: version: 2.2.0 bullmq: specifier: ^5.51.0 - version: 5.61.2 + version: 5.62.1 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -405,7 +423,7 @@ importers: version: 4.3.3 exiftool-vendored: specifier: ^31.1.0 - version: 31.1.0 + version: 31.3.0 express: specifier: ^5.1.0 version: 5.1.0 @@ -430,6 +448,9 @@ importers: js-yaml: specifier: ^4.1.0 version: 4.1.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 kysely: specifier: 0.28.2 version: 0.28.2 @@ -450,16 +471,16 @@ importers: version: 2.0.2 nest-commander: specifier: ^3.16.0 - version: 3.20.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@types/inquirer@8.2.11)(@types/node@22.18.13)(typescript@5.9.3) + version: 3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3) nestjs-cls: specifier: ^5.0.0 - version: 5.4.3(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 5.4.3(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: 3.1.2 - version: 3.1.2(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(kysely@0.28.2)(reflect-metadata@0.2.2) + version: 3.1.2(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(kysely@0.28.2)(reflect-metadata@0.2.2) nestjs-otel: specifier: ^7.0.0 - version: 7.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) + version: 7.0.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) nodemailer: specifier: ^7.0.0 version: 7.0.10 @@ -486,7 +507,7 @@ importers: version: 19.2.0(react@19.2.0) react-email: specifier: ^4.0.0 - version: 4.3.1 + version: 4.3.2 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 @@ -525,23 +546,23 @@ importers: version: 11.1.0 validator: specifier: ^13.12.0 - version: 13.15.15 + version: 13.15.20 devDependencies: '@eslint/js': specifier: ^9.8.0 version: 9.38.0 '@nestjs/cli': specifier: ^11.0.2 - version: 11.0.10(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.13) + version: 11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.4 - version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-express@11.1.7) + version: 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-express@11.1.8) '@swc/core': specifier: ^1.4.14 - version: 1.13.5(@swc/helpers@0.5.17) + version: 1.14.0(@swc/helpers@0.5.17) '@types/archiver': specifier: ^6.0.0 version: 6.0.4 @@ -569,6 +590,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/lodash': specifier: ^4.14.197 version: 4.17.20 @@ -582,8 +606,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^22.18.12 - version: 22.18.13 + specifier: ^22.19.0 + version: 22.19.0 '@types/nodemailer': specifier: ^7.0.0 version: 7.0.3 @@ -610,10 +634,10 @@ importers: version: 0.7.39 '@types/validator': specifier: ^13.15.2 - version: 13.15.3 + version: 13.15.4 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) eslint: specifier: ^9.14.0 version: 9.38.0(jiti@2.6.1) @@ -664,13 +688,13 @@ importers: version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) unplugin-swc: specifier: ^1.4.5 - version: 1.5.8(@swc/core@1.13.5(@swc/helpers@0.5.17))(rollup@4.52.5) + version: 1.5.8(@swc/core@1.14.0(@swc/helpers@0.5.17))(rollup@4.52.5) vite-tsconfig-paths: specifier: ^5.0.0 - version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) web: dependencies: @@ -684,8 +708,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.40.2 - version: 0.40.2(@internationalized/date@3.8.2)(svelte@5.41.3) + specifier: ^0.43.0 + version: 0.43.0(@internationalized/date@3.8.2)(svelte@5.43.0) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -715,7 +739,7 @@ importers: version: 0.41.3 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.7(svelte@5.41.3) + version: 0.3.7(svelte@5.43.0) async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -736,7 +760,7 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.0.8 + version: 20.0.10 intl-messageformat: specifier: ^10.7.11 version: 10.7.18 @@ -751,7 +775,7 @@ importers: version: 3.7.2 maplibre-gl: specifier: ^5.6.2 - version: 5.9.0 + version: 5.10.0 pmtiles: specifier: ^4.3.0 version: 4.3.0 @@ -760,7 +784,7 @@ importers: version: 1.5.4 simple-icons: specifier: ^15.15.0 - version: 15.17.0 + version: 15.18.0 socket.io-client: specifier: ~4.8.0 version: 4.8.1 @@ -769,13 +793,13 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.41.3) + version: 4.0.1(svelte@5.43.0) svelte-maplibre: - specifier: ^1.2.0 - version: 1.2.3(svelte@5.41.3) + specifier: ^1.2.5 + version: 1.2.5(svelte@5.43.0) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.41.3) + version: 0.12.0(svelte@5.43.0) tabbable: specifier: ^6.2.0 version: 6.3.0 @@ -797,25 +821,25 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.47.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))) + version: 3.0.10(@sveltejs/kit@2.48.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))) '@sveltejs/enhanced-img': specifier: ^0.8.0 - version: 0.8.4(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.52.5)(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 0.8.4(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.52.5)(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.47.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 2.48.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.1 - version: 6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@tailwindcss/vite': specifier: ^4.1.7 - version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 4.1.16(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.2.8(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.2.8(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.0) @@ -839,7 +863,7 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) dotenv: specifier: ^17.0.0 version: 17.2.3 @@ -854,7 +878,7 @@ importers: version: 6.0.2(eslint@9.38.0(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.12.5(eslint@9.38.0(jiti@2.6.1))(svelte@5.41.3) + version: 3.12.5(eslint@9.38.0(jiti@2.6.1))(svelte@5.43.0) eslint-plugin-unicorn: specifier: ^61.0.2 version: 61.0.2(eslint@9.38.0(jiti@2.6.1)) @@ -875,19 +899,19 @@ importers: version: 4.1.1(prettier@3.6.2) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.4.0(prettier@3.6.2)(svelte@5.41.3) + version: 3.4.0(prettier@3.6.2)(svelte@5.43.0) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.52.5) svelte: - specifier: 5.41.3 - version: 5.41.3 + specifier: 5.43.0 + version: 5.43.0 svelte-check: specifier: ^4.1.5 - version: 4.3.3(picomatch@4.0.3)(svelte@5.41.3)(typescript@5.9.3) + version: 4.3.3(picomatch@4.0.3)(svelte@5.43.0)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.0(svelte@5.41.3) + version: 1.4.0(svelte@5.43.0) tailwindcss: specifier: ^4.1.7 version: 4.1.16 @@ -899,10 +923,10 @@ importers: version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 - version: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) packages: @@ -2238,8 +2262,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.11': - resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -2250,8 +2274,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.11': - resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -2262,8 +2286,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.11': - resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -2274,8 +2298,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.11': - resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -2286,8 +2310,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.11': - resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -2298,8 +2322,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.11': - resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -2310,8 +2334,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.11': - resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -2322,8 +2346,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.11': - resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -2334,8 +2358,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.11': - resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -2346,8 +2370,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.11': - resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -2358,8 +2382,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.11': - resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -2370,8 +2394,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.11': - resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -2382,8 +2406,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.11': - resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -2394,8 +2418,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.11': - resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -2406,8 +2430,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.11': - resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -2418,8 +2442,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.11': - resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -2430,14 +2454,14 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.11': - resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.11': - resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -2448,14 +2472,14 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.11': - resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.11': - resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -2466,14 +2490,14 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.11': - resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.11': - resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -2484,8 +2508,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.11': - resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -2496,8 +2520,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.11': - resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -2508,8 +2532,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.11': - resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -2520,8 +2544,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.11': - resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -2572,6 +2596,12 @@ packages: resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@extism/extism@2.0.0-rc13': + resolution: {integrity: sha512-iQ3mrPKOC0WMZ94fuJrKbJmMyz4LQ9Abf8gd4F5ShxKWa+cRKcVzk0EqRQsp5xXsQ2dO3zJTiA6eTc4Ihf7k+A==} + + '@extism/js-pdk@1.1.1': + resolution: {integrity: sha512-VZLn/dX0ttA1uKk2PZeR/FL3N+nA1S5Vc7E5gdjkR60LuUIwCZT9cYON245V4HowHlBA7YOegh0TLjkx+wNbrA==} + '@faker-js/faker@10.1.0': resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -2776,13 +2806,13 @@ packages: '@immich/justified-layout-wasm@0.4.3': resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==} - '@immich/svelte-markdown-preprocess@0.0.1': - resolution: {integrity: sha512-1vWoT4LO6fEyxrKwLKiNFECEkRVbuvpYPDvA7LavObTt2ijnonPYBDgfTwCPTofjxcocIGYUayv3CzgOzFiMOA==} + '@immich/svelte-markdown-preprocess@0.1.0': + resolution: {integrity: sha512-jgSOJEGLPKEXQCNRI4r4YUayeM2b0ZYLdzgKGl891jZBhOQIetlY7rU44kPpV1AA3/8wGDwNFKduIQZZ/qJYzg==} peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.40.2': - resolution: {integrity: sha512-6NS4yVx0VoyH+AaM7TISDaoIzZe3RuDOi6xMkK2LrOPQbKwTuheD2iagxsRYzUtJ9IPrmCPrwRBc9Jq5BkvmBQ==} + '@immich/ui@0.43.0': + resolution: {integrity: sha512-dwWIURsGghsbeFnqxCqUyWslyRU2vQjih7uewNr0nsW68bJ5/esl+V/Kiw2opiNiwI4Q3HEcuTRY57k4Hq+X3Q==} peerDependencies: svelte: ^5.0.0 @@ -3088,8 +3118,8 @@ packages: resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} engines: {node: '>=6.0.0'} - '@maplibre/maplibre-gl-style-spec@24.3.0': - resolution: {integrity: sha512-CTJc/Nvldv+GNQuis29VnyV0TYsFTgQBY3SNagTzZ28oHDsDYJ7LwEmfick4Z30wPwI/4gXe3se8PH2IIfLx2g==} + '@maplibre/maplibre-gl-style-spec@24.3.1': + resolution: {integrity: sha512-TUM5JD40H2mgtVXl5IwWz03BuQabw8oZQLJTmPpJA0YTYF+B+oZppy5lNMO6bMvHzB+/5mxqW9VLG3wFdeqtOw==} hasBin: true '@maplibre/vt-pbf@4.0.3': @@ -3178,8 +3208,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.1.7': - resolution: {integrity: sha512-lwlObwGgIlpXSXYOTpfzdCepUyWomz6bv9qzGzzvpgspUxkj0Uz0fUJcvD44V8Ps7QhKW3lZBoYbXrH25UZrbA==} + '@nestjs/common@11.1.8': + resolution: {integrity: sha512-bbsOqwld/GdBfiRNc4nnjyWWENDEicq4SH+R5AuYatvf++vf1x5JIsHB1i1KtfZMD3eRte0D4K9WXuAYil6XAg==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -3191,8 +3221,8 @@ packages: class-validator: optional: true - '@nestjs/core@11.1.7': - resolution: {integrity: sha512-TyXFOwjhHv/goSgJ8i20K78jwTM0iSpk9GBcC2h3mf4MxNy+znI8m7nWjfoACjTkb89cTwDQetfTHtSfGLLaiA==} + '@nestjs/core@11.1.8': + resolution: {integrity: sha512-7riWfmTmMhCJHZ5ZiaG+crj4t85IPCq/wLRuOUSigBYyFT2JZj0lVHtAdf4Davp9ouNI8GINBDt9h9b5Gz9nTw==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -3222,14 +3252,14 @@ packages: class-validator: optional: true - '@nestjs/platform-express@11.1.7': - resolution: {integrity: sha512-5T+GLdvTiGPKB4/P4PM9ftKUKNHJy8ThEFhZA3vQnXVL7Vf0rDr07TfVTySVu+XTh85m1lpFVuyFM6u6wLNsRA==} + '@nestjs/platform-express@11.1.8': + resolution: {integrity: sha512-rL6pZH9BW7BnL5X2eWbJMtt86uloAKjFgyY5+L2UkizgfEp7rgAs0+Z1z0BcW2Pgu5+q8O7RKPNyHJ/9ZNz/ZQ==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@nestjs/platform-socket.io@11.1.7': - resolution: {integrity: sha512-suAyy5JWWvqU0fXbRp79Ihy7a1HSfB5rKgecVRmuQQyTi28W/0lsRsJN41plsxOEiXtaZq7sqiQp5Dg4XeUc9g==} + '@nestjs/platform-socket.io@11.1.8': + resolution: {integrity: sha512-nMUvwcdztso8BjN9czRl4sm0Ewc5xrCcgLvy+QPt6VAnTdu06KcZqtA6Cl3MKxViSQsQ8NBN5foKvZehNt/tug==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/websockets': ^11.0.0 @@ -3263,8 +3293,8 @@ packages: class-validator: optional: true - '@nestjs/testing@11.1.7': - resolution: {integrity: sha512-QbtrgSlc3QVo6RHNxTTlyhaiobLLy8kvhOlgWHsoXRknybuRs7vZg4k5mo3ye6pITGeT3CrWIRpZjUsh5Wps5Q==} + '@nestjs/testing@11.1.8': + resolution: {integrity: sha512-E6K+0UTKztcPxJzLnQa7S34lFjZbrj3Z1r7c5y5WDrL1m5HD1H4AeyBhicHgdaFmxjLAva2bq0sYKy/S7cdeYA==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3276,8 +3306,8 @@ packages: '@nestjs/platform-express': optional: true - '@nestjs/websockets@11.1.7': - resolution: {integrity: sha512-FWPgZPN7yQWIeonQ7JL64Rbsbw/IQovft0cVC5UX1Jbsovq+rUaTuk3rilimGrawN9VOGcoiQLGNiIbmjjiCew==} + '@nestjs/websockets@11.1.8': + resolution: {integrity: sha512-RXo2336p/vyAwJ0qPInglzNSQ//qz+JTLr2LE1vlbmN5WcyB7zV6+gY06YgNdsr3oy/cXRh7fnC3Ph/VAu1EVg==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3550,8 +3580,8 @@ packages: peerDependencies: '@photo-sphere-viewer/core': 5.14.0 - '@photostructure/tz-lookup@11.2.1': - resolution: {integrity: sha512-ugPtvpdLwGQ8IWezSGFgUCYOpO/XXetfKLNv+UN2jjTYyfIDq9dA21GydGyzXuoQ06nN3VGBd3JxmEu+ZtXScg==} + '@photostructure/tz-lookup@11.3.0': + resolution: {integrity: sha512-rYGy7ETBHTnXrwbzm47e3LJPKJmzpY7zXnbZhdosNU0lTGWVqzxptSjK4qZkJ1G+Kwy4F6XStNR9ZqMsXAoASQ==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -4091,8 +4121,8 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.47.3': - resolution: {integrity: sha512-zN2yzBc2dIES2BSzLhNP2weYhwB77kgM/oAktICZVmmljyEmPZrlUwr14jjdK9/eDu7WdAuf6gTdYIJLTcN3Fw==} + '@sveltejs/kit@2.48.3': + resolution: {integrity: sha512-jf8mx3yctRXE9hvixgcqqK94YI2hDnbxI/12Upkz99XFMvxnJKCMzvz0j7lmbXSyBSNEycWO5xHvi7b73y9qkQ==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -4197,68 +4227,68 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} - '@swc/core-darwin-arm64@1.13.5': - resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} + '@swc/core-darwin-arm64@1.14.0': + resolution: {integrity: sha512-uHPC8rlCt04nvYNczWzKVdgnRhxCa3ndKTBBbBpResOZsRmiwRAvByIGh599j+Oo6Z5eyTPrgY+XfJzVmXnN7Q==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.13.5': - resolution: {integrity: sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==} + '@swc/core-darwin-x64@1.14.0': + resolution: {integrity: sha512-2SHrlpl68vtePRknv9shvM9YKKg7B9T13tcTg9aFCwR318QTYo+FzsKGmQSv9ox/Ua0Q2/5y2BNjieffJoo4nA==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.13.5': - resolution: {integrity: sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==} + '@swc/core-linux-arm-gnueabihf@1.14.0': + resolution: {integrity: sha512-SMH8zn01dxt809svetnxpeg/jWdpi6dqHKO3Eb11u4OzU2PK7I5uKS6gf2hx5LlTbcJMFKULZiVwjlQLe8eqtg==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.13.5': - resolution: {integrity: sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==} + '@swc/core-linux-arm64-gnu@1.14.0': + resolution: {integrity: sha512-q2JRu2D8LVqGeHkmpVCljVNltG0tB4o4eYg+dElFwCS8l2Mnt9qurMCxIeo9mgoqz0ax+k7jWtIRHktnVCbjvQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-arm64-musl@1.13.5': - resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} + '@swc/core-linux-arm64-musl@1.14.0': + resolution: {integrity: sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-x64-gnu@1.13.5': - resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} + '@swc/core-linux-x64-gnu@1.14.0': + resolution: {integrity: sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-linux-x64-musl@1.13.5': - resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} + '@swc/core-linux-x64-musl@1.14.0': + resolution: {integrity: sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-win32-arm64-msvc@1.13.5': - resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==} + '@swc/core-win32-arm64-msvc@1.14.0': + resolution: {integrity: sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.13.5': - resolution: {integrity: sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==} + '@swc/core-win32-ia32-msvc@1.14.0': + resolution: {integrity: sha512-dPai3KUIcihV5hfoO4QNQF5HAaw8+2bT7dvi8E5zLtecW2SfL3mUZipzampXq5FHll0RSCLzlrXnSx+dBRZIIQ==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.13.5': - resolution: {integrity: sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==} + '@swc/core-win32-x64-msvc@1.14.0': + resolution: {integrity: sha512-nm+JajGrTqUA6sEHdghDlHMNfH1WKSiuvljhdmBACW4ta4LC3gKurX2qZuiBARvPkephW9V/i5S8QPY1PzFEqg==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.13.5': - resolution: {integrity: sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==} + '@swc/core@1.14.0': + resolution: {integrity: sha512-oExhY90bes5pDTVrei0xlMVosTxwd/NMafIpqsC4dMbRYZ5KB981l/CX8tMnGsagTplj/RcG9BeRYmV6/J5m3w==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -4590,6 +4620,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/justified-layout@4.1.4': resolution: {integrity: sha512-q2ybP0u0NVj87oMnGZOGxY2iUN8ddr48zPOBHBdbOLpsMTA/keGj+93ou+OMCnJk0xewzlNIaVEkxM6VBD3E2w==} @@ -4650,11 +4683,11 @@ packages: '@types/node@20.19.24': resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} - '@types/node@22.18.13': - resolution: {integrity: sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==} + '@types/node@22.19.0': + resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==} - '@types/node@24.9.2': - resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==} + '@types/node@24.10.0': + resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} '@types/nodemailer@7.0.3': resolution: {integrity: sha512-fC8w49YQ868IuPWRXqPfLf+MuTRex5Z1qxMoG8rr70riqqbOp2F5xgOKE9fODEBPzpnvjkJXFgK6IL2xgMSTnA==} @@ -4671,6 +4704,9 @@ packages: '@types/pg@8.15.5': resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==} + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/picomatch@4.0.2': resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} @@ -4761,8 +4797,8 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/validator@13.15.3': - resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==} + '@types/validator@13.15.4': + resolution: {integrity: sha512-LSFfpSnJJY9wbC0LQxgvfb+ynbHftFo0tMsFOl/J4wexLnYMmDSPaj2ZyDv3TkfL1UePxPrxOWJfbiRS8mQv7A==} '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -5361,6 +5397,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5378,8 +5417,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.61.2: - resolution: {integrity: sha512-b39hbiq/xXpOTT/OfmKpQYD+4VE4+XUlvdZ6GjbGl9asmRk8cSvUaQWD18jVCn1I0SzIfbrgOf+RAkqjXDUhJg==} + bullmq@5.62.1: + resolution: {integrity: sha512-FiRxqSquGTf8W8l8OMczKfEFG0BEqJ5NzChwKZ4vbSpZSPFLSmmxXAQlW4imB1rZHnlc7sYq8o+Oy4JavoIEpQ==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -6291,6 +6330,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -6410,8 +6452,8 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.25.11: - resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} hasBin: true @@ -6624,17 +6666,17 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - exiftool-vendored.exe@13.38.0: - resolution: {integrity: sha512-oZx5enTAvSiIAXL+OEk7nNWrfUhEdKUpaGwDjCmz4VKwOa4HbisqyM808xPGPYj8X7XikcME/fq5hvevPeE3cw==} + exiftool-vendored.exe@13.41.0: + resolution: {integrity: sha512-7XG0PjZCm8HVsVUQAD4b/eBtvYBuGkySf2qslqHlnSR6jU1xoD1AgEprb2bCPqwhw0Jn3xzZoo/ihDo4F6fMyA==} os: [win32] - exiftool-vendored.pl@13.38.0: - resolution: {integrity: sha512-Q3xl1nnwswrsR5344z4NyqvI74fKwla+VJHY1N+32gcDgt8cs9KBsDUwcNzKHSOSa/MjEfniuCJVrQiqR05iag==} + exiftool-vendored.pl@13.41.0: + resolution: {integrity: sha512-JqqRuB8TggIOC983oTnOunB/baseGYw8XCkn7ylFGOmEv7oTQAK3uUTZV76vXE1X6c5H6IdHYt0abSgi8Kzc4g==} os: ['!win32'] hasBin: true - exiftool-vendored@31.1.0: - resolution: {integrity: sha512-q8StxLawHLDvhqv/uoBYCfVbDskn49Cr5ouNCZhh4lgryGu1aymHwK9AvO6RcW2SbPm5MSnQDJOfGp2MW5Nnrw==} + exiftool-vendored@31.3.0: + resolution: {integrity: sha512-JQeyRvh7cV81fm9eKej2btdVh2z2Ak/sx89c4OCykeQnhnI81hk9TTraBtborYA+WcLM20cwYMPmpaW/sMy5Qw==} engines: {node: '>=20.0.0'} expect-type@1.2.1: @@ -7051,8 +7093,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.0.8: - resolution: {integrity: sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==} + happy-dom@20.0.10: + resolution: {integrity: sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -7714,12 +7756,22 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + just-compare@2.3.0: resolution: {integrity: sha512-6shoR7HDT+fzfL3gBahx1jZG3hWLrhPAf+l7nCwahDdT9XDtosB9kIF0ZrzUp5QY8dJWfQVr5rnsPqsbvflDzg==} justified-layout@4.1.0: resolution: {integrity: sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + kdbush@3.0.0: resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} @@ -7919,15 +7971,36 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} @@ -8018,8 +8091,8 @@ packages: resolution: {integrity: sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==} engines: {node: '>=6.4.0'} - maplibre-gl@5.9.0: - resolution: {integrity: sha512-YxW9glb/YrDXGDhqy1u+aG113+L86ttAUpTd6sCkGHyUKMXOX8qbGHJQVqxOczy+4CtRKnqcCfSura2MzB0nQA==} + maplibre-gl@5.10.0: + resolution: {integrity: sha512-eLhlX8Fnpaoo7+uGqggZmXmZld6WrbzOJUPB7G8JB8XpminlTnrQTtXilMHduR8fsNVxrzD8yRRqEoajONc8LQ==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} mark.js@8.11.1: @@ -9673,8 +9746,8 @@ packages: peerDependencies: react: ^19.2.0 - react-email@4.3.1: - resolution: {integrity: sha512-GBgI7fl0fXVFVQ4zlXG+x14egDNX1WVlOrAXKMyc1h9xeTnIAt/u3g1liU4v+7Yv3yprMSkZ1mIO3YPuTKo77A==} + react-email@4.3.2: + resolution: {integrity: sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA==} engines: {node: '>=18.0.0'} hasBin: true @@ -10168,8 +10241,8 @@ packages: simple-get@3.1.1: resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==} - simple-icons@15.17.0: - resolution: {integrity: sha512-viOcugYj+JFYVWJvDh4Ph1xHk9iTGhzt+NoPrfAQYSCADvmZFSQUWyKEbSMuqVRUsaRgvADn+Cczysemsf1N3Q==} + simple-icons@15.18.0: + resolution: {integrity: sha512-lYpvaIuZZr6N50YSdYZQzrKccSSF3dqcgcoz2vMKVQCc/fJWD8nFszJVZz2tCDTSu082rqRYfuYRUPhjdixDAA==} engines: {node: '>=0.12.18'} sirv@2.0.4: @@ -10480,8 +10553,8 @@ packages: peerDependencies: svelte: ^3 || ^4 || ^5 - svelte-maplibre@1.2.3: - resolution: {integrity: sha512-2EToGWdSlTq9Tr7MLmUlve3J86uDM9D6s5ErY/oc5LEsktd0TCTPXM1HJ1IGSaa+ElxCv/ka/igvGPb6L4BhLw==} + svelte-maplibre@1.2.5: + resolution: {integrity: sha512-Uklcbi6inW9GA0MuSusbXmFr/MQPmXrjuP8hS1+yFX3ySvCQ477tsM3I7Jo/fUDK3XAxFSIHW6hZfucnM3kXwQ==} peerDependencies: '@deck.gl/core': ^9 '@deck.gl/layers': ^9 @@ -10512,8 +10585,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.41.3: - resolution: {integrity: sha512-bkHg+whEnVVNcK3XP8Dy4NHujn5mU/+at9z09PXM5THKm+E73AwiKFoRMMTfyAzAj1yExKtudvGHq8UqOh8kMQ==} + svelte@5.43.0: + resolution: {integrity: sha512-1sRxVbgJAB+UGzwkc3GUoiBSzEOf0jqzccMaVoI2+pI+kASUe9qubslxace8+Mzhqw19k4syTA5niCIJwfXpOA==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11040,6 +11113,9 @@ packages: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} + urlpattern-polyfill@8.0.2: + resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -11078,8 +11154,8 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true - validator@13.15.15: - resolution: {integrity: sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==} + validator@13.15.20: + resolution: {integrity: sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==} engines: {node: '>= 0.10'} value-equal@1.0.1: @@ -11714,11 +11790,11 @@ snapshots: optionalDependencies: chokidar: 4.0.3 - '@angular-devkit/schematics-cli@19.2.15(@types/node@22.18.13)(chokidar@4.0.3)': + '@angular-devkit/schematics-cli@19.2.15(@types/node@22.19.0)(chokidar@4.0.3)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.15(chokidar@4.0.3) - '@inquirer/prompts': 7.3.2(@types/node@22.18.13) + '@inquirer/prompts': 7.3.2(@types/node@22.19.0) ansi-colors: 4.1.3 symbol-observable: 4.0.0 yargs-parser: 21.1.1 @@ -13968,148 +14044,148 @@ snapshots: '@esbuild/aix-ppc64@0.19.12': optional: true - '@esbuild/aix-ppc64@0.25.11': + '@esbuild/aix-ppc64@0.25.12': optional: true '@esbuild/android-arm64@0.19.12': optional: true - '@esbuild/android-arm64@0.25.11': + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm@0.19.12': optional: true - '@esbuild/android-arm@0.25.11': + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-x64@0.19.12': optional: true - '@esbuild/android-x64@0.25.11': + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/darwin-arm64@0.19.12': optional: true - '@esbuild/darwin-arm64@0.25.11': + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-x64@0.19.12': optional: true - '@esbuild/darwin-x64@0.25.11': + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.19.12': optional: true - '@esbuild/freebsd-arm64@0.25.11': + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-x64@0.19.12': optional: true - '@esbuild/freebsd-x64@0.25.11': + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/linux-arm64@0.19.12': optional: true - '@esbuild/linux-arm64@0.25.11': + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm@0.19.12': optional: true - '@esbuild/linux-arm@0.25.11': + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-ia32@0.19.12': optional: true - '@esbuild/linux-ia32@0.25.11': + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-loong64@0.19.12': optional: true - '@esbuild/linux-loong64@0.25.11': + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-mips64el@0.19.12': optional: true - '@esbuild/linux-mips64el@0.25.11': + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-ppc64@0.19.12': optional: true - '@esbuild/linux-ppc64@0.25.11': + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-riscv64@0.19.12': optional: true - '@esbuild/linux-riscv64@0.25.11': + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-s390x@0.19.12': optional: true - '@esbuild/linux-s390x@0.25.11': + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-x64@0.19.12': optional: true - '@esbuild/linux-x64@0.25.11': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.25.11': + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-x64@0.19.12': optional: true - '@esbuild/netbsd-x64@0.25.11': + '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.25.11': + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-x64@0.19.12': optional: true - '@esbuild/openbsd-x64@0.25.11': + '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.25.11': + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/sunos-x64@0.19.12': optional: true - '@esbuild/sunos-x64@0.25.11': + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/win32-arm64@0.19.12': optional: true - '@esbuild/win32-arm64@0.25.11': + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-ia32@0.19.12': optional: true - '@esbuild/win32-ia32@0.25.11': + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-x64@0.19.12': optional: true - '@esbuild/win32-x64@0.25.11': + '@esbuild/win32-x64@0.25.12': optional: true '@eslint-community/eslint-utils@4.9.0(eslint@9.38.0(jiti@2.6.1))': @@ -14167,6 +14243,12 @@ snapshots: '@eslint/core': 0.16.0 levn: 0.4.1 + '@extism/extism@2.0.0-rc13': {} + + '@extism/js-pdk@1.1.1': + dependencies: + urlpattern-polyfill: 8.0.2 + '@faker-js/faker@10.1.0': {} '@fig/complete-commander@3.2.0(commander@11.1.0)': @@ -14211,10 +14293,10 @@ snapshots: dependencies: tslib: 2.8.1 - '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)': + '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.17.21 '@grpc/grpc-js@1.14.0': @@ -14343,18 +14425,18 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.0.1(svelte@5.41.3)': + '@immich/svelte-markdown-preprocess@0.1.0(svelte@5.43.0)': dependencies: - svelte: 5.41.3 + svelte: 5.43.0 - '@immich/ui@0.40.2(@internationalized/date@3.8.2)(svelte@5.41.3)': + '@immich/ui@0.43.0(@internationalized/date@3.8.2)(svelte@5.43.0)': dependencies: - '@immich/svelte-markdown-preprocess': 0.0.1(svelte@5.41.3) + '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.43.0) '@mdi/js': 7.4.47 - bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.41.3) + bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.43.0) luxon: 3.7.2 - simple-icons: 15.17.0 - svelte: 5.41.3 + simple-icons: 15.18.0 + svelte: 5.43.0 svelte-highlight: 7.8.4 tailwind-merge: 3.3.1 tailwind-variants: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.16) @@ -14362,27 +14444,27 @@ snapshots: transitivePeerDependencies: - '@internationalized/date' - '@inquirer/checkbox@4.2.1(@types/node@22.18.13)': + '@inquirer/checkbox@4.2.1(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/type': 3.0.8(@types/node@22.19.0) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/confirm@5.1.15(@types/node@22.18.13)': + '@inquirer/confirm@5.1.15(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/core@10.1.15(@types/node@22.18.13)': + '@inquirer/core@10.1.15(@types/node@22.19.0)': dependencies: '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/type': 3.0.8(@types/node@22.19.0) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -14390,115 +14472,115 @@ snapshots: wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/editor@4.2.17(@types/node@22.18.13)': + '@inquirer/editor@4.2.17(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/external-editor': 1.0.2(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/external-editor': 1.0.2(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/expand@4.0.17(@types/node@22.18.13)': + '@inquirer/expand@4.0.17(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/external-editor@1.0.2(@types/node@22.18.13)': + '@inquirer/external-editor@1.0.2(@types/node@22.19.0)': dependencies: chardet: 2.1.0 iconv-lite: 0.7.0 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@inquirer/figures@1.0.13': {} - '@inquirer/input@4.2.1(@types/node@22.18.13)': + '@inquirer/input@4.2.1(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/number@3.0.17(@types/node@22.18.13)': + '@inquirer/number@3.0.17(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/password@4.0.17(@types/node@22.18.13)': + '@inquirer/password@4.0.17(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) ansi-escapes: 4.3.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/prompts@7.3.2(@types/node@22.18.13)': + '@inquirer/prompts@7.3.2(@types/node@22.19.0)': dependencies: - '@inquirer/checkbox': 4.2.1(@types/node@22.18.13) - '@inquirer/confirm': 5.1.15(@types/node@22.18.13) - '@inquirer/editor': 4.2.17(@types/node@22.18.13) - '@inquirer/expand': 4.0.17(@types/node@22.18.13) - '@inquirer/input': 4.2.1(@types/node@22.18.13) - '@inquirer/number': 3.0.17(@types/node@22.18.13) - '@inquirer/password': 4.0.17(@types/node@22.18.13) - '@inquirer/rawlist': 4.1.5(@types/node@22.18.13) - '@inquirer/search': 3.1.0(@types/node@22.18.13) - '@inquirer/select': 4.3.1(@types/node@22.18.13) + '@inquirer/checkbox': 4.2.1(@types/node@22.19.0) + '@inquirer/confirm': 5.1.15(@types/node@22.19.0) + '@inquirer/editor': 4.2.17(@types/node@22.19.0) + '@inquirer/expand': 4.0.17(@types/node@22.19.0) + '@inquirer/input': 4.2.1(@types/node@22.19.0) + '@inquirer/number': 3.0.17(@types/node@22.19.0) + '@inquirer/password': 4.0.17(@types/node@22.19.0) + '@inquirer/rawlist': 4.1.5(@types/node@22.19.0) + '@inquirer/search': 3.1.0(@types/node@22.19.0) + '@inquirer/select': 4.3.1(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/prompts@7.8.0(@types/node@22.18.13)': + '@inquirer/prompts@7.8.0(@types/node@22.19.0)': dependencies: - '@inquirer/checkbox': 4.2.1(@types/node@22.18.13) - '@inquirer/confirm': 5.1.15(@types/node@22.18.13) - '@inquirer/editor': 4.2.17(@types/node@22.18.13) - '@inquirer/expand': 4.0.17(@types/node@22.18.13) - '@inquirer/input': 4.2.1(@types/node@22.18.13) - '@inquirer/number': 3.0.17(@types/node@22.18.13) - '@inquirer/password': 4.0.17(@types/node@22.18.13) - '@inquirer/rawlist': 4.1.5(@types/node@22.18.13) - '@inquirer/search': 3.1.0(@types/node@22.18.13) - '@inquirer/select': 4.3.1(@types/node@22.18.13) + '@inquirer/checkbox': 4.2.1(@types/node@22.19.0) + '@inquirer/confirm': 5.1.15(@types/node@22.19.0) + '@inquirer/editor': 4.2.17(@types/node@22.19.0) + '@inquirer/expand': 4.0.17(@types/node@22.19.0) + '@inquirer/input': 4.2.1(@types/node@22.19.0) + '@inquirer/number': 3.0.17(@types/node@22.19.0) + '@inquirer/password': 4.0.17(@types/node@22.19.0) + '@inquirer/rawlist': 4.1.5(@types/node@22.19.0) + '@inquirer/search': 3.1.0(@types/node@22.19.0) + '@inquirer/select': 4.3.1(@types/node@22.19.0) optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/rawlist@4.1.5(@types/node@22.18.13)': + '@inquirer/rawlist@4.1.5(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) + '@inquirer/type': 3.0.8(@types/node@22.19.0) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/search@3.1.0(@types/node@22.18.13)': + '@inquirer/search@3.1.0(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/type': 3.0.8(@types/node@22.19.0) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/select@4.3.1(@types/node@22.18.13)': + '@inquirer/select@4.3.1(@types/node@22.19.0)': dependencies: - '@inquirer/core': 10.1.15(@types/node@22.18.13) + '@inquirer/core': 10.1.15(@types/node@22.19.0) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@22.18.13) + '@inquirer/type': 3.0.8(@types/node@22.19.0) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 - '@inquirer/type@3.0.8(@types/node@22.18.13)': + '@inquirer/type@3.0.8(@types/node@22.19.0)': optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@internationalized/date@3.8.2': dependencies: @@ -14536,7 +14618,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/yargs': 17.0.34 chalk: 4.1.2 @@ -14702,7 +14784,7 @@ snapshots: '@mapbox/whoots-js@3.1.0': {} - '@maplibre/maplibre-gl-style-spec@24.3.0': + '@maplibre/maplibre-gl-style-spec@24.3.1': dependencies: '@mapbox/jsonlint-lines-primitives': 2.0.2 '@mapbox/unitbezier': 0.0.1 @@ -14790,32 +14872,32 @@ snapshots: '@namnode/store@0.1.0': {} - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(bullmq@5.61.2)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(bullmq@5.62.1)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.61.2 + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.62.1 tslib: 2.8.1 - '@nestjs/cli@11.0.10(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.13)': + '@nestjs/cli@11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.15(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.15(@types/node@22.18.13)(chokidar@4.0.3) - '@inquirer/prompts': 7.8.0(@types/node@22.18.13) + '@angular-devkit/schematics-cli': 19.2.15(@types/node@22.19.0)(chokidar@4.0.3) + '@inquirer/prompts': 7.8.0(@types/node@22.19.0) '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.8.3) ansis: 4.1.0 chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.8.3)(webpack@5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17))) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.8.3)(webpack@5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17))) glob: 11.0.3 node-emoji: 1.11.0 ora: 5.4.1 @@ -14823,17 +14905,17 @@ snapshots: tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.8.3 - webpack: 5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17)) + webpack: 5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17)) webpack-node-externals: 3.0.0 optionalDependencies: - '@swc/core': 1.13.5(@swc/helpers@0.5.17) + '@swc/core': 1.14.0(@swc/helpers@0.5.17) transitivePeerDependencies: - '@types/node' - esbuild - uglify-js - webpack-cli - '@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.0.0 iterare: 1.2.1 @@ -14848,9 +14930,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -14860,21 +14942,21 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) - '@nestjs/websockets': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-socket.io@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) + '@nestjs/websockets': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-socket.io@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.2 - '@nestjs/platform-express@11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)': + '@nestjs/platform-express@11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.5 express: 5.1.0 multer: 2.0.2 @@ -14883,10 +14965,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/platform-socket.io@11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.7)(rxjs@7.8.2)': + '@nestjs/platform-socket.io@11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.8)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/websockets': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-socket.io@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-socket.io@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) rxjs: 7.8.2 socket.io: 4.8.1 tslib: 2.8.1 @@ -14895,10 +14977,10 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schedule@6.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)': + '@nestjs/schedule@6.0.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.3.3 '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.8.3)': @@ -14923,12 +15005,12 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.15.1 - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) js-yaml: 4.1.0 lodash: 4.17.21 path-to-regexp: 8.3.0 @@ -14938,25 +15020,25 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.2 - '@nestjs/testing@11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-express@11.1.7)': + '@nestjs/testing@11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-express@11.1.8)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) + '@nestjs/platform-express': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) - '@nestjs/websockets@11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@nestjs/platform-socket.io@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/websockets@11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@nestjs/platform-socket.io@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-socket.io': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.7)(rxjs@7.8.2) + '@nestjs/platform-socket.io': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.8)(rxjs@7.8.2) '@noble/hashes@1.8.0': {} @@ -15297,7 +15379,7 @@ snapshots: '@photo-sphere-viewer/core': 5.14.0 three: 0.180.0 - '@photostructure/tz-lookup@11.2.1': {} + '@photostructure/tz-lookup@11.3.0': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -15859,29 +15941,29 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.47.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.48.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))': dependencies: - '@sveltejs/kit': 2.47.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/kit': 2.48.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) - '@sveltejs/enhanced-img@0.8.4(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.52.5)(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/enhanced-img@0.8.4(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.52.5)(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) magic-string: 0.30.21 sharp: 0.34.4 - svelte: 5.41.3 - svelte-parse-markup: 0.1.5(svelte@5.41.3) - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + svelte: 5.43.0 + svelte-parse-markup: 0.1.5(svelte@5.43.0) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite-imagetools: 8.0.0(rollup@4.52.5) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - supports-color - '@sveltejs/kit@2.47.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/kit@2.48.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@standard-schema/spec': 1.0.0 '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -15893,29 +15975,29 @@ snapshots: sade: 1.8.1 set-cookie-parser: 2.7.2 sirv: 3.0.2 - svelte: 5.41.3 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + svelte: 5.43.0 + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) optionalDependencies: '@opentelemetry/api': 1.9.0 - '@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) debug: 4.4.3 - svelte: 5.41.3 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + svelte: 5.43.0 + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) debug: 4.4.3 deepmerge: 4.3.1 magic-string: 0.30.21 - svelte: 5.41.3 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vitefu: 1.1.1(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + svelte: 5.43.0 + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitefu: 1.1.1(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: - supports-color @@ -16012,51 +16094,51 @@ snapshots: - supports-color - typescript - '@swc/core-darwin-arm64@1.13.5': + '@swc/core-darwin-arm64@1.14.0': optional: true - '@swc/core-darwin-x64@1.13.5': + '@swc/core-darwin-x64@1.14.0': optional: true - '@swc/core-linux-arm-gnueabihf@1.13.5': + '@swc/core-linux-arm-gnueabihf@1.14.0': optional: true - '@swc/core-linux-arm64-gnu@1.13.5': + '@swc/core-linux-arm64-gnu@1.14.0': optional: true - '@swc/core-linux-arm64-musl@1.13.5': + '@swc/core-linux-arm64-musl@1.14.0': optional: true - '@swc/core-linux-x64-gnu@1.13.5': + '@swc/core-linux-x64-gnu@1.14.0': optional: true - '@swc/core-linux-x64-musl@1.13.5': + '@swc/core-linux-x64-musl@1.14.0': optional: true - '@swc/core-win32-arm64-msvc@1.13.5': + '@swc/core-win32-arm64-msvc@1.14.0': optional: true - '@swc/core-win32-ia32-msvc@1.13.5': + '@swc/core-win32-ia32-msvc@1.14.0': optional: true - '@swc/core-win32-x64-msvc@1.13.5': + '@swc/core-win32-x64-msvc@1.14.0': optional: true - '@swc/core@1.13.5(@swc/helpers@0.5.17)': + '@swc/core@1.14.0(@swc/helpers@0.5.17)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.13.5 - '@swc/core-darwin-x64': 1.13.5 - '@swc/core-linux-arm-gnueabihf': 1.13.5 - '@swc/core-linux-arm64-gnu': 1.13.5 - '@swc/core-linux-arm64-musl': 1.13.5 - '@swc/core-linux-x64-gnu': 1.13.5 - '@swc/core-linux-x64-musl': 1.13.5 - '@swc/core-win32-arm64-msvc': 1.13.5 - '@swc/core-win32-ia32-msvc': 1.13.5 - '@swc/core-win32-x64-msvc': 1.13.5 + '@swc/core-darwin-arm64': 1.14.0 + '@swc/core-darwin-x64': 1.14.0 + '@swc/core-linux-arm-gnueabihf': 1.14.0 + '@swc/core-linux-arm64-gnu': 1.14.0 + '@swc/core-linux-arm64-musl': 1.14.0 + '@swc/core-linux-x64-gnu': 1.14.0 + '@swc/core-linux-x64-musl': 1.14.0 + '@swc/core-win32-arm64-msvc': 1.14.0 + '@swc/core-win32-ia32-msvc': 1.14.0 + '@swc/core-win32-x64-msvc': 1.14.0 '@swc/helpers': 0.5.17 '@swc/counter@0.1.3': {} @@ -16134,12 +16216,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.16 '@tailwindcss/oxide-win32-x64-msvc': 4.1.16 - '@tailwindcss/vite@4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@tailwindcss/vite@4.1.16(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@tailwindcss/node': 4.1.16 '@tailwindcss/oxide': 4.1.16 tailwindcss: 4.1.16 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) '@testing-library/dom@10.4.0': dependencies: @@ -16161,13 +16243,13 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte@5.2.8(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@testing-library/svelte@5.2.8(svelte@5.43.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@testing-library/dom': 10.4.0 - svelte: 5.41.3 + svelte: 5.43.0 optionalDependencies: - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': dependencies: @@ -16209,7 +16291,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/archiver@6.0.4': dependencies: @@ -16221,16 +16303,16 @@ snapshots: '@types/bcrypt@6.0.0': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/bonjour@3.5.13': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/braces@3.0.5': {} @@ -16251,21 +16333,21 @@ snapshots: '@types/cli-progress@3.11.6': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/compression@1.8.1': dependencies: '@types/express': 5.0.5 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 5.1.0 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/connect@3.4.38': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/content-disposition@0.5.9': {} @@ -16282,11 +16364,11 @@ snapshots: '@types/connect': 3.4.38 '@types/express': 5.0.5 '@types/keygrip': 1.0.6 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/cors@2.8.19': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/debug@4.1.12': dependencies: @@ -16296,13 +16378,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ssh2': 1.15.5 '@types/dockerode@3.3.45': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ssh2': 1.15.5 '@types/dom-to-image@2.6.7': {} @@ -16325,14 +16407,14 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 '@types/express-serve-static-core@5.1.0': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -16358,7 +16440,7 @@ snapshots: '@types/fluent-ffmpeg@2.1.28': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/geojson-vt@3.2.5': dependencies: @@ -16390,7 +16472,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/inquirer@8.2.11': dependencies: @@ -16411,6 +16493,11 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.0 + '@types/justified-layout@4.1.4': {} '@types/keygrip@1.0.6': {} @@ -16428,7 +16515,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.8 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/leaflet@1.9.21': dependencies: @@ -16458,7 +16545,7 @@ snapshots: '@types/mock-fs@4.13.4': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ms@2.1.0': {} @@ -16468,7 +16555,7 @@ snapshots: '@types/node-forge@1.3.14': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/node@17.0.45': {} @@ -16480,11 +16567,11 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.18.13': + '@types/node@22.19.0': dependencies: undici-types: 6.21.0 - '@types/node@24.9.2': + '@types/node@24.10.0': dependencies: undici-types: 7.16.0 optional: true @@ -16492,7 +16579,7 @@ snapshots: '@types/nodemailer@7.0.3': dependencies: '@aws-sdk/client-sesv2': 3.919.0 - '@types/node': 22.18.13 + '@types/node': 22.19.0 transitivePeerDependencies: - aws-crt @@ -16500,17 +16587,23 @@ snapshots: dependencies: '@types/keygrip': 1.0.6 '@types/koa': 3.0.0 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/parse5@5.0.3': {} '@types/pg-pool@2.0.6': dependencies: - '@types/pg': 8.15.5 + '@types/pg': 8.15.6 '@types/pg@8.15.5': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 22.19.0 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -16518,13 +16611,13 @@ snapshots: '@types/pngjs@6.0.5': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/prismjs@1.26.5': {} '@types/qrcode@1.5.6': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/qs@6.14.0': {} @@ -16553,7 +16646,7 @@ snapshots: '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/retry@0.12.2': {} @@ -16563,18 +16656,18 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/semver@7.7.1': {} '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/send@1.2.1': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/serve-index@1.9.4': dependencies: @@ -16583,20 +16676,20 @@ snapshots: '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/send': 0.17.6 '@types/sockjs@0.3.36': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ssh2@0.5.52': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -16607,7 +16700,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 22.18.13 + '@types/node': 22.19.0 form-data: 4.0.4 '@types/supercluster@7.1.3': @@ -16621,7 +16714,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/ua-parser-js@0.7.39': {} @@ -16629,13 +16722,13 @@ snapshots: '@types/unist@3.0.3': {} - '@types/validator@13.15.3': {} + '@types/validator@13.15.4': {} '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 '@types/yargs-parser@21.0.3': {} @@ -16740,7 +16833,7 @@ snapshots: '@vercel/oidc@3.0.3': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16755,11 +16848,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16774,7 +16867,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -16786,21 +16879,21 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -16912,10 +17005,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.7(svelte@5.41.3)': + '@zoom-image/svelte@0.3.7(svelte@5.43.0)': dependencies: '@zoom-image/core': 0.41.3 - svelte: 5.41.3 + svelte: 5.43.0 abab@2.0.6: optional: true @@ -17269,21 +17362,24 @@ snapshots: bcrypt@6.0.0: dependencies: node-addon-api: 8.5.0 + node-gyp: 11.5.0 node-gyp-build: 4.8.4 + transitivePeerDependencies: + - supports-color big.js@5.2.2: {} binary-extensions@2.3.0: {} - bits-ui@2.9.8(@internationalized/date@3.8.2)(svelte@5.41.3): + bits-ui@2.9.8(@internationalized/date@3.8.2)(svelte@5.43.0): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.8.2 esm-env: 1.2.2 - runed: 0.29.2(svelte@5.41.3) - svelte: 5.41.3 - svelte-toolbelt: 0.9.3(svelte@5.41.3) + runed: 0.29.2(svelte@5.43.0) + svelte: 5.43.0 + svelte-toolbelt: 0.9.3(svelte@5.43.0) tabbable: 6.3.0 bl@4.1.0: @@ -17377,6 +17473,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -17394,7 +17492,7 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.61.2: + bullmq@5.62.1: dependencies: cron-parser: 4.9.0 ioredis: 5.8.2 @@ -17602,9 +17700,9 @@ snapshots: class-validator@0.14.2: dependencies: - '@types/validator': 13.15.3 + '@types/validator': 13.15.4 libphonenumber-js: 1.12.9 - validator: 13.15.15 + validator: 13.15.20 clean-css@5.3.3: dependencies: @@ -18329,6 +18427,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.243: {} @@ -18375,7 +18477,7 @@ snapshots: engine.io@6.6.4: dependencies: '@types/cors': 2.8.19 - '@types/node': 22.18.13 + '@types/node': 22.19.0 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -18489,34 +18591,34 @@ snapshots: '@esbuild/win32-ia32': 0.19.12 '@esbuild/win32-x64': 0.19.12 - esbuild@0.25.11: + esbuild@0.25.12: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.11 - '@esbuild/android-arm': 0.25.11 - '@esbuild/android-arm64': 0.25.11 - '@esbuild/android-x64': 0.25.11 - '@esbuild/darwin-arm64': 0.25.11 - '@esbuild/darwin-x64': 0.25.11 - '@esbuild/freebsd-arm64': 0.25.11 - '@esbuild/freebsd-x64': 0.25.11 - '@esbuild/linux-arm': 0.25.11 - '@esbuild/linux-arm64': 0.25.11 - '@esbuild/linux-ia32': 0.25.11 - '@esbuild/linux-loong64': 0.25.11 - '@esbuild/linux-mips64el': 0.25.11 - '@esbuild/linux-ppc64': 0.25.11 - '@esbuild/linux-riscv64': 0.25.11 - '@esbuild/linux-s390x': 0.25.11 - '@esbuild/linux-x64': 0.25.11 - '@esbuild/netbsd-arm64': 0.25.11 - '@esbuild/netbsd-x64': 0.25.11 - '@esbuild/openbsd-arm64': 0.25.11 - '@esbuild/openbsd-x64': 0.25.11 - '@esbuild/openharmony-arm64': 0.25.11 - '@esbuild/sunos-x64': 0.25.11 - '@esbuild/win32-arm64': 0.25.11 - '@esbuild/win32-ia32': 0.25.11 - '@esbuild/win32-x64': 0.25.11 + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 escalade@3.2.0: {} @@ -18565,7 +18667,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.38.0(jiti@2.6.1)) - eslint-plugin-svelte@3.12.5(eslint@9.38.0(jiti@2.6.1))(svelte@5.41.3): + eslint-plugin-svelte@3.12.5(eslint@9.38.0(jiti@2.6.1))(svelte@5.43.0): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -18577,9 +18679,9 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.6) postcss-safe-parser: 7.0.1(postcss@8.5.6) semver: 7.7.3 - svelte-eslint-parser: 1.4.0(svelte@5.41.3) + svelte-eslint-parser: 1.4.0(svelte@5.43.0) optionalDependencies: - svelte: 5.41.3 + svelte: 5.43.0 transitivePeerDependencies: - ts-node @@ -18764,7 +18866,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 require-like: 0.1.2 event-emitter@0.3.5: @@ -18798,21 +18900,21 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - exiftool-vendored.exe@13.38.0: + exiftool-vendored.exe@13.41.0: optional: true - exiftool-vendored.pl@13.38.0: {} + exiftool-vendored.pl@13.41.0: {} - exiftool-vendored@31.1.0: + exiftool-vendored@31.3.0: dependencies: - '@photostructure/tz-lookup': 11.2.1 + '@photostructure/tz-lookup': 11.3.0 '@types/luxon': 3.7.1 batch-cluster: 15.0.1 - exiftool-vendored.pl: 13.38.0 + exiftool-vendored.pl: 13.41.0 he: 1.2.0 luxon: 3.7.2 optionalDependencies: - exiftool-vendored.exe: 13.38.0 + exiftool-vendored.exe: 13.41.0 expect-type@1.2.1: {} @@ -19060,7 +19162,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.8.3)(webpack@5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17))): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.8.3)(webpack@5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17))): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -19075,7 +19177,7 @@ snapshots: semver: 7.7.3 tapable: 2.3.0 typescript: 5.8.3 - webpack: 5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17)) + webpack: 5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17)) form-data-encoder@2.1.4: {} @@ -19324,7 +19426,7 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.0.8: + happy-dom@20.0.10: dependencies: '@types/node': 20.19.24 '@types/whatwg-mimetype': 3.0.2 @@ -19753,9 +19855,9 @@ snapshots: inline-style-parser@0.2.4: {} - inquirer@8.2.7(@types/node@22.18.13): + inquirer@8.2.7(@types/node@22.19.0): dependencies: - '@inquirer/external-editor': 1.0.2(@types/node@22.18.13) + '@inquirer/external-editor': 1.0.2(@types/node@22.19.0) ansi-escapes: 4.3.2 chalk: 4.1.2 cli-cursor: 3.1.0 @@ -19969,7 +20071,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.18.13 + '@types/node': 22.19.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -19977,13 +20079,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -20143,10 +20245,34 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + just-compare@2.3.0: {} justified-layout@4.1.0: {} + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + kdbush@3.0.0: {} kdbush@4.0.2: {} @@ -20311,12 +20437,26 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.uniq@4.5.0: {} lodash@4.17.21: {} @@ -20435,7 +20575,7 @@ snapshots: tinyqueue: 2.0.3 vt-pbf: 3.1.3 - maplibre-gl@5.9.0: + maplibre-gl@5.10.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -20444,7 +20584,7 @@ snapshots: '@mapbox/unitbezier': 0.0.1 '@mapbox/vector-tile': 2.0.4 '@mapbox/whoots-js': 3.1.0 - '@maplibre/maplibre-gl-style-spec': 24.3.0 + '@maplibre/maplibre-gl-style-spec': 24.3.1 '@maplibre/vt-pbf': 4.0.3 '@types/geojson': 7946.0.16 '@types/geojson-vt': 3.2.5 @@ -21203,39 +21343,39 @@ snapshots: neo-async@2.6.2: {} - nest-commander@3.20.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(@types/inquirer@8.2.11)(@types/node@22.18.13)(typescript@5.9.3): + nest-commander@3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3): dependencies: '@fig/complete-commander': 3.2.0(commander@11.1.0) - '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/inquirer': 8.2.11 commander: 11.1.0 cosmiconfig: 8.3.6(typescript@5.9.3) - inquirer: 8.2.7(@types/node@22.18.13) + inquirer: 8.2.7(@types/node@22.19.0) transitivePeerDependencies: - '@types/node' - typescript - nestjs-cls@5.4.3(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2): + nestjs-cls@5.4.3(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - nestjs-kysely@3.1.2(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(kysely@0.28.2)(reflect-metadata@0.2.2): + nestjs-kysely@3.1.2(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(kysely@0.28.2)(reflect-metadata@0.2.2): dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) kysely: 0.28.2 reflect-metadata: 0.2.2 tslib: 2.8.1 - nestjs-otel@7.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7): + nestjs-otel@7.0.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8): dependencies: - '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.7)(@nestjs/websockets@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.8(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.8)(@nestjs/websockets@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': 1.9.0 '@opentelemetry/host-metrics': 0.36.0(@opentelemetry/api@1.9.0) response-time: 2.3.4 @@ -22228,10 +22368,10 @@ snapshots: dependencies: prettier: 3.6.2 - prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.41.3): + prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.43.0): dependencies: prettier: 3.6.2 - svelte: 5.41.3 + svelte: 5.43.0 prettier@3.6.2: {} @@ -22310,7 +22450,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.18.13 + '@types/node': 22.19.0 long: 5.3.2 protocol-buffers-schema@3.6.0: {} @@ -22418,14 +22558,14 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 - react-email@4.3.1: + react-email@4.3.2: dependencies: '@babel/parser': 7.28.5 '@babel/traverse': 7.28.5 chokidar: 4.0.3 commander: 13.1.0 debounce: 2.2.0 - esbuild: 0.25.11 + esbuild: 0.25.12 glob: 11.0.3 jiti: 2.4.2 log-symbols: 7.0.1 @@ -22841,10 +22981,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.29.2(svelte@5.41.3): + runed@0.29.2(svelte@5.43.0): dependencies: esm-env: 1.2.2 - svelte: 5.41.3 + svelte: 5.43.0 rw@1.3.3: {} @@ -23128,7 +23268,7 @@ snapshots: simple-concat: 1.0.1 optional: true - simple-icons@15.17.0: {} + simple-icons@15.18.0: {} sirv@2.0.4: dependencies: @@ -23460,19 +23600,19 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.3.3(picomatch@4.0.3)(svelte@5.41.3)(typescript@5.9.3): + svelte-check@4.3.3(picomatch@4.0.3)(svelte@5.43.0)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.41.3 + svelte: 5.43.0 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.0(svelte@5.41.3): + svelte-eslint-parser@1.4.0(svelte@5.43.0): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -23481,7 +23621,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.0 optionalDependencies: - svelte: 5.41.3 + svelte: 5.43.0 svelte-gestures@5.2.2: {} @@ -23489,7 +23629,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.41.3): + svelte-i18n@4.0.1(svelte@5.43.0): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -23497,34 +23637,34 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.41.3 + svelte: 5.43.0 tiny-glob: 0.2.9 - svelte-maplibre@1.2.3(svelte@5.41.3): + svelte-maplibre@1.2.5(svelte@5.43.0): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 - maplibre-gl: 5.9.0 + maplibre-gl: 5.10.0 pmtiles: 3.2.1 - svelte: 5.41.3 + svelte: 5.43.0 - svelte-parse-markup@0.1.5(svelte@5.41.3): + svelte-parse-markup@0.1.5(svelte@5.43.0): dependencies: - svelte: 5.41.3 + svelte: 5.43.0 - svelte-persisted-store@0.12.0(svelte@5.41.3): + svelte-persisted-store@0.12.0(svelte@5.43.0): dependencies: - svelte: 5.41.3 + svelte: 5.43.0 - svelte-toolbelt@0.9.3(svelte@5.41.3): + svelte-toolbelt@0.9.3(svelte@5.43.0): dependencies: clsx: 2.1.1 - runed: 0.29.2(svelte@5.41.3) + runed: 0.29.2(svelte@5.43.0) style-to-object: 1.0.11 - svelte: 5.41.3 + svelte: 5.43.0 - svelte@5.41.3: + svelte@5.43.0: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -23681,16 +23821,16 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - terser-webpack-plugin@5.3.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(webpack@5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17))): + terser-webpack-plugin@5.3.14(@swc/core@1.14.0(@swc/helpers@0.5.17))(webpack@5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17))): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.0 - webpack: 5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17)) + webpack: 5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17)) optionalDependencies: - '@swc/core': 1.13.5(@swc/helpers@0.5.17) + '@swc/core': 1.14.0(@swc/helpers@0.5.17) terser-webpack-plugin@5.3.14(webpack@5.102.1): dependencies: @@ -24070,10 +24210,10 @@ snapshots: unpipe@1.0.0: {} - unplugin-swc@1.5.8(@swc/core@1.13.5(@swc/helpers@0.5.17))(rollup@4.52.5): + unplugin-swc@1.5.8(@swc/core@1.14.0(@swc/helpers@0.5.17))(rollup@4.52.5): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.52.5) - '@swc/core': 1.13.5(@swc/helpers@0.5.17) + '@swc/core': 1.14.0(@swc/helpers@0.5.17) load-tsconfig: 0.2.5 unplugin: 2.3.10 transitivePeerDependencies: @@ -24133,6 +24273,8 @@ snapshots: punycode: 1.4.1 qs: 6.14.0 + urlpattern-polyfill@8.0.2: {} + use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 @@ -24161,7 +24303,7 @@ snapshots: uuid@8.3.2: {} - validator@13.15.15: {} + validator@13.15.20: {} value-equal@1.0.1: {} @@ -24205,13 +24347,13 @@ snapshots: - rollup - supports-color - vite-node@3.2.4(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vite-node@3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -24226,13 +24368,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vite-node@3.2.4(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -24247,62 +24389,62 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: - esbuild: 0.25.11 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 22.18.13 + '@types/node': 22.19.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 terser: 5.44.0 yaml: 2.8.1 - vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: - esbuild: 0.25.11 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.9.2 + '@types/node': 24.10.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 terser: 5.44.0 yaml: 2.8.1 - vitefu@1.1.1(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): + vitefu@1.1.1(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): optionalDependencies: - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -24320,13 +24462,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 22.18.13 - happy-dom: 20.0.8 + '@types/node': 22.19.0 + happy-dom: 20.0.10 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -24342,11 +24484,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -24364,13 +24506,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 22.18.13 - happy-dom: 20.0.8 + '@types/node': 22.19.0 + happy-dom: 20.0.10 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -24386,11 +24528,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -24408,13 +24550,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 24.9.2 - happy-dom: 20.0.8 + '@types/node': 24.10.0 + happy-dom: 20.0.10 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -24553,7 +24695,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17)): + webpack@5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -24577,7 +24719,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(webpack@5.100.2(@swc/core@1.13.5(@swc/helpers@0.5.17))) + terser-webpack-plugin: 5.3.14(@swc/core@1.14.0(@swc/helpers@0.5.17))(webpack@5.100.2(@swc/core@1.14.0(@swc/helpers@0.5.17))) watchpack: 2.4.4 webpack-sources: 3.3.3 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5fd79faba3..d5629d2323 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,13 +4,13 @@ packages: - e2e - open-api/typescript-sdk - server + - plugins - web - .github ignoredBuiltDependencies: - '@nestjs/core' - '@scarf/scarf' - '@swc/core' - - bcrypt - canvas - core-js - core-js-pure @@ -25,6 +25,7 @@ ignoredBuiltDependencies: onlyBuiltDependencies: - sharp - '@tailwindcss/oxide' + - bcrypt overrides: canvas: 2.11.2 sharp: ^0.34.4 @@ -51,6 +52,10 @@ packageExtensions: tailwind-variants: dependencies: tailwindcss: '>=4.1' + bcrypt: + dependencies: + node-addon-api: '*' + node-gyp: '*' dedupePeerDependents: false preferWorkspacePackages: true injectWorkspacePackages: true diff --git a/readme_i18n/README_ru_RU.md b/readme_i18n/README_ru_RU.md index e29adde9c1..9c60e5f772 100644 --- a/readme_i18n/README_ru_RU.md +++ b/readme_i18n/README_ru_RU.md @@ -94,7 +94,7 @@ | LivePhoto/MotionPhoto воспроизведение и бекап | Да | Да | | Отображение 360° изображений | Нет | Да | | Настраиваемая структура хранилища | Да | Да | -| Общий доступ к контенту | Нет | Да | +| Общий доступ к контенту | Да | Да | | Архив и избранное | Да | Да | | Мировая карта | Да | Да | | Совместное использование | Да | Да | @@ -104,7 +104,7 @@ | Галереи только для просмотра | Да | Да | | Коллажи | Да | Да | | Метки (теги) | Нет | Да | -| Просмотр папкой | Нет | Да | +| Просмотр папкой | Да | Да | ## Перевод diff --git a/renovate.json b/renovate.json index 3a889f4789..fbbc8976bd 100644 --- a/renovate.json +++ b/renovate.json @@ -26,6 +26,12 @@ "matchPackageNames": ["ghcr.io/immich-app/postgres"], "matchUpdateTypes": ["major"], "enabled": false + }, + { + "matchPackageNames": ["ruby"], + "groupName": "ruby", + "matchCurrentVersion": "< 3.4", + "enabled": false } ], "ignorePaths": [ diff --git a/server/Dockerfile b/server/Dockerfile index 54077d80ce..0bb7fc6be5 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/immich-app/base-server-dev:202510281104@sha256:e2f94c2e92cbae5982b014e610ff29731c0fbcb4bf69022c7fe27594e40c9f83 AS builder +FROM ghcr.io/immich-app/base-server-dev:202511041104@sha256:7558931a4a71989e7fd9fa3e1ba6c28da15891867310edda8c58236171839f2f AS builder ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp \ @@ -48,7 +48,25 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \ pnpm --filter @immich/sdk --filter @immich/cli build && \ pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned -FROM ghcr.io/immich-app/base-server-prod:202510281104@sha256:84f8f3eb4cfafc5e624235f7db703e1222fd60831bef1d488d8d8cad2be5023d +FROM builder AS plugins + +COPY --from=ghcr.io/jdx/mise:2025.11.3 /usr/local/bin/mise /usr/local/bin/mise + +WORKDIR /usr/src/app +COPY ./plugins/mise.toml ./plugins/ +ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml +RUN mise install --cd plugins + +COPY ./plugins ./plugins/ +# Build plugins +RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ + --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ + --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ + --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ + cd plugins && mise run build + +FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb WORKDIR /usr/src/app ENV NODE_ENV=production \ @@ -58,6 +76,8 @@ ENV NODE_ENV=production \ COPY --from=server /output/server-pruned ./server COPY --from=web /usr/src/app/web/build /build/www COPY --from=cli /output/cli-pruned ./cli +COPY --from=plugins /usr/src/app/plugins/dist /build/corePlugin/dist +COPY --from=plugins /usr/src/app/plugins/manifest.json /build/corePlugin/manifest.json RUN ln -s ../../cli/bin/immich server/bin/immich COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 93a4f197ea..133b8a835d 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202510281104@sha256:e2f94c2e92cbae5982b014e610ff29731c0fbcb4bf69022c7fe27594e40c9f83 AS dev +FROM ghcr.io/immich-app/base-server-dev:202511041104@sha256:7558931a4a71989e7fd9fa3e1ba6c28da15891867310edda8c58236171839f2f AS dev ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ diff --git a/server/mise.toml b/server/mise.toml new file mode 100644 index 0000000000..d2240c4289 --- /dev/null +++ b/server/mise.toml @@ -0,0 +1,66 @@ +[tasks.install] +run = "pnpm install --filter immich --frozen-lockfile" + +[tasks.build] +env._.path = "./node_modules/.bin" +run = "nest build" + +[tasks.test] +env._.path = "./node_modules/.bin" +run = "vitest --config test/vitest.config.mjs" + +[tasks."test-medium"] +env._.path = "./node_modules/.bin" +run = "vitest --config test/vitest.config.medium.mjs" + +[tasks.format] +env._.path = "./node_modules/.bin" +run = "prettier --check ." + +[tasks."format-fix"] +env._.path = "./node_modules/.bin" +run = "prettier --write ." + +[tasks.lint] +env._.path = "./node_modules/.bin" +run = "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0" + +[tasks."lint-fix"] +run = { task = "lint --fix" } + +[tasks.check] +env._.path = "./node_modules/.bin" +run = "tsc --noEmit" + +[tasks.sql] +run = "node ./dist/bin/sync-open-api.js" + +[tasks."open-api"] +run = "node ./dist/bin/sync-open-api.js" + +[tasks.migrations] +run = "node ./dist/bin/migrations.js" +description = "Run database migration commands (create, generate, run, debug, or query)" + +[tasks."schema-drop"] +run = { task = "migrations query 'DROP schema public cascade; CREATE schema public;'" } + +[tasks."schema-reset"] +run = [ + { task = ":schema-drop" }, + { task = "migrations run" }, +] + +[tasks."email-dev"] +env._.path = "./node_modules/.bin" +run = "email dev -p 3050 --dir src/emails" + +[tasks.checklist] +run = [ + { task = ":install" }, + { task = ":format" }, + { task = ":lint" }, + { task = ":check" }, + { task = ":test-medium --run" }, + { task = ":test --run" }, +] diff --git a/server/package.json b/server/package.json index 69fb98d0ed..a252a53b8a 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "2.2.2", + "version": "2.2.3", "description": "", "author": "", "private": true, @@ -22,11 +22,11 @@ "test:cov": "vitest --config test/vitest.config.mjs --coverage", "test:medium": "vitest --config test/vitest.config.medium.mjs", "typeorm": "typeorm", - "lifecycle": "node ./dist/utils/lifecycle.js", "migrations:debug": "node ./dist/bin/migrations.js debug", "migrations:generate": "node ./dist/bin/migrations.js generate", "migrations:create": "node ./dist/bin/migrations.js create", "migrations:run": "node ./dist/bin/migrations.js run", + "migrations:revert": "node ./dist/bin/migrations.js revert", "schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'", "schema:reset": "npm run schema:drop && npm run migrations:run", "sync:open-api": "node ./dist/bin/sync-open-api.js", @@ -34,6 +34,7 @@ "email:dev": "email dev -p 3050 --dir src/emails" }, "dependencies": { + "@extism/extism": "2.0.0-rc13", "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", "@nestjs/core": "^11.0.4", @@ -56,6 +57,7 @@ "@react-email/components": "^0.5.0", "@react-email/render": "^1.1.2", "@socket.io/redis-adapter": "^8.3.0", + "ajv": "^8.17.1", "archiver": "^7.0.0", "async-lock": "^1.4.0", "bcrypt": "^6.0.0", @@ -77,6 +79,7 @@ "i18n-iso-countries": "^7.6.0", "ioredis": "^5.8.2", "js-yaml": "^4.1.0", + "jsonwebtoken": "^9.0.2", "kysely": "0.28.2", "kysely-postgres-js": "^3.0.0", "lodash": "^4.17.21", @@ -124,12 +127,13 @@ "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/fluent-ffmpeg": "^2.1.21", + "@types/jsonwebtoken": "^9.0.10", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.14.197", "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^22.18.12", + "@types/node": "^22.19.0", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 8079441329..f80a47bb77 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -23,7 +23,7 @@ import { WebsocketRepository } from 'src/repositories/websocket.repository'; import { services } from 'src/services'; import { AuthService } from 'src/services/auth.service'; import { CliService } from 'src/services/cli.service'; -import { JobService } from 'src/services/job.service'; +import { QueueService } from 'src/services/queue.service'; import { getKyselyConfig } from 'src/utils/database'; const common = [...repositories, ...services, GlobalExceptionFilter]; @@ -52,11 +52,11 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { constructor( @Inject(IWorker) private worker: ImmichWorker, logger: LoggingRepository, - private eventRepository: EventRepository, - private websocketRepository: WebsocketRepository, - private jobService: JobService, - private telemetryRepository: TelemetryRepository, private authService: AuthService, + private eventRepository: EventRepository, + private queueService: QueueService, + private telemetryRepository: TelemetryRepository, + private websocketRepository: WebsocketRepository, ) { logger.setAppName(this.worker); } @@ -64,7 +64,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { async onModuleInit() { this.telemetryRepository.setup({ repositories }); - this.jobService.setServices(services); + this.queueService.setServices(services); this.websocketRepository.setAuthFn(async (client) => this.authService.authenticate({ diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts index ebb07af442..588f358023 100644 --- a/server/src/bin/migrations.ts +++ b/server/src/bin/migrations.ts @@ -2,7 +2,7 @@ process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich'; import { Kysely, sql } from 'kysely'; -import { mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { basename, dirname, extname, join } from 'node:path'; import postgres from 'postgres'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -27,6 +27,11 @@ const main = async () => { return; } + case 'revert': { + await revert(); + return; + } + case 'query': { const query = process.argv[3]; await runQuery(query); @@ -48,6 +53,7 @@ const main = async () => { node dist/bin/migrations.js create node dist/bin/migrations.js generate node dist/bin/migrations.js run + node dist/bin/migrations.js revert `); } } @@ -74,6 +80,25 @@ const runMigrations = async () => { await db.destroy(); }; +const revert = async () => { + const configRepository = new ConfigRepository(); + const logger = LoggingRepository.create(); + const db = getDatabaseClient(); + const databaseRepository = new DatabaseRepository(db, logger, configRepository); + + try { + const migrationName = await databaseRepository.revertLastMigration(); + if (!migrationName) { + console.log('No migrations to revert'); + return; + } + + markMigrationAsReverted(migrationName); + } finally { + await db.destroy(); + } +}; + const debug = async () => { const { up } = await compare(); const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n'); @@ -148,6 +173,37 @@ ${downSql} `; }; +const markMigrationAsReverted = (migrationName: string) => { + // eslint-disable-next-line unicorn/prefer-module + const distRoot = join(__dirname, '..'); + const projectRoot = join(distRoot, '..'); + const sourceFolder = join(projectRoot, 'src', 'schema', 'migrations'); + const distFolder = join(distRoot, 'schema', 'migrations'); + + const sourcePath = join(sourceFolder, `${migrationName}.ts`); + const revertedFolder = join(sourceFolder, 'reverted'); + const revertedPath = join(revertedFolder, `${migrationName}.ts`); + + if (existsSync(revertedPath)) { + console.log(`Migration ${migrationName} is already marked as reverted`); + } else if (existsSync(sourcePath)) { + mkdirSync(revertedFolder, { recursive: true }); + renameSync(sourcePath, revertedPath); + console.log(`Moved ${sourcePath} to ${revertedPath}`); + } else { + console.warn(`Source migration file not found for ${migrationName}`); + } + + const distBase = join(distFolder, migrationName); + for (const extension of ['.js', '.js.map', '.d.ts']) { + const filePath = `${distBase}${extension}`; + if (existsSync(filePath)) { + rmSync(filePath, { force: true }); + console.log(`Removed ${filePath}`); + } + } +}; + main() .then(() => { process.exit(0); diff --git a/server/src/config.ts b/server/src/config.ts index e81ad49621..c18acd79f8 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -235,6 +235,7 @@ export const defaults = Object.freeze({ [QueueName.VideoConversion]: { concurrency: 1 }, [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, + [QueueName.Workflow]: { concurrency: 5 }, }, logging: { enabled: true, diff --git a/server/src/constants.ts b/server/src/constants.ts index 3b75ca9f7e..d624557c54 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -2,18 +2,13 @@ import { Duration } from 'luxon'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { SemVer } from 'semver'; -import { DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum'; +import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum'; export const POSTGRES_VERSION_RANGE = '>=14.0.0'; export const VECTORCHORD_VERSION_RANGE = '>=0.3 <0.6'; export const VECTORS_VERSION_RANGE = '>=0.2 <0.4'; export const VECTOR_VERSION_RANGE = '>=0.5 <1'; -export const NEXT_RELEASE = 'NEXT_RELEASE'; -export const LIFECYCLE_EXTENSION = 'x-immich-lifecycle'; -export const DEPRECATED_IN_PREFIX = 'This property was deprecated in '; -export const ADDED_IN_PREFIX = 'This property was added in '; - export const JOBS_ASSET_PAGINATION_SIZE = 1000; export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000; @@ -138,3 +133,60 @@ export const ORIENTATION_TO_SHARP_ROTATION: Record = { + [ApiTag.Activities]: 'An activity is a like or a comment made by a user on an asset or album.', + [ApiTag.Albums]: 'An album is a collection of assets that can be shared with other users or via shared links.', + [ApiTag.ApiKeys]: 'An api key can be used to programmatically access the Immich API.', + [ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.', + [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', + [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', + [ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.', + [ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.', + [ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.', + [ApiTag.Faces]: + 'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually.', + [ApiTag.Jobs]: + 'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.', + [ApiTag.Libraries]: + 'An external library is made up of input file paths or expressions that are scanned for asset files. Discovered files are automatically imported. Assets much be unique within a library, but can be duplicated across libraries. Each user has a default upload library, and can have one or more external libraries.', + [ApiTag.Map]: + 'Map endpoints include supplemental functionality related to geolocation, such as reverse geocoding and retrieving map markers for assets with geolocation data.', + [ApiTag.Memories]: + 'A memory is a specialized collection of assets with dedicated viewing implementations in the web and mobile clients. A memory includes fields related to visibility and are automatically generated per user via a background job.', + [ApiTag.Notifications]: + 'A notification is a specialized message sent to users to inform them of important events. Currently, these notifications are only shown in the Immich web application.', + [ApiTag.NotificationsAdmin]: 'Notification administrative endpoints.', + [ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.', + [ApiTag.People]: + 'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.', + [ApiTag.Plugins]: + 'A plugin is an installed module that makes filters and actions available for the workflow feature.', + [ApiTag.Search]: + 'Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting.', + [ApiTag.Server]: + 'Information about the current server deployment, including version and build information, available features, supported media types, and more.', + [ApiTag.Sessions]: + 'A session represents an authenticated login session for a user. Sessions also appear in the web application as "Authorized devices".', + [ApiTag.SharedLinks]: + 'A shared link is a public url that provides access to a specific album, asset, or collection of assets. A shared link can be protected with a password, include a specific slug, allow or disallow downloads, and optionally include an expiration date.', + [ApiTag.Stacks]: + 'A stack is a group of related assets. One asset is the "primary" asset, and the rest are "child" assets. On the main timeline, stack parents are included by default, while child assets are hidden.', + [ApiTag.Sync]: 'A collection of endpoints for the new mobile synchronization implementation.', + [ApiTag.SystemConfig]: 'Endpoints to view, modify, and validate the system configuration settings.', + [ApiTag.SystemMetadata]: + 'Endpoints to view, modify, and validate the system metadata, which includes information about things like admin onboarding status.', + [ApiTag.Tags]: + 'A tag is a user-defined label that can be applied to assets for organizational purposes. Tags can also be hierarchical, allowing for parent-child relationships between tags.', + [ApiTag.Timeline]: + 'Specialized endpoints related to the timeline implementation used in the web application. External applications or tools should not use or rely on these endpoints, as they are subject to change without notice.', + [ApiTag.Trash]: + 'Endpoints for managing the trash can, which includes assets that have been discarded. Items in the trash are automatically deleted after a configured amount of time.', + [ApiTag.UsersAdmin]: + 'Administrative endpoints for managing users, including creating, updating, deleting, and restoring users. Also includes endpoints for resetting passwords and PIN codes.', + [ApiTag.Users]: + 'Endpoints for viewing and updating the current users, including product key information, profile picture data, onboarding progress, and more.', + [ApiTag.Views]: 'Endpoints for specialized views, such as the folder view.', + [ApiTag.Workflows]: + 'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.', +}; diff --git a/server/src/controllers/activity.controller.ts b/server/src/controllers/activity.controller.ts index 75b2e2f8a3..850e95510f 100644 --- a/server/src/controllers/activity.controller.ts +++ b/server/src/controllers/activity.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { ActivityCreateDto, ActivityDto, @@ -9,24 +10,35 @@ import { ActivityStatisticsResponseDto, } from 'src/dtos/activity.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { ActivityService } from 'src/services/activity.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Activities') +@ApiTags(ApiTag.Activities) @Controller('activities') export class ActivityController { constructor(private service: ActivityService) {} @Get() @Authenticated({ permission: Permission.ActivityRead }) + @Endpoint({ + summary: 'List all activities', + description: + 'Returns a list of activities for the selected asset or album. The activities are returned in sorted order, with the oldest activities appearing first.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getActivities(@Auth() auth: AuthDto, @Query() dto: ActivitySearchDto): Promise { return this.service.getAll(auth, dto); } @Post() @Authenticated({ permission: Permission.ActivityCreate }) + @Endpoint({ + summary: 'Create an activity', + description: 'Create a like or a comment for an album, or an asset in an album.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async createActivity( @Auth() auth: AuthDto, @Body() dto: ActivityCreateDto, @@ -41,6 +53,11 @@ export class ActivityController { @Get('statistics') @Authenticated({ permission: Permission.ActivityStatistics }) + @Endpoint({ + summary: 'Retrieve activity statistics', + description: 'Returns the number of likes and comments for a given album or asset in an album.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise { return this.service.getStatistics(auth, dto); } @@ -48,6 +65,11 @@ export class ActivityController { @Delete(':id') @Authenticated({ permission: Permission.ActivityDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete an activity', + description: 'Removes a like or comment from a given album or asset in an album.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index 47f8b5603a..dad70257a7 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AddUsersDto, AlbumInfoDto, @@ -14,36 +15,56 @@ import { } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { AlbumService } from 'src/services/album.service'; import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation'; -@ApiTags('Albums') +@ApiTags(ApiTag.Albums) @Controller('albums') export class AlbumController { constructor(private service: AlbumService) {} @Get() @Authenticated({ permission: Permission.AlbumRead }) + @Endpoint({ + summary: 'List all albums', + description: 'Retrieve a list of albums available to the authenticated user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise { return this.service.getAll(auth, query); } @Post() @Authenticated({ permission: Permission.AlbumCreate }) + @Endpoint({ + summary: 'Create an album', + description: 'Create a new album. The album can also be created with initial users and assets.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise { return this.service.create(auth, dto); } @Get('statistics') @Authenticated({ permission: Permission.AlbumStatistics }) + @Endpoint({ + summary: 'Retrieve album statistics', + description: 'Returns statistics about the albums available to the authenticated user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAlbumStatistics(@Auth() auth: AuthDto): Promise { return this.service.getStatistics(auth); } @Authenticated({ permission: Permission.AlbumRead, sharedLink: true }) @Get(':id') + @Endpoint({ + summary: 'Retrieve an album', + description: 'Retrieve information about a specific album by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAlbumInfo( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -54,6 +75,12 @@ export class AlbumController { @Patch(':id') @Authenticated({ permission: Permission.AlbumUpdate }) + @Endpoint({ + summary: 'Update an album', + description: + 'Update the information of a specific album by its ID. This endpoint can be used to update the album name, description, sort order, etc. However, it is not used to add or remove assets or users from the album.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateAlbumInfo( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -65,12 +92,23 @@ export class AlbumController { @Delete(':id') @Authenticated({ permission: Permission.AlbumDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete an album', + description: + 'Delete a specific album by its ID. Note the album is initially trashed and then immediately scheduled for deletion, but relies on a background job to complete the process.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { return this.service.delete(auth, id); } @Put(':id/assets') @Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true }) + @Endpoint({ + summary: 'Add assets to an album', + description: 'Add multiple assets to a specific album by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) addAssetsToAlbum( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -81,12 +119,22 @@ export class AlbumController { @Put('assets') @Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true }) + @Endpoint({ + summary: 'Add assets to albums', + description: 'Send a list of asset IDs and album IDs to add each asset to each album.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) addAssetsToAlbums(@Auth() auth: AuthDto, @Body() dto: AlbumsAddAssetsDto): Promise { return this.service.addAssetsToAlbums(auth, dto); } @Delete(':id/assets') @Authenticated({ permission: Permission.AlbumAssetDelete }) + @Endpoint({ + summary: 'Remove assets from an album', + description: 'Remove multiple assets from a specific album by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) removeAssetFromAlbum( @Auth() auth: AuthDto, @Body() dto: BulkIdsDto, @@ -97,6 +145,11 @@ export class AlbumController { @Put(':id/users') @Authenticated({ permission: Permission.AlbumUserCreate }) + @Endpoint({ + summary: 'Share album with users', + description: 'Share an album with multiple users. Each user can be given a specific role in the album.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) addUsersToAlbum( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -108,6 +161,11 @@ export class AlbumController { @Put(':id/user/:userId') @Authenticated({ permission: Permission.AlbumUserUpdate }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Update user role', + description: 'Change the role for a specific user in a specific album.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateAlbumUser( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -120,6 +178,11 @@ export class AlbumController { @Delete(':id/user/:userId') @Authenticated({ permission: Permission.AlbumUserDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Remove user from album', + description: 'Remove a user from an album. Use an ID of "me" to leave a shared album.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) removeUserFromAlbum( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index 59b6908128..61ad203331 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -1,43 +1,69 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { ApiKeyService } from 'src/services/api-key.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('API Keys') +@ApiTags(ApiTag.ApiKeys) @Controller('api-keys') export class ApiKeyController { constructor(private service: ApiKeyService) {} @Post() @Authenticated({ permission: Permission.ApiKeyCreate }) + @Endpoint({ + summary: 'Create an API key', + description: 'Creates a new API key. It will be limited to the permissions specified.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise { return this.service.create(auth, dto); } @Get() @Authenticated({ permission: Permission.ApiKeyRead }) + @Endpoint({ + summary: 'List all API keys', + description: 'Retrieve all API keys of the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getApiKeys(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Get('me') @Authenticated({ permission: false }) + @Endpoint({ + summary: 'Retrieve the current API key', + description: 'Retrieve the API key that is used to access this endpoint.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async getMyApiKey(@Auth() auth: AuthDto): Promise { return this.service.getMine(auth); } @Get(':id') @Authenticated({ permission: Permission.ApiKeyRead }) + @Endpoint({ + summary: 'Retrieve an API key', + description: 'Retrieve an API key by its ID. The current user must own this API key.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getById(auth, id); } @Put(':id') @Authenticated({ permission: Permission.ApiKeyUpdate }) + @Endpoint({ + summary: 'Update an API key', + description: 'Updates the name and permissions of an API key by its ID. The current user must own this API key.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateApiKey( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -49,6 +75,11 @@ export class ApiKeyController { @Delete(':id') @Authenticated({ permission: Permission.ApiKeyDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete an API key', + description: 'Deletes an API key identified by its ID. The current user must own this API key.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 688e513b64..843c2a3f3d 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -15,9 +15,9 @@ import { UploadedFiles, UseInterceptors, } from '@nestjs/common'; -import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; import { NextFunction, Request, Response } from 'express'; -import { EndpointLifecycle } from 'src/decorators'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetBulkUploadCheckResponseDto, AssetMediaResponseDto, @@ -34,7 +34,7 @@ import { UploadFieldName, } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ImmichHeader, Permission, RouteKey } from 'src/enum'; +import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor'; @@ -44,7 +44,7 @@ import { UploadFiles } from 'src/types'; import { ImmichFileResponse, sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; -@ApiTags('Assets') +@ApiTags(ApiTag.Assets) @Controller(RouteKey.Asset) export class AssetMediaController { constructor( @@ -53,6 +53,7 @@ export class AssetMediaController { ) {} @Post() + @Authenticated({ permission: Permission.AssetUpload, sharedLink: true }) @UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor) @ApiConsumes('multipart/form-data') @ApiHeader({ @@ -61,7 +62,11 @@ export class AssetMediaController { required: false, }) @ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto }) - @Authenticated({ permission: Permission.AssetUpload, sharedLink: true }) + @Endpoint({ + summary: 'Upload asset', + description: 'Uploads a new asset to the server.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async uploadAsset( @Auth() auth: AuthDto, @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles, @@ -81,6 +86,11 @@ export class AssetMediaController { @Get(':id/original') @FileResponse() @Authenticated({ permission: Permission.AssetDownload, sharedLink: true }) + @Endpoint({ + summary: 'Download original asset', + description: 'Downloads the original file of the specified asset.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async downloadAsset( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -90,17 +100,13 @@ export class AssetMediaController { await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger); } - /** - * Replace the asset with new file, without changing its id - */ @Put(':id/original') @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') - @EndpointLifecycle({ - addedAt: 'v1.106.0', - deprecatedAt: 'v1.142.0', - summary: 'replaceAsset', - description: 'Replace the asset with new file, without changing its id', + @Endpoint({ + summary: 'Replace asset', + description: 'Replace the asset with new file, without changing its id.', + history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'copyAsset' }), }) @Authenticated({ permission: Permission.AssetReplace, sharedLink: true }) async replaceAsset( @@ -122,6 +128,11 @@ export class AssetMediaController { @Get(':id/thumbnail') @FileResponse() @Authenticated({ permission: Permission.AssetView, sharedLink: true }) + @Endpoint({ + summary: 'View asset thumbnail', + description: 'Retrieve the thumbnail image for the specified asset.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async viewAsset( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -159,6 +170,11 @@ export class AssetMediaController { @Get(':id/video/playback') @FileResponse() @Authenticated({ permission: Permission.AssetView, sharedLink: true }) + @Endpoint({ + summary: 'Play asset video', + description: 'Streams the video file for the specified asset. This endpoint also supports byte range requests.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async playAssetVideo( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -168,14 +184,12 @@ export class AssetMediaController { await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger); } - /** - * Checks if multiple assets exist on the server and returns all existing - used by background backup - */ @Post('exist') @Authenticated() - @ApiOperation({ - summary: 'checkExistingAssets', + @Endpoint({ + summary: 'Check existing assets', description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) @HttpCode(HttpStatus.OK) checkExistingAssets( @@ -185,14 +199,12 @@ export class AssetMediaController { return this.service.checkExistingAssets(auth, dto); } - /** - * Checks if assets exist by checksums - */ @Post('bulk-upload-check') @Authenticated({ permission: Permission.AssetUpload }) - @ApiOperation({ - summary: 'checkBulkUpload', - description: 'Checks if assets exist by checksums', + @Endpoint({ + summary: 'Check bulk upload', + description: 'Determine which assets have already been uploaded to the server based on their SHA1 checksums.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) @HttpCode(HttpStatus.OK) checkBulkUpload( diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index a6f8c7921d..bcc13fbc06 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { EndpointLifecycle } from 'src/decorators'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, @@ -18,30 +18,32 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; -import { Permission, RouteKey } from 'src/enum'; +import { ApiTag, Permission, RouteKey } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { AssetService } from 'src/services/asset.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Assets') +@ApiTags(ApiTag.Assets) @Controller(RouteKey.Asset) export class AssetController { constructor(private service: AssetService) {} @Get('random') @Authenticated({ permission: Permission.AssetRead }) - @EndpointLifecycle({ deprecatedAt: 'v1.116.0' }) + @Endpoint({ + summary: 'Get random assets', + description: 'Retrieve a specified number of random assets for the authenticated user.', + history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'searchAssets' }), + }) getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise { return this.service.getRandom(auth, dto.count ?? 1); } - /** - * Get all asset of a device that are in the database, ID only. - */ @Get('/device/:deviceId') - @ApiOperation({ - summary: 'getAllUserAssetsByDeviceId', + @Endpoint({ + summary: 'Retrieve assets by device ID', description: 'Get all asset of a device that are in the database, ID only.', + history: new HistoryBuilder().added('v1').deprecated('v2'), }) @Authenticated() getAllUserAssetsByDeviceId(@Auth() auth: AuthDto, @Param() { deviceId }: DeviceIdDto) { @@ -50,6 +52,11 @@ export class AssetController { @Get('statistics') @Authenticated({ permission: Permission.AssetStatistics }) + @Endpoint({ + summary: 'Get asset statistics', + description: 'Retrieve various statistics about the assets owned by the authenticated user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAssetStatistics(@Auth() auth: AuthDto, @Query() dto: AssetStatsDto): Promise { return this.service.getStatistics(auth, dto); } @@ -57,6 +64,11 @@ export class AssetController { @Post('jobs') @Authenticated() @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Run an asset job', + description: 'Run a specific job on a set of assets.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise { return this.service.run(auth, dto); } @@ -64,6 +76,11 @@ export class AssetController { @Put() @Authenticated({ permission: Permission.AssetUpdate }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Update assets', + description: 'Updates multiple assets at the same time.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkUpdateDto): Promise { return this.service.updateAll(auth, dto); } @@ -71,12 +88,22 @@ export class AssetController { @Delete() @Authenticated({ permission: Permission.AssetDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete assets', + description: 'Deletes multiple assets at the same time.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkDeleteDto): Promise { return this.service.deleteAll(auth, dto); } @Get(':id') @Authenticated({ permission: Permission.AssetRead, sharedLink: true }) + @Endpoint({ + summary: 'Retrieve an asset', + description: 'Retrieve detailed information about a specific asset.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id) as Promise; } @@ -84,12 +111,22 @@ export class AssetController { @Put('copy') @Authenticated({ permission: Permission.AssetCopy }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Copy asset', + description: 'Copy asset information like albums, tags, etc. from one asset to another.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) copyAsset(@Auth() auth: AuthDto, @Body() dto: AssetCopyDto): Promise { return this.service.copy(auth, dto); } @Put(':id') @Authenticated({ permission: Permission.AssetUpdate }) + @Endpoint({ + summary: 'Update an asset', + description: 'Update information of a specific asset.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateAsset( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -100,18 +137,33 @@ export class AssetController { @Get(':id/metadata') @Authenticated({ permission: Permission.AssetRead }) + @Endpoint({ + summary: 'Get asset metadata', + description: 'Retrieve all metadata key-value pairs associated with the specified asset.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getMetadata(auth, id); } @Get(':id/ocr') @Authenticated({ permission: Permission.AssetRead }) + @Endpoint({ + summary: 'Retrieve asset OCR data', + description: 'Retrieve all OCR (Optical Character Recognition) data associated with the specified asset.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAssetOcr(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getOcr(auth, id); } @Put(':id/metadata') @Authenticated({ permission: Permission.AssetUpdate }) + @Endpoint({ + summary: 'Update asset metadata', + description: 'Update or add metadata key-value pairs for the specified asset.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateAssetMetadata( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -122,6 +174,11 @@ export class AssetController { @Get(':id/metadata/:key') @Authenticated({ permission: Permission.AssetRead }) + @Endpoint({ + summary: 'Retrieve asset metadata by key', + description: 'Retrieve the value of a specific metadata key associated with the specified asset.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAssetMetadataByKey( @Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams, @@ -132,6 +189,11 @@ export class AssetController { @Delete(':id/metadata/:key') @Authenticated({ permission: Permission.AssetUpdate }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete asset metadata by key', + description: 'Delete a specific metadata key-value pair associated with the specified asset.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise { return this.service.deleteMetadataByKey(auth, id, key); } diff --git a/server/src/controllers/auth-admin.controller.ts b/server/src/controllers/auth-admin.controller.ts index dba352783e..d4cada9afc 100644 --- a/server/src/controllers/auth-admin.controller.ts +++ b/server/src/controllers/auth-admin.controller.ts @@ -1,17 +1,23 @@ import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { AuthAdminService } from 'src/services/auth-admin.service'; -@ApiTags('Auth (admin)') +@ApiTags(ApiTag.AuthenticationAdmin) @Controller('admin/auth') export class AuthAdminController { constructor(private service: AuthAdminService) {} @Post('unlink-all') @Authenticated({ permission: Permission.AdminAuthUnlinkAll, admin: true }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Unlink all OAuth accounts', + description: 'Unlinks all OAuth accounts associated with user accounts in the system.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) unlinkAllOAuthAccountsAdmin(@Auth() auth: AuthDto): Promise { return this.service.unlinkAll(auth); } diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 636e3a3047..ea09e33080 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto, AuthStatusResponseDto, @@ -16,17 +17,22 @@ import { ValidateAccessTokenResponseDto, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto'; -import { AuthType, ImmichCookie, Permission } from 'src/enum'; +import { ApiTag, AuthType, ImmichCookie, Permission } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { respondWithCookie, respondWithoutCookie } from 'src/utils/response'; -@ApiTags('Authentication') +@ApiTags(ApiTag.Authentication) @Controller('auth') export class AuthController { constructor(private service: AuthService) {} @Post('login') + @Endpoint({ + summary: 'Login', + description: 'Login with username and password and receive a session token.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async login( @Res({ passthrough: true }) res: Response, @Body() loginCredential: LoginCredentialDto, @@ -44,11 +50,21 @@ export class AuthController { } @Post('admin-sign-up') + @Endpoint({ + summary: 'Register admin', + description: 'Create the first admin user in the system.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) signUpAdmin(@Body() dto: SignUpDto): Promise { return this.service.adminSignUp(dto); } @Post('validateToken') + @Endpoint({ + summary: 'Validate access token', + description: 'Validate the current authorization method is still valid.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) @Authenticated({ permission: false }) @HttpCode(HttpStatus.OK) validateAccessToken(): ValidateAccessTokenResponseDto { @@ -58,6 +74,11 @@ export class AuthController { @Post('change-password') @Authenticated({ permission: Permission.AuthChangePassword }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Change password', + description: 'Change the password of the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise { return this.service.changePassword(auth, dto); } @@ -65,6 +86,11 @@ export class AuthController { @Post('logout') @Authenticated() @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Logout', + description: 'Logout the current user and invalidate the session token.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async logout( @Req() request: Request, @Res({ passthrough: true }) res: Response, @@ -82,6 +108,11 @@ export class AuthController { @Get('status') @Authenticated() + @Endpoint({ + summary: 'Retrieve auth status', + description: + 'Get information about the current session, including whether the user has a password, and if the session can access locked assets.', + }) getAuthStatus(@Auth() auth: AuthDto): Promise { return this.service.getAuthStatus(auth); } @@ -89,6 +120,11 @@ export class AuthController { @Post('pin-code') @Authenticated({ permission: Permission.PinCodeCreate }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Setup pin code', + description: 'Setup a new pin code for the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise { return this.service.setupPinCode(auth, dto); } @@ -96,6 +132,11 @@ export class AuthController { @Put('pin-code') @Authenticated({ permission: Permission.PinCodeUpdate }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Change pin code', + description: 'Change the pin code for the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise { return this.service.changePinCode(auth, dto); } @@ -103,6 +144,11 @@ export class AuthController { @Delete('pin-code') @Authenticated({ permission: Permission.PinCodeDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Reset pin code', + description: 'Reset the pin code for the current user by providing the account password', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise { return this.service.resetPinCode(auth, dto); } @@ -110,12 +156,22 @@ export class AuthController { @Post('session/unlock') @Authenticated() @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Unlock auth session', + description: 'Temporarily grant the session elevated access to locked assets by providing the correct PIN code.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise { return this.service.unlockSession(auth, dto); } @Post('session/lock') @Authenticated() + @Endpoint({ + summary: 'Lock auth session', + description: 'Remove elevated access to locked assets from the current session.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) @HttpCode(HttpStatus.NO_CONTENT) async lockAuthSession(@Auth() auth: AuthDto): Promise { return this.service.lockSession(auth); diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index a7c2af78ed..942d44f4c3 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,20 +1,27 @@ import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { DownloadService } from 'src/services/download.service'; import { asStreamableFile } from 'src/utils/file'; -@ApiTags('Download') +@ApiTags(ApiTag.Download) @Controller('download') export class DownloadController { constructor(private service: DownloadService) {} @Post('info') @Authenticated({ permission: Permission.AssetDownload, sharedLink: true }) + @Endpoint({ + summary: 'Retrieve download information', + description: + 'Retrieve information about how to request a download for the specified assets or album. The response includes groups of assets that can be downloaded together.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getDownloadInfo(@Auth() auth: AuthDto, @Body() dto: DownloadInfoDto): Promise { return this.service.getDownloadInfo(auth, dto); } @@ -23,6 +30,12 @@ export class DownloadController { @Authenticated({ permission: Permission.AssetDownload, sharedLink: true }) @FileResponse() @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Download asset archive', + description: + 'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise { return this.service.downloadArchive(auth, dto).then(asStreamableFile); } diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts index 9cf5ae97a6..e8c8e5ef80 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -1,20 +1,26 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { DuplicateService } from 'src/services/duplicate.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Duplicates') +@ApiTags(ApiTag.Duplicates) @Controller('duplicates') export class DuplicateController { constructor(private service: DuplicateService) {} @Get() @Authenticated({ permission: Permission.DuplicateRead }) + @Endpoint({ + summary: 'Retrieve duplicates', + description: 'Retrieve a list of duplicate assets available to the authenticated user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAssetDuplicates(@Auth() auth: AuthDto): Promise { return this.service.getDuplicates(auth); } @@ -22,6 +28,11 @@ export class DuplicateController { @Delete() @Authenticated({ permission: Permission.DuplicateDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete duplicates', + description: 'Delete multiple duplicate assets specified by their IDs.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteDuplicates(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { return this.service.deleteAll(auth, dto); } @@ -29,6 +40,11 @@ export class DuplicateController { @Delete(':id') @Authenticated({ permission: Permission.DuplicateDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a duplicate', + description: 'Delete a single duplicate asset specified by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/face.controller.ts b/server/src/controllers/face.controller.ts index 564b217c16..a1c1d6ee4d 100644 --- a/server/src/controllers/face.controller.ts +++ b/server/src/controllers/face.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceCreateDto, @@ -8,30 +9,46 @@ import { FaceDto, PersonResponseDto, } from 'src/dtos/person.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { PersonService } from 'src/services/person.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Faces') +@ApiTags(ApiTag.Faces) @Controller('faces') export class FaceController { constructor(private service: PersonService) {} @Post() @Authenticated({ permission: Permission.FaceCreate }) + @Endpoint({ + summary: 'Create a face', + description: + 'Create a new face that has not been discovered by facial recognition. The content of the bounding box is considered a face.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createFace(@Auth() auth: AuthDto, @Body() dto: AssetFaceCreateDto) { return this.service.createFace(auth, dto); } @Get() @Authenticated({ permission: Permission.FaceRead }) + @Endpoint({ + summary: 'Retrieve faces for asset', + description: 'Retrieve all faces belonging to an asset.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise { return this.service.getFacesById(auth, dto); } @Put(':id') @Authenticated({ permission: Permission.FaceUpdate }) + @Endpoint({ + summary: 'Re-assign a face to another person', + description: 'Re-assign the face provided in the body to the person identified by the id in the path parameter.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) reassignFacesById( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -43,6 +60,11 @@ export class FaceController { @Delete(':id') @Authenticated({ permission: Permission.FaceDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a face', + description: 'Delete a face identified by the id. Optionally can be force deleted.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto): Promise { return this.service.deleteFace(auth, id, dto); } diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e3661ec794..c0c0461fb3 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -18,6 +18,7 @@ import { NotificationController } from 'src/controllers/notification.controller' import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; +import { PluginController } from 'src/controllers/plugin.controller'; import { SearchController } from 'src/controllers/search.controller'; import { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; @@ -32,6 +33,7 @@ import { TrashController } from 'src/controllers/trash.controller'; import { UserAdminController } from 'src/controllers/user-admin.controller'; import { UserController } from 'src/controllers/user.controller'; import { ViewController } from 'src/controllers/view.controller'; +import { WorkflowController } from 'src/controllers/workflow.controller'; export const controllers = [ ApiKeyController, @@ -54,6 +56,7 @@ export const controllers = [ OAuthController, PartnerController, PersonController, + PluginController, SearchController, ServerController, SessionController, @@ -68,4 +71,5 @@ export const controllers = [ UserAdminController, UserController, ViewController, + WorkflowController, ]; diff --git a/server/src/controllers/job.controller.ts b/server/src/controllers/job.controller.ts index 9c4e819649..977f1e0f1e 100644 --- a/server/src/controllers/job.controller.ts +++ b/server/src/controllers/job.controller.ts @@ -1,31 +1,54 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; -import { Permission } from 'src/enum'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { JobCreateDto } from 'src/dtos/job.dto'; +import { QueueCommandDto, QueueNameParamDto, QueueResponseDto, QueuesResponseDto } from 'src/dtos/queue.dto'; +import { ApiTag, Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { JobService } from 'src/services/job.service'; +import { QueueService } from 'src/services/queue.service'; -@ApiTags('Jobs') +@ApiTags(ApiTag.Jobs) @Controller('jobs') export class JobController { - constructor(private service: JobService) {} + constructor( + private service: JobService, + private queueService: QueueService, + ) {} @Get() @Authenticated({ permission: Permission.JobRead, admin: true }) - getAllJobsStatus(): Promise { - return this.service.getAllJobsStatus(); + @Endpoint({ + summary: 'Retrieve queue counts and status', + description: 'Retrieve the counts of the current queue, as well as the current status.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) + getQueuesLegacy(): Promise { + return this.queueService.getAll(); } @Post() @Authenticated({ permission: Permission.JobCreate, admin: true }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Create a manual job', + description: + 'Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createJob(@Body() dto: JobCreateDto): Promise { return this.service.create(dto); } - @Put(':id') + @Put(':name') @Authenticated({ permission: Permission.JobCreate, admin: true }) - sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise { - return this.service.handleCommand(id, dto); + @Endpoint({ + summary: 'Run jobs', + description: + 'Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) + runQueueCommandLegacy(@Param() { name }: QueueNameParamDto, @Body() dto: QueueCommandDto): Promise { + return this.queueService.runCommand(name, dto); } } diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index b37bc40ce7..5672e9117a 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { CreateLibraryDto, LibraryResponseDto, @@ -8,36 +9,56 @@ import { ValidateLibraryDto, ValidateLibraryResponseDto, } from 'src/dtos/library.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { LibraryService } from 'src/services/library.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Libraries') +@ApiTags(ApiTag.Libraries) @Controller('libraries') export class LibraryController { constructor(private service: LibraryService) {} @Get() @Authenticated({ permission: Permission.LibraryRead, admin: true }) + @Endpoint({ + summary: 'Retrieve libraries', + description: 'Retrieve a list of external libraries.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAllLibraries(): Promise { return this.service.getAll(); } @Post() @Authenticated({ permission: Permission.LibraryCreate, admin: true }) + @Endpoint({ + summary: 'Create a library', + description: 'Create a new external library.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createLibrary(@Body() dto: CreateLibraryDto): Promise { return this.service.create(dto); } @Get(':id') @Authenticated({ permission: Permission.LibraryRead, admin: true }) + @Endpoint({ + summary: 'Retrieve a library', + description: 'Retrieve an external library by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getLibrary(@Param() { id }: UUIDParamDto): Promise { return this.service.get(id); } @Put(':id') @Authenticated({ permission: Permission.LibraryUpdate, admin: true }) + @Endpoint({ + summary: 'Update a library', + description: 'Update an existing external library.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise { return this.service.update(id, dto); } @@ -45,6 +66,11 @@ export class LibraryController { @Delete(':id') @Authenticated({ permission: Permission.LibraryDelete, admin: true }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a library', + description: 'Delete an external library by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteLibrary(@Param() { id }: UUIDParamDto): Promise { return this.service.delete(id); } @@ -52,6 +78,11 @@ export class LibraryController { @Post(':id/validate') @Authenticated({ admin: true }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Validate library settings', + description: 'Validate the settings of an external library.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) // TODO: change endpoint to validate current settings instead validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise { return this.service.validate(id, dto); @@ -59,6 +90,12 @@ export class LibraryController { @Get(':id/statistics') @Authenticated({ permission: Permission.LibraryStatistics, admin: true }) + @Endpoint({ + summary: 'Retrieve library statistics', + description: + 'Retrieve statistics for a specific external library, including number of videos, images, and storage usage.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { return this.service.getStatistics(id); } @@ -66,6 +103,11 @@ export class LibraryController { @Post(':id/scan') @Authenticated({ permission: Permission.LibraryUpdate, admin: true }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Scan a library', + description: 'Queue a scan for the external library to find and import new assets.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) scanLibrary(@Param() { id }: UUIDParamDto): Promise { return this.service.queueScan(id); } diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts index 88104e6b58..dbd1082561 100644 --- a/server/src/controllers/map.controller.ts +++ b/server/src/controllers/map.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, @@ -7,16 +8,22 @@ import { MapReverseGeocodeDto, MapReverseGeocodeResponseDto, } from 'src/dtos/map.dto'; +import { ApiTag } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MapService } from 'src/services/map.service'; -@ApiTags('Map') +@ApiTags(ApiTag.Map) @Controller('map') export class MapController { constructor(private service: MapService) {} @Get('markers') @Authenticated() + @Endpoint({ + summary: 'Retrieve map markers', + description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getMapMarkers(@Auth() auth: AuthDto, @Query() options: MapMarkerDto): Promise { return this.service.getMapMarkers(auth, options); } @@ -24,6 +31,11 @@ export class MapController { @Authenticated() @Get('reverse-geocode') @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Reverse geocode coordinates', + description: 'Retrieve location information (e.g., city, country) for given latitude and longitude coordinates.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) reverseGeocode(@Query() dto: MapReverseGeocodeDto): Promise { return this.service.reverseGeocode(dto); } diff --git a/server/src/controllers/memory.controller.spec.ts b/server/src/controllers/memory.controller.spec.ts index ac96e54a5b..8629b6c799 100644 --- a/server/src/controllers/memory.controller.spec.ts +++ b/server/src/controllers/memory.controller.spec.ts @@ -24,6 +24,11 @@ describe(MemoryController.name, () => { await request(ctx.getHttpServer()).get('/memories'); expect(ctx.authenticate).toHaveBeenCalled(); }); + + it('should not require any parameters', async () => { + await request(ctx.getHttpServer()).get('/memories').query({}); + expect(service.search).toHaveBeenCalled(); + }); }); describe('POST /memories', () => { diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index 3b5ad2bb4e..cbf86199bb 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -9,42 +10,69 @@ import { MemoryStatisticsResponseDto, MemoryUpdateDto, } from 'src/dtos/memory.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MemoryService } from 'src/services/memory.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Memories') +@ApiTags(ApiTag.Memories) @Controller('memories') export class MemoryController { constructor(private service: MemoryService) {} @Get() @Authenticated({ permission: Permission.MemoryRead }) + @Endpoint({ + summary: 'Retrieve memories', + description: + 'Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { return this.service.search(auth, dto); } @Post() @Authenticated({ permission: Permission.MemoryCreate }) + @Endpoint({ + summary: 'Create a memory', + description: + 'Create a new memory by providing a name, description, and a list of asset IDs to include in the memory.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise { return this.service.create(auth, dto); } @Get('statistics') @Authenticated({ permission: Permission.MemoryStatistics }) + @Endpoint({ + summary: 'Retrieve memories statistics', + description: 'Retrieve statistics about memories, such as total count and other relevant metrics.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) memoriesStatistics(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { return this.service.statistics(auth, dto); } @Get(':id') @Authenticated({ permission: Permission.MemoryRead }) + @Endpoint({ + summary: 'Retrieve a memory', + description: 'Retrieve a specific memory by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') @Authenticated({ permission: Permission.MemoryUpdate }) + @Endpoint({ + summary: 'Update a memory', + description: 'Update an existing memory by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateMemory( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -56,12 +84,22 @@ export class MemoryController { @Delete(':id') @Authenticated({ permission: Permission.MemoryDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a memory', + description: 'Delete a specific memory by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } @Put(':id/assets') @Authenticated({ permission: Permission.MemoryAssetCreate }) + @Endpoint({ + summary: 'Add assets to a memory', + description: 'Add a list of asset IDs to a specific memory.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) addMemoryAssets( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -73,6 +111,11 @@ export class MemoryController { @Delete(':id/assets') @Authenticated({ permission: Permission.MemoryAssetDelete }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Remove assets from a memory', + description: 'Remove a list of asset IDs from a specific memory.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) removeMemoryAssets( @Auth() auth: AuthDto, @Body() dto: BulkIdsDto, diff --git a/server/src/controllers/notification-admin.controller.ts b/server/src/controllers/notification-admin.controller.ts index 28ca7bfd30..c322c5a2b6 100644 --- a/server/src/controllers/notification-admin.controller.ts +++ b/server/src/controllers/notification-admin.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { NotificationCreateDto, @@ -9,17 +10,23 @@ import { TestEmailResponseDto, } from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; +import { ApiTag } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { EmailTemplate } from 'src/repositories/email.repository'; import { NotificationAdminService } from 'src/services/notification-admin.service'; -@ApiTags('Notifications (Admin)') +@ApiTags(ApiTag.NotificationsAdmin) @Controller('admin/notifications') export class NotificationAdminController { constructor(private service: NotificationAdminService) {} @Post() @Authenticated({ admin: true }) + @Endpoint({ + summary: 'Create a notification', + description: 'Create a new notification for a specific user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createNotification(@Auth() auth: AuthDto, @Body() dto: NotificationCreateDto): Promise { return this.service.create(auth, dto); } @@ -27,6 +34,11 @@ export class NotificationAdminController { @Post('test-email') @Authenticated({ admin: true }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Send test email', + description: 'Send a test email using the provided SMTP configuration.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) sendTestEmailAdmin(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise { return this.service.sendTestEmail(auth.user.id, dto); } @@ -34,6 +46,11 @@ export class NotificationAdminController { @Post('templates/:name') @Authenticated({ admin: true }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Render email template', + description: 'Retrieve a preview of the provided email template.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getNotificationTemplateAdmin( @Auth() auth: AuthDto, @Param('name') name: EmailTemplate, diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts index 8ce183c5d0..0a28e1bda8 100644 --- a/server/src/controllers/notification.controller.ts +++ b/server/src/controllers/notification.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { NotificationDeleteAllDto, @@ -8,18 +9,23 @@ import { NotificationUpdateAllDto, NotificationUpdateDto, } from 'src/dtos/notification.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { NotificationService } from 'src/services/notification.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Notifications') +@ApiTags(ApiTag.Notifications) @Controller('notifications') export class NotificationController { constructor(private service: NotificationService) {} @Get() @Authenticated({ permission: Permission.NotificationRead }) + @Endpoint({ + summary: 'Retrieve notifications', + description: 'Retrieve a list of notifications.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getNotifications(@Auth() auth: AuthDto, @Query() dto: NotificationSearchDto): Promise { return this.service.search(auth, dto); } @@ -27,6 +33,11 @@ export class NotificationController { @Put() @Authenticated({ permission: Permission.NotificationUpdate }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Update notifications', + description: 'Update a list of notifications. Allows to bulk-set the read status of notifications.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise { return this.service.updateAll(auth, dto); } @@ -34,18 +45,33 @@ export class NotificationController { @Delete() @Authenticated({ permission: Permission.NotificationDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete notifications', + description: 'Delete a list of notifications at once.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise { return this.service.deleteAll(auth, dto); } @Get(':id') @Authenticated({ permission: Permission.NotificationRead }) + @Endpoint({ + summary: 'Get a notification', + description: 'Retrieve a specific notification identified by id.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') @Authenticated({ permission: Permission.NotificationUpdate }) + @Endpoint({ + summary: 'Update a notification', + description: 'Update a specific notification to set its read status.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateNotification( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -57,6 +83,11 @@ export class NotificationController { @Delete(':id') @Authenticated({ permission: Permission.NotificationDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a notification', + description: 'Delete a specific notification.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index f81a184557..797bf497ef 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto, LoginResponseDto, @@ -9,18 +10,24 @@ import { OAuthConfigDto, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto'; -import { AuthType, ImmichCookie } from 'src/enum'; +import { ApiTag, AuthType, ImmichCookie } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { respondWithCookie } from 'src/utils/response'; -@ApiTags('OAuth') +@ApiTags(ApiTag.Authentication) @Controller('oauth') export class OAuthController { constructor(private service: AuthService) {} @Get('mobile-redirect') @Redirect() + @Endpoint({ + summary: 'Redirect OAuth to mobile', + description: + 'Requests to this URL are automatically forwarded to the mobile app, and is used in some cases for OAuth redirecting.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) redirectOAuthToMobile(@Req() request: Request) { return { url: this.service.getMobileRedirect(request.url), @@ -29,6 +36,11 @@ export class OAuthController { } @Post('authorize') + @Endpoint({ + summary: 'Start OAuth', + description: 'Initiate the OAuth authorization process.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async startOAuth( @Body() dto: OAuthConfigDto, @Res({ passthrough: true }) res: Response, @@ -49,6 +61,11 @@ export class OAuthController { } @Post('callback') + @Endpoint({ + summary: 'Finish OAuth', + description: 'Complete the OAuth authorization process by exchanging the authorization code for a session token.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async finishOAuth( @Req() request: Request, @Res({ passthrough: true }) res: Response, @@ -71,6 +88,11 @@ export class OAuthController { @Post('link') @Authenticated() @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Link OAuth account', + description: 'Link an OAuth account to the authenticated user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) linkOAuthAccount( @Req() request: Request, @Auth() auth: AuthDto, @@ -82,6 +104,11 @@ export class OAuthController { @Post('unlink') @Authenticated() @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Unlink OAuth account', + description: 'Unlink the OAuth account from the authenticated user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) unlinkOAuthAccount(@Auth() auth: AuthDto): Promise { return this.service.unlink(auth); } diff --git a/server/src/controllers/partner.controller.ts b/server/src/controllers/partner.controller.ts index 7cb5c1c274..951aee7e0c 100644 --- a/server/src/controllers/partner.controller.ts +++ b/server/src/controllers/partner.controller.ts @@ -1,32 +1,46 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { EndpointLifecycle } from 'src/decorators'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { PartnerService } from 'src/services/partner.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Partners') +@ApiTags(ApiTag.Partners) @Controller('partners') export class PartnerController { constructor(private service: PartnerService) {} @Get() @Authenticated({ permission: Permission.PartnerRead }) + @Endpoint({ + summary: 'Retrieve partners', + description: 'Retrieve a list of partners with whom assets are shared.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getPartners(@Auth() auth: AuthDto, @Query() dto: PartnerSearchDto): Promise { return this.service.search(auth, dto); } @Post() @Authenticated({ permission: Permission.PartnerCreate }) + @Endpoint({ + summary: 'Create a partner', + description: 'Create a new partner to share assets with.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createPartner(@Auth() auth: AuthDto, @Body() dto: PartnerCreateDto): Promise { return this.service.create(auth, dto); } @Post(':id') - @EndpointLifecycle({ deprecatedAt: 'v1.141.0' }) + @Endpoint({ + summary: 'Create a partner', + description: 'Create a new partner to share assets with.', + history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'createPartner' }), + }) @Authenticated({ permission: Permission.PartnerCreate }) createPartnerDeprecated(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.create(auth, { sharedWithId: id }); @@ -34,6 +48,11 @@ export class PartnerController { @Put(':id') @Authenticated({ permission: Permission.PartnerUpdate }) + @Endpoint({ + summary: 'Update a partner', + description: "Specify whether a partner's assets should appear in the user's timeline.", + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updatePartner( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -45,6 +64,11 @@ export class PartnerController { @Delete(':id') @Authenticated({ permission: Permission.PartnerDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Remove a partner', + description: 'Stop sharing assets with a partner.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index 84bb864cd3..5abd6eb1b4 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -14,6 +14,7 @@ import { } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -27,14 +28,14 @@ import { PersonStatisticsResponseDto, PersonUpdateDto, } from 'src/dtos/person.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PersonService } from 'src/services/person.service'; import { sendFile } from 'src/utils/file'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('People') +@ApiTags(ApiTag.People) @Controller('people') export class PersonController { constructor( @@ -46,18 +47,33 @@ export class PersonController { @Get() @Authenticated({ permission: Permission.PersonRead }) + @Endpoint({ + summary: 'Get all people', + description: 'Retrieve a list of all people.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAllPeople(@Auth() auth: AuthDto, @Query() options: PersonSearchDto): Promise { return this.service.getAll(auth, options); } @Post() @Authenticated({ permission: Permission.PersonCreate }) + @Endpoint({ + summary: 'Create a person', + description: 'Create a new person that can have multiple faces assigned to them.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise { return this.service.create(auth, dto); } @Put() @Authenticated({ permission: Permission.PersonUpdate }) + @Endpoint({ + summary: 'Update people', + description: 'Bulk update multiple people at once.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise { return this.service.updateAll(auth, dto); } @@ -65,18 +81,33 @@ export class PersonController { @Delete() @Authenticated({ permission: Permission.PersonDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete people', + description: 'Bulk delete a list of people at once.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deletePeople(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { return this.service.deleteAll(auth, dto); } @Get(':id') @Authenticated({ permission: Permission.PersonRead }) + @Endpoint({ + summary: 'Get a person', + description: 'Retrieve a person by id.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getById(auth, id); } @Put(':id') @Authenticated({ permission: Permission.PersonUpdate }) + @Endpoint({ + summary: 'Update person', + description: 'Update an individual person.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updatePerson( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -88,12 +119,22 @@ export class PersonController { @Delete(':id') @Authenticated({ permission: Permission.PersonDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete person', + description: 'Delete an individual person.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } @Get(':id/statistics') @Authenticated({ permission: Permission.PersonStatistics }) + @Endpoint({ + summary: 'Get person statistics', + description: 'Retrieve statistics about a specific person.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getStatistics(auth, id); } @@ -101,6 +142,11 @@ export class PersonController { @Get(':id/thumbnail') @FileResponse() @Authenticated({ permission: Permission.PersonRead }) + @Endpoint({ + summary: 'Get person thumbnail', + description: 'Retrieve the thumbnail file for a person.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async getPersonThumbnail( @Res() res: Response, @Next() next: NextFunction, @@ -112,6 +158,11 @@ export class PersonController { @Put(':id/reassign') @Authenticated({ permission: Permission.PersonReassign }) + @Endpoint({ + summary: 'Reassign faces', + description: 'Bulk reassign a list of faces to a different person.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) reassignFaces( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -123,6 +174,11 @@ export class PersonController { @Post(':id/merge') @Authenticated({ permission: Permission.PersonMerge }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Merge people', + description: 'Merge a list of people into the person specified in the path parameter.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) mergePerson( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts new file mode 100644 index 0000000000..a0a4d14b0b --- /dev/null +++ b/server/src/controllers/plugin.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { PluginResponseDto } from 'src/dtos/plugin.dto'; +import { Permission } from 'src/enum'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { PluginService } from 'src/services/plugin.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Plugins') +@Controller('plugins') +export class PluginController { + constructor(private service: PluginService) {} + + @Get() + @Authenticated({ permission: Permission.PluginRead }) + @Endpoint({ + summary: 'List all plugins', + description: 'Retrieve a list of plugins available to the authenticated user.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getPlugins(): Promise { + return this.service.getAll(); + } + + @Get(':id') + @Authenticated({ permission: Permission.PluginRead }) + @Endpoint({ + summary: 'Retrieve a plugin', + description: 'Retrieve information about a specific plugin by its ID.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getPlugin(@Param() { id }: UUIDParamDto): Promise { + return this.service.get(id); + } +} diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index f9aa6bce81..439a7a5118 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PersonResponseDto } from 'src/dtos/person.dto'; @@ -17,11 +18,11 @@ import { SmartSearchDto, StatisticsSearchDto, } from 'src/dtos/search.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { SearchService } from 'src/services/search.service'; -@ApiTags('Search') +@ApiTags(ApiTag.Search) @Controller('search') export class SearchController { constructor(private service: SearchService) {} @@ -29,6 +30,11 @@ export class SearchController { @Post('metadata') @Authenticated({ permission: Permission.AssetRead }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Search assets by metadata', + description: 'Search for assets based on various metadata criteria.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchAssets(@Auth() auth: AuthDto, @Body() dto: MetadataSearchDto): Promise { return this.service.searchMetadata(auth, dto); } @@ -36,6 +42,11 @@ export class SearchController { @Post('statistics') @Authenticated({ permission: Permission.AssetStatistics }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Search asset statistics', + description: 'Retrieve statistical data about assets based on search criteria, such as the total matching count.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchAssetStatistics(@Auth() auth: AuthDto, @Body() dto: StatisticsSearchDto): Promise { return this.service.searchStatistics(auth, dto); } @@ -43,6 +54,11 @@ export class SearchController { @Post('random') @Authenticated({ permission: Permission.AssetRead }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Search random assets', + description: 'Retrieve a random selection of assets based on the provided criteria.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { return this.service.searchRandom(auth, dto); } @@ -50,6 +66,11 @@ export class SearchController { @Post('large-assets') @Authenticated({ permission: Permission.AssetRead }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Search large assets', + description: 'Search for assets that are considered large based on specified criteria.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchLargeAssets(@Auth() auth: AuthDto, @Query() dto: LargeAssetSearchDto): Promise { return this.service.searchLargeAssets(auth, dto); } @@ -57,36 +78,68 @@ export class SearchController { @Post('smart') @Authenticated({ permission: Permission.AssetRead }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Smart asset search', + description: 'Perform a smart search for assets by using machine learning vectors to determine relevance.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchSmart(@Auth() auth: AuthDto, @Body() dto: SmartSearchDto): Promise { return this.service.searchSmart(auth, dto); } @Get('explore') @Authenticated({ permission: Permission.AssetRead }) + @Endpoint({ + summary: 'Retrieve explore data', + description: 'Retrieve data for the explore section, such as popular people and places.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getExploreData(@Auth() auth: AuthDto): Promise { return this.service.getExploreData(auth); } @Get('person') @Authenticated({ permission: Permission.PersonRead }) + @Endpoint({ + summary: 'Search people', + description: 'Search for people by name.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise { return this.service.searchPerson(auth, dto); } @Get('places') @Authenticated({ permission: Permission.AssetRead }) + @Endpoint({ + summary: 'Search places', + description: 'Search for places by name.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchPlaces(@Query() dto: SearchPlacesDto): Promise { return this.service.searchPlaces(dto); } @Get('cities') @Authenticated({ permission: Permission.AssetRead }) + @Endpoint({ + summary: 'Retrieve assets by city', + description: + 'Retrieve a list of assets with each asset belonging to a different city. This endpoint is used on the places pages to show a single thumbnail for each city the user has assets in.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAssetsByCity(@Auth() auth: AuthDto): Promise { return this.service.getAssetsByCity(auth); } @Get('suggestions') @Authenticated({ permission: Permission.AssetRead }) + @Endpoint({ + summary: 'Retrieve search suggestions', + description: + 'Retrieve search suggestions based on partial input. This endpoint is used for typeahead search features.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise { // TODO fix open api generation to indicate that results can be nullable return this.service.getSearchSuggestions(auth, dto) as Promise; diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index f9a340eb31..ffcb50c674 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Put } from '@nestjs/common'; import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -15,13 +16,13 @@ import { ServerVersionResponseDto, } from 'src/dtos/server.dto'; import { VersionCheckStateResponseDto } from 'src/dtos/system-metadata.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { ServerService } from 'src/services/server.service'; import { SystemMetadataService } from 'src/services/system-metadata.service'; import { VersionService } from 'src/services/version.service'; -@ApiTags('Server') +@ApiTags(ApiTag.Server) @Controller('server') export class ServerController { constructor( @@ -32,59 +33,114 @@ export class ServerController { @Get('about') @Authenticated({ permission: Permission.ServerAbout }) + @Endpoint({ + summary: 'Get server information', + description: 'Retrieve a list of information about the server.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAboutInfo(): Promise { return this.service.getAboutInfo(); } @Get('apk-links') @Authenticated({ permission: Permission.ServerApkLinks }) + @Endpoint({ + summary: 'Get APK links', + description: 'Retrieve links to the APKs for the current server version.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getApkLinks(): ServerApkLinksDto { return this.service.getApkLinks(); } @Get('storage') @Authenticated({ permission: Permission.ServerStorage }) + @Endpoint({ + summary: 'Get storage', + description: 'Retrieve the current storage utilization information of the server.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getStorage(): Promise { return this.service.getStorage(); } @Get('ping') + @Endpoint({ + summary: 'Ping', + description: 'Pong', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) pingServer(): ServerPingResponse { return this.service.ping(); } @Get('version') + @Endpoint({ + summary: 'Get server version', + description: 'Retrieve the current server version in semantic versioning (semver) format.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getServerVersion(): ServerVersionResponseDto { return this.versionService.getVersion(); } @Get('version-history') + @Endpoint({ + summary: 'Get version history', + description: 'Retrieve a list of past versions the server has been on.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getVersionHistory(): Promise { return this.versionService.getVersionHistory(); } @Get('features') + @Endpoint({ + summary: 'Get features', + description: 'Retrieve available features supported by this server.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getServerFeatures(): Promise { return this.service.getFeatures(); } @Get('theme') + @Endpoint({ + summary: 'Get theme', + description: 'Retrieve the custom CSS, if existent.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getTheme(): Promise { return this.service.getTheme(); } @Get('config') + @Endpoint({ + summary: 'Get config', + description: 'Retrieve the current server configuration.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getServerConfig(): Promise { return this.service.getSystemConfig(); } @Get('statistics') @Authenticated({ permission: Permission.ServerStatistics, admin: true }) + @Endpoint({ + summary: 'Get statistics', + description: 'Retrieve statistics about the entire Immich instance such as asset counts.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getServerStatistics(): Promise { return this.service.getStatistics(); } @Get('media-types') + @Endpoint({ + summary: 'Get supported media types', + description: 'Retrieve all media types supported by the server.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getSupportedMediaTypes(): ServerMediaTypesResponseDto { return this.service.getSupportedMediaTypes(); } @@ -92,12 +148,22 @@ export class ServerController { @Get('license') @Authenticated({ permission: Permission.ServerLicenseRead, admin: true }) @ApiNotFoundResponse() + @Endpoint({ + summary: 'Get product key', + description: 'Retrieve information about whether the server currently has a product key registered.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getServerLicense(): Promise { return this.service.getLicense(); } @Put('license') @Authenticated({ permission: Permission.ServerLicenseUpdate, admin: true }) + @Endpoint({ + summary: 'Set server product key', + description: 'Validate and set the server product key if successful.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) setServerLicense(@Body() license: LicenseKeyDto): Promise { return this.service.setLicense(license); } @@ -105,12 +171,22 @@ export class ServerController { @Delete('license') @Authenticated({ permission: Permission.ServerLicenseDelete, admin: true }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete server product key', + description: 'Delete the currently set server product key.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteServerLicense(): Promise { return this.service.deleteLicense(); } @Get('version-check') @Authenticated({ permission: Permission.ServerVersionCheck }) + @Endpoint({ + summary: 'Get version check status', + description: 'Retrieve information about the last time the version check ran.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getVersionCheck(): Promise { return this.systemMetadataService.getVersionCheckState(); } diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index cbe8158fee..d21cca3a83 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -1,25 +1,36 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, SessionUpdateDto } from 'src/dtos/session.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { SessionService } from 'src/services/session.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Sessions') +@ApiTags(ApiTag.Sessions) @Controller('sessions') export class SessionController { constructor(private service: SessionService) {} @Post() @Authenticated({ permission: Permission.SessionCreate }) + @Endpoint({ + summary: 'Create a session', + description: 'Create a session as a child to the current session. This endpoint is used for casting.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createSession(@Auth() auth: AuthDto, @Body() dto: SessionCreateDto): Promise { return this.service.create(auth, dto); } @Get() @Authenticated({ permission: Permission.SessionRead }) + @Endpoint({ + summary: 'Retrieve sessions', + description: 'Retrieve a list of sessions for the user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getSessions(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @@ -27,12 +38,22 @@ export class SessionController { @Delete() @Authenticated({ permission: Permission.SessionDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete all sessions', + description: 'Delete all sessions for the user. This will not delete the current session.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteAllSessions(@Auth() auth: AuthDto): Promise { return this.service.deleteAll(auth); } @Put(':id') @Authenticated({ permission: Permission.SessionUpdate }) + @Endpoint({ + summary: 'Update a session', + description: 'Update a specific session identified by id.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateSession( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -44,6 +65,11 @@ export class SessionController { @Delete(':id') @Authenticated({ permission: Permission.SessionDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a session', + description: 'Delete a specific session by id.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } @@ -51,6 +77,11 @@ export class SessionController { @Post(':id/lock') @Authenticated({ permission: Permission.SessionLock }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Lock a session', + description: 'Lock a specific session by id.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) lockSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.lock(auth, id); } diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index ef0a93e012..8875127a25 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -15,6 +15,7 @@ import { } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -25,26 +26,36 @@ import { SharedLinkResponseDto, SharedLinkSearchDto, } from 'src/dtos/shared-link.dto'; -import { ImmichCookie, Permission } from 'src/enum'; +import { ApiTag, ImmichCookie, Permission } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { LoginDetails } from 'src/services/auth.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { respondWithCookie } from 'src/utils/response'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Shared Links') +@ApiTags(ApiTag.SharedLinks) @Controller('shared-links') export class SharedLinkController { constructor(private service: SharedLinkService) {} @Get() @Authenticated({ permission: Permission.SharedLinkRead }) + @Endpoint({ + summary: 'Retrieve all shared links', + description: 'Retrieve a list of all shared links.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAllSharedLinks(@Auth() auth: AuthDto, @Query() dto: SharedLinkSearchDto): Promise { return this.service.getAll(auth, dto); } @Get('me') @Authenticated({ sharedLink: true }) + @Endpoint({ + summary: 'Retrieve current shared link', + description: 'Retrieve the current shared link associated with authentication method.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async getMySharedLink( @Auth() auth: AuthDto, @Query() dto: SharedLinkPasswordDto, @@ -65,18 +76,33 @@ export class SharedLinkController { @Get(':id') @Authenticated({ permission: Permission.SharedLinkRead }) + @Endpoint({ + summary: 'Retrieve a shared link', + description: 'Retrieve a specific shared link by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getSharedLinkById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Post() @Authenticated({ permission: Permission.SharedLinkCreate }) + @Endpoint({ + summary: 'Create a shared link', + description: 'Create a new shared link.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createSharedLink(@Auth() auth: AuthDto, @Body() dto: SharedLinkCreateDto) { return this.service.create(auth, dto); } @Patch(':id') @Authenticated({ permission: Permission.SharedLinkUpdate }) + @Endpoint({ + summary: 'Update a shared link', + description: 'Update an existing shared link by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateSharedLink( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -88,12 +114,23 @@ export class SharedLinkController { @Delete(':id') @Authenticated({ permission: Permission.SharedLinkDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a shared link', + description: 'Delete a specific shared link by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } @Put(':id/assets') @Authenticated({ sharedLink: true }) + @Endpoint({ + summary: 'Add assets to a shared link', + description: + 'Add assets to a specific shared link by its ID. This endpoint is only relevant for shared link of type individual.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) addSharedLinkAssets( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -104,6 +141,12 @@ export class SharedLinkController { @Delete(':id/assets') @Authenticated({ sharedLink: true }) + @Endpoint({ + summary: 'Remove assets from a shared link', + description: + 'Remove assets from a specific shared link by its ID. This endpoint is only relevant for shared link of type individual.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) removeSharedLinkAssets( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/stack.controller.ts b/server/src/controllers/stack.controller.ts index 6acd4abc24..b35b49c786 100644 --- a/server/src/controllers/stack.controller.ts +++ b/server/src/controllers/stack.controller.ts @@ -1,26 +1,38 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto } from 'src/dtos/stack.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { StackService } from 'src/services/stack.service'; import { UUIDAssetIDParamDto, UUIDParamDto } from 'src/validation'; -@ApiTags('Stacks') +@ApiTags(ApiTag.Stacks) @Controller('stacks') export class StackController { constructor(private service: StackService) {} @Get() @Authenticated({ permission: Permission.StackRead }) + @Endpoint({ + summary: 'Retrieve stacks', + description: 'Retrieve a list of stacks.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchStacks(@Auth() auth: AuthDto, @Query() query: StackSearchDto): Promise { return this.service.search(auth, query); } @Post() @Authenticated({ permission: Permission.StackCreate }) + @Endpoint({ + summary: 'Create a stack', + description: + 'Create a new stack by providing a name and a list of asset IDs to include in the stack. If any of the provided asset IDs are primary assets of an existing stack, the existing stack will be merged into the newly created stack.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createStack(@Auth() auth: AuthDto, @Body() dto: StackCreateDto): Promise { return this.service.create(auth, dto); } @@ -28,18 +40,33 @@ export class StackController { @Delete() @Authenticated({ permission: Permission.StackDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete stacks', + description: 'Delete multiple stacks by providing a list of stack IDs.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteStacks(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { return this.service.deleteAll(auth, dto); } @Get(':id') @Authenticated({ permission: Permission.StackRead }) + @Endpoint({ + summary: 'Retrieve a stack', + description: 'Retrieve a specific stack by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') @Authenticated({ permission: Permission.StackUpdate }) + @Endpoint({ + summary: 'Update a stack', + description: 'Update an existing stack by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateStack( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -51,6 +78,11 @@ export class StackController { @Delete(':id') @Authenticated({ permission: Permission.StackDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a stack', + description: 'Delete a specific stack by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } @@ -58,6 +90,11 @@ export class StackController { @Delete(':id/assets/:assetId') @Authenticated({ permission: Permission.StackUpdate }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Remove an asset from a stack', + description: 'Remove a specific asset from a stack by providing the stack ID and asset ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) removeAssetFromStack(@Auth() auth: AuthDto, @Param() dto: UUIDAssetIDParamDto): Promise { return this.service.removeAsset(auth, dto); } diff --git a/server/src/controllers/sync.controller.ts b/server/src/controllers/sync.controller.ts index 61432e43e3..de94738f73 100644 --- a/server/src/controllers/sync.controller.ts +++ b/server/src/controllers/sync.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -12,12 +13,12 @@ import { SyncAckSetDto, SyncStreamDto, } from 'src/dtos/sync.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { SyncService } from 'src/services/sync.service'; -@ApiTags('Sync') +@ApiTags(ApiTag.Sync) @Controller('sync') export class SyncController { constructor( @@ -28,6 +29,11 @@ export class SyncController { @Post('full-sync') @Authenticated() @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Get full sync for user', + description: 'Retrieve all assets for a full synchronization for the authenticated user.', + history: new HistoryBuilder().added('v1').deprecated('v2'), + }) getFullSyncForUser(@Auth() auth: AuthDto, @Body() dto: AssetFullSyncDto): Promise { return this.service.getFullSync(auth, dto); } @@ -35,6 +41,11 @@ export class SyncController { @Post('delta-sync') @Authenticated() @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Get delta sync for user', + description: 'Retrieve changed assets since the last sync for the authenticated user.', + history: new HistoryBuilder().added('v1').deprecated('v2'), + }) getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise { return this.service.getDeltaSync(auth, dto); } @@ -43,6 +54,12 @@ export class SyncController { @Authenticated({ permission: Permission.SyncStream }) @Header('Content-Type', 'application/jsonlines+json') @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Stream sync changes', + description: + 'Retrieve a JSON lines streamed response of changes for synchronization. This endpoint is used by the mobile app to efficiently stay up to date with changes.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async getSyncStream(@Auth() auth: AuthDto, @Res() res: Response, @Body() dto: SyncStreamDto) { try { await this.service.stream(auth, res, dto); @@ -54,6 +71,11 @@ export class SyncController { @Get('ack') @Authenticated({ permission: Permission.SyncCheckpointRead }) + @Endpoint({ + summary: 'Retrieve acknowledgements', + description: 'Retrieve the synchronization acknowledgments for the current session.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getSyncAck(@Auth() auth: AuthDto): Promise { return this.service.getAcks(auth); } @@ -61,6 +83,12 @@ export class SyncController { @Post('ack') @Authenticated({ permission: Permission.SyncCheckpointUpdate }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Acknowledge changes', + description: + 'Send a list of synchronization acknowledgements to confirm that the latest changes have been received.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) sendSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckSetDto) { return this.service.setAcks(auth, dto); } @@ -68,6 +96,11 @@ export class SyncController { @Delete('ack') @Authenticated({ permission: Permission.SyncCheckpointDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete acknowledgements', + description: 'Delete specific synchronization acknowledgments.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckDeleteDto): Promise { return this.service.deleteAcks(auth, dto); } diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index 69117f4d45..6b79b38d98 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -1,12 +1,13 @@ import { Body, Controller, Get, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { SystemConfigService } from 'src/services/system-config.service'; -@ApiTags('System Config') +@ApiTags(ApiTag.SystemConfig) @Controller('system-config') export class SystemConfigController { constructor( @@ -16,24 +17,44 @@ export class SystemConfigController { @Get() @Authenticated({ permission: Permission.SystemConfigRead, admin: true }) + @Endpoint({ + summary: 'Get system configuration', + description: 'Retrieve the current system configuration.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getConfig(): Promise { return this.service.getSystemConfig(); } @Get('defaults') @Authenticated({ permission: Permission.SystemConfigRead, admin: true }) + @Endpoint({ + summary: 'Get system configuration defaults', + description: 'Retrieve the default values for the system configuration.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getConfigDefaults(): SystemConfigDto { return this.service.getDefaults(); } @Put() @Authenticated({ permission: Permission.SystemConfigUpdate, admin: true }) + @Endpoint({ + summary: 'Update system configuration', + description: 'Update the system configuration with a new system configuration.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateConfig(@Body() dto: SystemConfigDto): Promise { return this.service.updateSystemConfig(dto); } @Get('storage-template-options') @Authenticated({ permission: Permission.SystemConfigRead, admin: true }) + @Endpoint({ + summary: 'Get storage template options', + description: 'Retrieve exemplary storage template options.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { return this.storageTemplateService.getStorageTemplateOptions(); } diff --git a/server/src/controllers/system-metadata.controller.ts b/server/src/controllers/system-metadata.controller.ts index d6634e9444..8f73def3f7 100644 --- a/server/src/controllers/system-metadata.controller.ts +++ b/server/src/controllers/system-metadata.controller.ts @@ -1,21 +1,27 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto, VersionCheckStateResponseDto, } from 'src/dtos/system-metadata.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { SystemMetadataService } from 'src/services/system-metadata.service'; -@ApiTags('System Metadata') +@ApiTags(ApiTag.SystemMetadata) @Controller('system-metadata') export class SystemMetadataController { constructor(private service: SystemMetadataService) {} @Get('admin-onboarding') @Authenticated({ permission: Permission.SystemMetadataRead, admin: true }) + @Endpoint({ + summary: 'Retrieve admin onboarding', + description: 'Retrieve the current admin onboarding status.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAdminOnboarding(): Promise { return this.service.getAdminOnboarding(); } @@ -23,18 +29,33 @@ export class SystemMetadataController { @Post('admin-onboarding') @Authenticated({ permission: Permission.SystemMetadataUpdate, admin: true }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Update admin onboarding', + description: 'Update the admin onboarding status.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise { return this.service.updateAdminOnboarding(dto); } @Get('reverse-geocoding-state') @Authenticated({ permission: Permission.SystemMetadataRead, admin: true }) + @Endpoint({ + summary: 'Retrieve reverse geocoding state', + description: 'Retrieve the current state of the reverse geocoding import.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getReverseGeocodingState(): Promise { return this.service.getReverseGeocodingState(); } @Get('version-check-state') @Authenticated({ permission: Permission.SystemMetadataRead, admin: true }) + @Endpoint({ + summary: 'Retrieve version check state', + description: 'Retrieve the current state of the version check process.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getVersionCheckState(): Promise { return this.service.getVersionCheckState(); } diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts index 59915ef2a4..101e89f3a5 100644 --- a/server/src/controllers/tag.controller.ts +++ b/server/src/controllers/tag.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -10,48 +11,78 @@ import { TagUpdateDto, TagUpsertDto, } from 'src/dtos/tag.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TagService } from 'src/services/tag.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Tags') +@ApiTags(ApiTag.Tags) @Controller('tags') export class TagController { constructor(private service: TagService) {} @Post() @Authenticated({ permission: Permission.TagCreate }) + @Endpoint({ + summary: 'Create a tag', + description: 'Create a new tag by providing a name and optional color.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise { return this.service.create(auth, dto); } @Get() @Authenticated({ permission: Permission.TagRead }) + @Endpoint({ + summary: 'Retrieve tags', + description: 'Retrieve a list of all tags.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAllTags(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Put() @Authenticated({ permission: Permission.TagCreate }) + @Endpoint({ + summary: 'Upsert tags', + description: 'Create or update multiple tags in a single request.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise { return this.service.upsert(auth, dto); } @Put('assets') @Authenticated({ permission: Permission.TagAsset }) + @Endpoint({ + summary: 'Tag assets', + description: 'Add multiple tags to multiple assets in a single request.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) bulkTagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise { return this.service.bulkTagAssets(auth, dto); } @Get(':id') @Authenticated({ permission: Permission.TagRead }) + @Endpoint({ + summary: 'Retrieve a tag', + description: 'Retrieve a specific tag by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') @Authenticated({ permission: Permission.TagUpdate }) + @Endpoint({ + summary: 'Update a tag', + description: 'Update an existing tag identified by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise { return this.service.update(auth, id, dto); } @@ -59,12 +90,22 @@ export class TagController { @Delete(':id') @Authenticated({ permission: Permission.TagDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a tag', + description: 'Delete a specific tag by its ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } @Put(':id/assets') @Authenticated({ permission: Permission.TagAsset }) + @Endpoint({ + summary: 'Tag assets', + description: 'Add a tag to all the specified assets.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) tagAssets( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -75,6 +116,11 @@ export class TagController { @Delete(':id/assets') @Authenticated({ permission: Permission.TagAsset }) + @Endpoint({ + summary: 'Untag assets', + description: 'Remove a tag from all the specified assets.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) untagAssets( @Auth() auth: AuthDto, @Body() dto: BulkIdsDto, diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts index 8cab840ec8..f1789a79e8 100644 --- a/server/src/controllers/timeline.controller.ts +++ b/server/src/controllers/timeline.controller.ts @@ -1,18 +1,24 @@ import { Controller, Get, Header, Query } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { TimeBucketAssetDto, TimeBucketAssetResponseDto, TimeBucketDto } from 'src/dtos/time-bucket.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TimelineService } from 'src/services/timeline.service'; -@ApiTags('Timeline') +@ApiTags(ApiTag.Timeline) @Controller('timeline') export class TimelineController { constructor(private service: TimelineService) {} @Get('buckets') @Authenticated({ permission: Permission.AssetRead, sharedLink: true }) + @Endpoint({ + summary: 'Get time buckets', + description: 'Retrieve a list of all minimal time buckets.', + history: new HistoryBuilder().added('v1').internal('v1'), + }) getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto) { return this.service.getTimeBuckets(auth, dto); } @@ -21,6 +27,11 @@ export class TimelineController { @Authenticated({ permission: Permission.AssetRead, sharedLink: true }) @ApiOkResponse({ type: TimeBucketAssetResponseDto }) @Header('Content-Type', 'application/json') + @Endpoint({ + summary: 'Get time bucket', + description: 'Retrieve a string of all asset ids in a given time bucket.', + history: new HistoryBuilder().added('v1').internal('v1'), + }) getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) { return this.service.getTimeBucket(auth, dto); } diff --git a/server/src/controllers/trash.controller.ts b/server/src/controllers/trash.controller.ts index eaf489f104..ec37c63ecc 100644 --- a/server/src/controllers/trash.controller.ts +++ b/server/src/controllers/trash.controller.ts @@ -1,13 +1,14 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TrashResponseDto } from 'src/dtos/trash.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TrashService } from 'src/services/trash.service'; -@ApiTags('Trash') +@ApiTags(ApiTag.Trash) @Controller('trash') export class TrashController { constructor(private service: TrashService) {} @@ -15,6 +16,11 @@ export class TrashController { @Post('empty') @Authenticated({ permission: Permission.AssetDelete }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Empty trash', + description: 'Permanently delete all items in the trash.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) emptyTrash(@Auth() auth: AuthDto): Promise { return this.service.empty(auth); } @@ -22,6 +28,11 @@ export class TrashController { @Post('restore') @Authenticated({ permission: Permission.AssetDelete }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Restore trash', + description: 'Restore all items in the trash.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) restoreTrash(@Auth() auth: AuthDto): Promise { return this.service.restore(auth); } @@ -29,6 +40,11 @@ export class TrashController { @Post('restore/assets') @Authenticated({ permission: Permission.AssetDelete }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Restore assets', + description: 'Restore specific assets from the trash.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { return this.service.restoreAssets(auth, dto); } diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index 25a4691b75..6dd919e193 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto } from 'src/dtos/session.dto'; @@ -11,36 +12,56 @@ import { UserAdminSearchDto, UserAdminUpdateDto, } from 'src/dtos/user.dto'; -import { Permission } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { UserAdminService } from 'src/services/user-admin.service'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Users (admin)') +@ApiTags(ApiTag.UsersAdmin) @Controller('admin/users') export class UserAdminController { constructor(private service: UserAdminService) {} @Get() @Authenticated({ permission: Permission.AdminUserRead, admin: true }) + @Endpoint({ + summary: 'Search users', + description: 'Search for users.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise { return this.service.search(auth, dto); } @Post() @Authenticated({ permission: Permission.AdminUserCreate, admin: true }) + @Endpoint({ + summary: 'Create a user', + description: 'Create a new user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise { return this.service.create(createUserDto); } @Get(':id') @Authenticated({ permission: Permission.AdminUserRead, admin: true }) + @Endpoint({ + summary: 'Retrieve a user', + description: 'Retrieve a specific user by their ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') @Authenticated({ permission: Permission.AdminUserUpdate, admin: true }) + @Endpoint({ + summary: 'Update a user', + description: 'Update an existing user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateUserAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -51,6 +72,11 @@ export class UserAdminController { @Delete(':id') @Authenticated({ permission: Permission.AdminUserDelete, admin: true }) + @Endpoint({ + summary: 'Delete a user', + description: 'Delete a user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteUserAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -61,12 +87,22 @@ export class UserAdminController { @Get(':id/sessions') @Authenticated({ permission: Permission.AdminSessionRead, admin: true }) + @Endpoint({ + summary: 'Retrieve user sessions', + description: 'Retrieve all sessions for a specific user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getUserSessionsAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getSessions(auth, id); } @Get(':id/statistics') @Authenticated({ permission: Permission.AdminUserRead, admin: true }) + @Endpoint({ + summary: 'Retrieve user statistics', + description: 'Retrieve asset statistics for a specific user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getUserStatisticsAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -77,12 +113,22 @@ export class UserAdminController { @Get(':id/preferences') @Authenticated({ permission: Permission.AdminUserRead, admin: true }) + @Endpoint({ + summary: 'Retrieve user preferences', + description: 'Retrieve the preferences of a specific user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getPreferences(auth, id); } @Put(':id/preferences') @Authenticated({ permission: Permission.AdminUserUpdate, admin: true }) + @Endpoint({ + summary: 'Update user preferences', + description: 'Update the preferences of a specific user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateUserPreferencesAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -94,6 +140,11 @@ export class UserAdminController { @Post(':id/restore') @Authenticated({ permission: Permission.AdminUserDelete, admin: true }) @HttpCode(HttpStatus.OK) + @Endpoint({ + summary: 'Restore a deleted user', + description: 'Restore a previously deleted user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.restore(auth, id); } diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index d72b088c54..9c0dd3db7a 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -15,13 +15,14 @@ import { } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; -import { Permission, RouteKey } from 'src/enum'; +import { ApiTag, Permission, RouteKey } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -29,7 +30,7 @@ import { UserService } from 'src/services/user.service'; import { sendFile } from 'src/utils/file'; import { UUIDParamDto } from 'src/validation'; -@ApiTags('Users') +@ApiTags(ApiTag.Users) @Controller(RouteKey.User) export class UserController { constructor( @@ -39,30 +40,55 @@ export class UserController { @Get() @Authenticated({ permission: Permission.UserRead }) + @Endpoint({ + summary: 'Get all users', + description: 'Retrieve a list of all users on the server.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) searchUsers(@Auth() auth: AuthDto): Promise { return this.service.search(auth); } @Get('me') @Authenticated({ permission: Permission.UserRead }) + @Endpoint({ + summary: 'Get current user', + description: 'Retrieve information about the user making the API request.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getMyUser(@Auth() auth: AuthDto): Promise { return this.service.getMe(auth); } @Put('me') @Authenticated({ permission: Permission.UserUpdate }) + @Endpoint({ + summary: 'Update current user', + description: 'Update the current user making teh API request.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise { return this.service.updateMe(auth, dto); } @Get('me/preferences') @Authenticated({ permission: Permission.UserPreferenceRead }) + @Endpoint({ + summary: 'Get my preferences', + description: 'Retrieve the preferences for the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getMyPreferences(@Auth() auth: AuthDto): Promise { return this.service.getMyPreferences(auth); } @Put('me/preferences') @Authenticated({ permission: Permission.UserPreferenceUpdate }) + @Endpoint({ + summary: 'Update my preferences', + description: 'Update the preferences of the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) updateMyPreferences( @Auth() auth: AuthDto, @Body() dto: UserPreferencesUpdateDto, @@ -72,12 +98,22 @@ export class UserController { @Get('me/license') @Authenticated({ permission: Permission.UserLicenseRead }) + @Endpoint({ + summary: 'Retrieve user product key', + description: 'Retrieve information about whether the current user has a registered product key.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getUserLicense(@Auth() auth: AuthDto): Promise { return this.service.getLicense(auth); } @Put('me/license') @Authenticated({ permission: Permission.UserLicenseUpdate }) + @Endpoint({ + summary: 'Set user product key', + description: 'Register a product key for the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async setUserLicense(@Auth() auth: AuthDto, @Body() license: LicenseKeyDto): Promise { return this.service.setLicense(auth, license); } @@ -85,18 +121,33 @@ export class UserController { @Delete('me/license') @Authenticated({ permission: Permission.UserLicenseDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete user product key', + description: 'Delete the registered product key for the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async deleteUserLicense(@Auth() auth: AuthDto): Promise { await this.service.deleteLicense(auth); } @Get('me/onboarding') @Authenticated({ permission: Permission.UserOnboardingRead }) + @Endpoint({ + summary: 'Retrieve user onboarding', + description: 'Retrieve the onboarding status of the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getUserOnboarding(@Auth() auth: AuthDto): Promise { return this.service.getOnboarding(auth); } @Put('me/onboarding') @Authenticated({ permission: Permission.UserOnboardingUpdate }) + @Endpoint({ + summary: 'Update user onboarding', + description: 'Update the onboarding status of the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async setUserOnboarding(@Auth() auth: AuthDto, @Body() Onboarding: OnboardingDto): Promise { return this.service.setOnboarding(auth, Onboarding); } @@ -104,12 +155,22 @@ export class UserController { @Delete('me/onboarding') @Authenticated({ permission: Permission.UserOnboardingDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete user onboarding', + description: 'Delete the onboarding status of the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async deleteUserOnboarding(@Auth() auth: AuthDto): Promise { await this.service.deleteOnboarding(auth); } @Get(':id') @Authenticated({ permission: Permission.UserRead }) + @Endpoint({ + summary: 'Retrieve a user', + description: 'Retrieve a specific user by their ID.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getUser(@Param() { id }: UUIDParamDto): Promise { return this.service.get(id); } @@ -119,6 +180,11 @@ export class UserController { @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') @ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto }) + @Endpoint({ + summary: 'Create user profile image', + description: 'Upload and set a new profile image for the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) createProfileImage( @Auth() auth: AuthDto, @UploadedFile() fileInfo: Express.Multer.File, @@ -129,6 +195,11 @@ export class UserController { @Delete('profile-image') @Authenticated({ permission: Permission.UserProfileImageDelete }) @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete user profile image', + description: 'Delete the profile image of the current user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) deleteProfileImage(@Auth() auth: AuthDto): Promise { return this.service.deleteProfileImage(auth); } @@ -136,6 +207,11 @@ export class UserController { @Get(':id/profile-image') @FileResponse() @Authenticated({ permission: Permission.UserProfileImageRead }) + @Endpoint({ + summary: 'Retrieve user profile image', + description: 'Retrieve the profile image file for a user.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) { await sendFile(res, next, () => this.service.getProfileImage(id), this.logger); } diff --git a/server/src/controllers/view.controller.ts b/server/src/controllers/view.controller.ts index b5e281e093..8a977e15bc 100644 --- a/server/src/controllers/view.controller.ts +++ b/server/src/controllers/view.controller.ts @@ -1,23 +1,35 @@ import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiTag } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { ViewService } from 'src/services/view.service'; -@ApiTags('View') +@ApiTags(ApiTag.Views) @Controller('view') export class ViewController { constructor(private service: ViewService) {} @Get('folder/unique-paths') @Authenticated() + @Endpoint({ + summary: 'Retrieve unique paths', + description: 'Retrieve a list of unique folder paths from asset original paths.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getUniqueOriginalPaths(@Auth() auth: AuthDto): Promise { return this.service.getUniqueOriginalPaths(auth); } @Get('folder') @Authenticated() + @Endpoint({ + summary: 'Retrieve assets by original path', + description: 'Retrieve assets that are children of a specific folder.', + history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + }) getAssetsByOriginalPath(@Auth() auth: AuthDto, @Query('path') path: string): Promise { return this.service.getAssetsByOriginalPath(auth, path); } diff --git a/server/src/controllers/workflow.controller.ts b/server/src/controllers/workflow.controller.ts new file mode 100644 index 0000000000..e07b6443f4 --- /dev/null +++ b/server/src/controllers/workflow.controller.ts @@ -0,0 +1,76 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { WorkflowService } from 'src/services/workflow.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Workflows') +@Controller('workflows') +export class WorkflowController { + constructor(private service: WorkflowService) {} + + @Post() + @Authenticated({ permission: Permission.WorkflowCreate }) + @Endpoint({ + summary: 'Create a workflow', + description: 'Create a new workflow, the workflow can also be created with empty filters and actions.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise { + return this.service.create(auth, dto); + } + + @Get() + @Authenticated({ permission: Permission.WorkflowRead }) + @Endpoint({ + summary: 'List all workflows', + description: 'Retrieve a list of workflows available to the authenticated user.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getWorkflows(@Auth() auth: AuthDto): Promise { + return this.service.getAll(auth); + } + + @Get(':id') + @Authenticated({ permission: Permission.WorkflowRead }) + @Endpoint({ + summary: 'Retrieve a workflow', + description: 'Retrieve information about a specific workflow by its ID.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.WorkflowUpdate }) + @Endpoint({ + summary: 'Update a workflow', + description: + 'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + updateWorkflow( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: WorkflowUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @Authenticated({ permission: Permission.WorkflowDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a workflow', + description: 'Delete a workflow by its ID.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/cores/storage.core.spec.ts b/server/src/cores/storage.core.spec.ts index ed446f9259..08e410bbe3 100644 --- a/server/src/cores/storage.core.spec.ts +++ b/server/src/cores/storage.core.spec.ts @@ -2,8 +2,6 @@ import { StorageCore } from 'src/cores/storage.core'; import { vitest } from 'vitest'; vitest.mock('src/constants', () => ({ - ADDED_IN_PREFIX: 'This property was added in ', - DEPRECATED_IN_PREFIX: 'This property was deprecated in ', IWorker: 'IWorker', })); diff --git a/server/src/database.ts b/server/src/database.ts index f60c2c228c..4aa69127ff 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -7,6 +7,8 @@ import { AssetVisibility, MemoryType, Permission, + PluginContext, + PluginTriggerType, SharedLinkType, SourceType, UserAvatarColor, @@ -14,7 +16,10 @@ import { } from 'src/enum'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; +import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; import { UserMetadataItem } from 'src/types'; +import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types'; export type AuthUser = { id: string; @@ -277,6 +282,45 @@ export type AssetFace = { updateId: string; }; +export type Plugin = Selectable; + +export type PluginFilter = Selectable & { + methodName: string; + title: string; + description: string; + supportedContexts: PluginContext[]; + schema: JSONSchema | null; +}; + +export type PluginAction = Selectable & { + methodName: string; + title: string; + description: string; + supportedContexts: PluginContext[]; + schema: JSONSchema | null; +}; + +export type Workflow = Selectable & { + triggerType: PluginTriggerType; + name: string | null; + description: string; + enabled: boolean; +}; + +export type WorkflowFilter = Selectable & { + workflowId: string; + filterId: string; + filterConfig: FilterConfig | null; + order: number; +}; + +export type WorkflowAction = Selectable & { + workflowId: string; + actionId: string; + actionConfig: ActionConfig | null; + order: number; +}; + const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; const userWithPrefixColumns = [ 'user2.id', @@ -356,7 +400,7 @@ export const columns = { 'asset.stackId', 'asset.libraryId', ], - syncAlbumUser: ['album_user.albumsId as albumId', 'album_user.usersId as userId', 'album_user.role'], + syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'], stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], @@ -418,4 +462,15 @@ export const columns = { 'asset_exif.state', 'asset_exif.timeZone', ], + plugin: [ + 'plugin.id as id', + 'plugin.name as name', + 'plugin.title as title', + 'plugin.description as description', + 'plugin.author as author', + 'plugin.version as version', + 'plugin.wasmPath as wasmPath', + 'plugin.createdAt as createdAt', + 'plugin.updatedAt as updatedAt', + ], } as const; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 8a8e23d880..054bbf8fec 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,8 +1,7 @@ import { SetMetadata, applyDecorators } from '@nestjs/common'; -import { ApiExtension, ApiOperation, ApiOperationOptions, ApiProperty, ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; -import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; -import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; +import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; import { EmitEvent } from 'src/repositories/event.repository'; import { immich_uuid_v7, updated_at } from 'src/schema/functions'; import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools'; @@ -153,39 +152,122 @@ export type JobConfig = { }; export const OnJob = (config: JobConfig) => SetMetadata(MetadataKey.JobConfig, config); -type LifecycleRelease = 'NEXT_RELEASE' | string; -type LifecycleMetadata = { - addedAt?: LifecycleRelease; - deprecatedAt?: LifecycleRelease; -}; +type EndpointOptions = ApiOperationOptions & { history?: HistoryBuilder }; +export const Endpoint = ({ history, ...options }: EndpointOptions) => { + const decorators: MethodDecorator[] = []; + const extensions = history?.getExtensions() ?? {}; -export const EndpointLifecycle = ({ - addedAt, - deprecatedAt, - description, - ...options -}: LifecycleMetadata & ApiOperationOptions) => { - const decorators: MethodDecorator[] = [ApiExtension(LIFECYCLE_EXTENSION, { addedAt, deprecatedAt })]; - if (deprecatedAt) { - decorators.push( - ApiTags('Deprecated'), - ApiOperation({ - deprecated: true, - description: DEPRECATED_IN_PREFIX + deprecatedAt + (description ? `. ${description}` : ''), - ...options, - }), - ); + if (!extensions[ApiCustomExtension.History]) { + console.log(`Missing history for endpoint: ${options.summary}`); } + if (history?.isDeprecated()) { + options.deprecated = true; + decorators.push(ApiTags(ApiTag.Deprecated)); + } + + decorators.push(ApiOperation({ ...options, ...extensions })); + return applyDecorators(...decorators); }; -export const PropertyLifecycle = ({ addedAt, deprecatedAt }: LifecycleMetadata) => { - const decorators: PropertyDecorator[] = []; - decorators.push(ApiProperty({ description: ADDED_IN_PREFIX + addedAt })); - if (deprecatedAt) { - decorators.push(ApiProperty({ deprecated: true, description: DEPRECATED_IN_PREFIX + deprecatedAt })); +type PropertyOptions = ApiPropertyOptions & { history?: HistoryBuilder }; +export const Property = ({ history, ...options }: PropertyOptions) => { + const extensions = history?.getExtensions() ?? {}; + + if (history?.isDeprecated()) { + options.deprecated = true; } - return applyDecorators(...decorators); + return ApiProperty({ ...options, ...extensions }); }; + +type HistoryEntry = { + version: string; + state: ApiState | 'Added' | 'Updated'; + description?: string; + replacementId?: string; +}; + +type DeprecatedOptions = { + /** replacement operationId */ + replacementId?: string; +}; + +type CustomExtensions = { + [ApiCustomExtension.State]?: ApiState; + [ApiCustomExtension.History]?: HistoryEntry[]; +}; + +enum ApiState { + 'Stable' = 'Stable', + 'Alpha' = 'Alpha', + 'Beta' = 'Beta', + 'Internal' = 'Internal', + 'Deprecated' = 'Deprecated', +} +export class HistoryBuilder { + private hasDeprecated = false; + private items: HistoryEntry[] = []; + + added(version: string, description?: string) { + return this.push({ version, state: 'Added', description }); + } + + updated(version: string, description: string) { + return this.push({ version, state: 'Updated', description }); + } + + alpha(version: string) { + return this.push({ version, state: ApiState.Alpha }); + } + + beta(version: string) { + return this.push({ version, state: ApiState.Beta }); + } + + internal(version: string) { + return this.push({ version, state: ApiState.Internal }); + } + + stable(version: string) { + return this.push({ version, state: ApiState.Stable }); + } + + deprecated(version: string, options?: DeprecatedOptions) { + const { replacementId } = options || {}; + this.hasDeprecated = true; + return this.push({ version, state: ApiState.Deprecated, replacementId }); + } + + isDeprecated(): boolean { + return this.hasDeprecated; + } + + getExtensions() { + const extensions: CustomExtensions = {}; + + if (this.items.length > 0) { + extensions[ApiCustomExtension.History] = this.items; + } + + for (const item of this.items.toReversed()) { + if (item.state === 'Added' || item.state === 'Updated') { + continue; + } + + extensions[ApiCustomExtension.State] = item.state; + break; + } + + return extensions; + } + + private push(item: HistoryEntry) { + if (!item.version.startsWith('v')) { + throw new Error(`Version string must start with 'v': received '${JSON.stringify(item)}'`); + } + this.items.push(item); + return this; + } +} diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index b3fecf2f8f..1716c327f3 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Selectable } from 'kysely'; import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database'; -import { PropertyLifecycle } from 'src/decorators'; +import { HistoryBuilder, Property } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto'; import { @@ -48,7 +48,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { deviceId!: string; ownerId!: string; owner?: UserResponseDto; - @PropertyLifecycle({ deprecatedAt: 'v1.106.0' }) + @Property({ history: new HistoryBuilder().added('v1').deprecated('v1') }) libraryId?: string | null; originalPath!: string; originalFileName!: string; @@ -91,7 +91,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { stack?: AssetStackResponseDto | null; duplicateId?: string | null; - @PropertyLifecycle({ deprecatedAt: 'v1.113.0' }) + @Property({ history: new HistoryBuilder().added('v1').deprecated('v1.113.0') }) resized?: boolean; } diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index 3543d8dae9..2a9dd8b662 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -57,6 +57,13 @@ export class EnvDto { @Type(() => Number) IMMICH_MICROSERVICES_METRICS_PORT?: number; + @ValidateBoolean({ optional: true }) + IMMICH_PLUGINS_ENABLED?: boolean; + + @Optional() + @Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' }) + IMMICH_PLUGINS_INSTALL_FOLDER?: string; + @IsInt() @Optional() @Type(() => Number) diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index 5daaeacdd3..794af6e5e0 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -1,99 +1,7 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { JobCommand, ManualJobName, QueueName } from 'src/enum'; -import { ValidateBoolean, ValidateEnum } from 'src/validation'; - -export class JobIdParamDto { - @ValidateEnum({ enum: QueueName, name: 'JobName' }) - id!: QueueName; -} - -export class JobCommandDto { - @ValidateEnum({ enum: JobCommand, name: 'JobCommand' }) - command!: JobCommand; - - @ValidateBoolean({ optional: true }) - force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit -} +import { ManualJobName } from 'src/enum'; +import { ValidateEnum } from 'src/validation'; export class JobCreateDto { @ValidateEnum({ enum: ManualJobName, name: 'ManualJobName' }) name!: ManualJobName; } - -export class JobCountsDto { - @ApiProperty({ type: 'integer' }) - active!: number; - @ApiProperty({ type: 'integer' }) - completed!: number; - @ApiProperty({ type: 'integer' }) - failed!: number; - @ApiProperty({ type: 'integer' }) - delayed!: number; - @ApiProperty({ type: 'integer' }) - waiting!: number; - @ApiProperty({ type: 'integer' }) - paused!: number; -} - -export class QueueStatusDto { - isActive!: boolean; - isPaused!: boolean; -} - -export class JobStatusDto { - @ApiProperty({ type: JobCountsDto }) - jobCounts!: JobCountsDto; - - @ApiProperty({ type: QueueStatusDto }) - queueStatus!: QueueStatusDto; -} - -export class AllJobStatusResponseDto implements Record { - @ApiProperty({ type: JobStatusDto }) - [QueueName.ThumbnailGeneration]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.MetadataExtraction]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.VideoConversion]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.SmartSearch]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.StorageTemplateMigration]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Migration]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.BackgroundTask]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Search]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.DuplicateDetection]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.FaceDetection]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.FacialRecognition]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Sidecar]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Library]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Notification]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.BackupDatabase]!: JobStatusDto; - - @ApiProperty({ type: JobStatusDto }) - [QueueName.Ocr]!: JobStatusDto; -} diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index a79511c73e..8e7320f831 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -4,8 +4,8 @@ import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator'; import { Memory } from 'src/database'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MemoryType } from 'src/enum'; -import { ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; +import { AssetOrderWithRandom, MemoryType } from 'src/enum'; +import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; class MemoryBaseDto { @ValidateBoolean({ optional: true }) @@ -27,6 +27,16 @@ export class MemorySearchDto { @ValidateBoolean({ optional: true }) isSaved?: boolean; + + @IsInt() + @IsPositive() + @Type(() => Number) + @Optional() + @ApiProperty({ type: 'integer', description: 'Number of memories to return' }) + size?: number; + + @ValidateEnum({ enum: AssetOrderWithRandom, name: 'MemorySearchOrder', optional: true }) + order?: AssetOrderWithRandom; } class OnThisDayDto { diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index f9b41627d9..3c90cfdc59 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -4,7 +4,7 @@ import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNeste import { Selectable } from 'kysely'; import { DateTime } from 'luxon'; import { AssetFace, Person } from 'src/database'; -import { PropertyLifecycle } from 'src/decorators'; +import { HistoryBuilder, Property } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { SourceType } from 'src/enum'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; @@ -111,11 +111,11 @@ export class PersonResponseDto { birthDate!: string | null; thumbnailPath!: string; isHidden!: boolean; - @PropertyLifecycle({ addedAt: 'v1.107.0' }) + @Property({ history: new HistoryBuilder().added('v1.107.0').stable('v2') }) updatedAt?: Date; - @PropertyLifecycle({ addedAt: 'v1.126.0' }) + @Property({ history: new HistoryBuilder().added('v1.126.0').stable('v2') }) isFavorite?: boolean; - @PropertyLifecycle({ addedAt: 'v1.126.0' }) + @Property({ history: new HistoryBuilder().added('v1.126.0').stable('v2') }) color?: string; } @@ -216,7 +216,7 @@ export class PeopleResponseDto { people!: PersonResponseDto[]; // TODO: make required after a few versions - @PropertyLifecycle({ addedAt: 'v1.110.0' }) + @Property({ history: new HistoryBuilder().added('v1.110.0').stable('v2') }) hasNextPage?: boolean; } diff --git a/server/src/dtos/plugin-manifest.dto.ts b/server/src/dtos/plugin-manifest.dto.ts new file mode 100644 index 0000000000..fcb3ad4a22 --- /dev/null +++ b/server/src/dtos/plugin-manifest.dto.ts @@ -0,0 +1,110 @@ +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsSemVer, + IsString, + Matches, + ValidateNested, +} from 'class-validator'; +import { PluginContext } from 'src/enum'; +import { JSONSchema } from 'src/types/plugin-schema.types'; +import { ValidateEnum } from 'src/validation'; + +class PluginManifestWasmDto { + @IsString() + @IsNotEmpty() + path!: string; +} + +class PluginManifestFilterDto { + @IsString() + @IsNotEmpty() + methodName!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsArray() + @ArrayMinSize(1) + @IsEnum(PluginContext, { each: true }) + supportedContexts!: PluginContext[]; + + @IsObject() + @IsOptional() + schema?: JSONSchema; +} + +class PluginManifestActionDto { + @IsString() + @IsNotEmpty() + methodName!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsArray() + @ArrayMinSize(1) + @ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true }) + supportedContexts!: PluginContext[]; + + @IsObject() + @IsOptional() + schema?: JSONSchema; +} + +export class PluginManifestDto { + @IsString() + @IsNotEmpty() + @Matches(/^[a-z0-9-]+[a-z0-9]$/, { + message: 'Plugin name must contain only lowercase letters, numbers, and hyphens, and cannot end with a hyphen', + }) + name!: string; + + @IsString() + @IsNotEmpty() + @IsSemVer() + version!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsString() + @IsNotEmpty() + author!: string; + + @ValidateNested() + @Type(() => PluginManifestWasmDto) + wasm!: PluginManifestWasmDto; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PluginManifestFilterDto) + @IsOptional() + filters?: PluginManifestFilterDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PluginManifestActionDto) + @IsOptional() + actions?: PluginManifestActionDto[]; +} diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts new file mode 100644 index 0000000000..ce80eccd65 --- /dev/null +++ b/server/src/dtos/plugin.dto.ts @@ -0,0 +1,77 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { PluginAction, PluginFilter } from 'src/database'; +import { PluginContext } from 'src/enum'; +import type { JSONSchema } from 'src/types/plugin-schema.types'; +import { ValidateEnum } from 'src/validation'; + +export class PluginResponseDto { + id!: string; + name!: string; + title!: string; + description!: string; + author!: string; + version!: string; + createdAt!: string; + updatedAt!: string; + filters!: PluginFilterResponseDto[]; + actions!: PluginActionResponseDto[]; +} + +export class PluginFilterResponseDto { + id!: string; + pluginId!: string; + methodName!: string; + title!: string; + description!: string; + + @ValidateEnum({ enum: PluginContext, name: 'PluginContext' }) + supportedContexts!: PluginContext[]; + schema!: JSONSchema | null; +} + +export class PluginActionResponseDto { + id!: string; + pluginId!: string; + methodName!: string; + title!: string; + description!: string; + + @ValidateEnum({ enum: PluginContext, name: 'PluginContext' }) + supportedContexts!: PluginContext[]; + schema!: JSONSchema | null; +} + +export class PluginInstallDto { + @IsString() + @IsNotEmpty() + manifestPath!: string; +} + +export type MapPlugin = { + id: string; + name: string; + title: string; + description: string; + author: string; + version: string; + wasmPath: string; + createdAt: Date; + updatedAt: Date; + filters: PluginFilter[]; + actions: PluginAction[]; +}; + +export function mapPlugin(plugin: MapPlugin): PluginResponseDto { + return { + id: plugin.id, + name: plugin.name, + title: plugin.title, + description: plugin.description, + author: plugin.author, + version: plugin.version, + createdAt: plugin.createdAt.toISOString(), + updatedAt: plugin.updatedAt.toISOString(), + filters: plugin.filters, + actions: plugin.actions, + }; +} diff --git a/server/src/dtos/queue.dto.ts b/server/src/dtos/queue.dto.ts new file mode 100644 index 0000000000..df00c5cfc2 --- /dev/null +++ b/server/src/dtos/queue.dto.ts @@ -0,0 +1,97 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { QueueCommand, QueueName } from 'src/enum'; +import { ValidateBoolean, ValidateEnum } from 'src/validation'; + +export class QueueNameParamDto { + @ValidateEnum({ enum: QueueName, name: 'QueueName' }) + name!: QueueName; +} + +export class QueueCommandDto { + @ValidateEnum({ enum: QueueCommand, name: 'QueueCommand' }) + command!: QueueCommand; + + @ValidateBoolean({ optional: true }) + force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit +} + +export class QueueStatisticsDto { + @ApiProperty({ type: 'integer' }) + active!: number; + @ApiProperty({ type: 'integer' }) + completed!: number; + @ApiProperty({ type: 'integer' }) + failed!: number; + @ApiProperty({ type: 'integer' }) + delayed!: number; + @ApiProperty({ type: 'integer' }) + waiting!: number; + @ApiProperty({ type: 'integer' }) + paused!: number; +} + +export class QueueStatusDto { + isActive!: boolean; + isPaused!: boolean; +} + +export class QueueResponseDto { + @ApiProperty({ type: QueueStatisticsDto }) + jobCounts!: QueueStatisticsDto; + + @ApiProperty({ type: QueueStatusDto }) + queueStatus!: QueueStatusDto; +} + +export class QueuesResponseDto implements Record { + @ApiProperty({ type: QueueResponseDto }) + [QueueName.ThumbnailGeneration]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.MetadataExtraction]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.VideoConversion]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.SmartSearch]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.StorageTemplateMigration]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Migration]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.BackgroundTask]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Search]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.DuplicateDetection]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.FaceDetection]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.FacialRecognition]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Sidecar]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Library]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Notification]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.BackupDatabase]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Ocr]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Workflow]!: QueueResponseDto; +} diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 591f1acd82..068cd6630c 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { Place } from 'src/database'; -import { PropertyLifecycle } from 'src/decorators'; +import { HistoryBuilder, Property } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetOrder, AssetType, AssetVisibility } from 'src/enum'; @@ -282,7 +282,7 @@ export class SearchSuggestionRequestDto { lensModel?: string; @ValidateBoolean({ optional: true }) - @PropertyLifecycle({ addedAt: 'v111.0.0' }) + @Property({ history: new HistoryBuilder().added('v1.111.0').stable('v2') }) includeNull?: boolean; } diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 6d36e2cc8a..c835073c31 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -224,6 +224,12 @@ class SystemConfigJobDto implements Record @IsObject() @Type(() => JobSettingsDto) [QueueName.Notification]!: JobSettingsDto; + + @ApiProperty({ type: JobSettingsDto }) + @ValidateNested() + @IsObject() + @Type(() => JobSettingsDto) + [QueueName.Workflow]!: JobSettingsDto; } class SystemConfigLibraryScanDto { diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index b258158ae2..452384b423 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -13,6 +13,12 @@ class AvatarUpdate { class MemoriesUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; + + @Optional() + @IsInt() + @IsPositive() + @ApiProperty({ type: 'integer' }) + duration?: number; } class RatingsUpdate { @@ -166,6 +172,9 @@ class RatingsResponse { class MemoriesResponse { enabled: boolean = true; + + @ApiProperty({ type: 'integer' }) + duration: number = 5; } class FoldersResponse { diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts new file mode 100644 index 0000000000..307440945d --- /dev/null +++ b/server/src/dtos/workflow.dto.ts @@ -0,0 +1,120 @@ +import { Type } from 'class-transformer'; +import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { WorkflowAction, WorkflowFilter } from 'src/database'; +import { PluginTriggerType } from 'src/enum'; +import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; +import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation'; + +export class WorkflowFilterItemDto { + @IsUUID() + filterId!: string; + + @IsObject() + @Optional() + filterConfig?: FilterConfig; +} + +export class WorkflowActionItemDto { + @IsUUID() + actionId!: string; + + @IsObject() + @Optional() + actionConfig?: ActionConfig; +} + +export class WorkflowCreateDto { + @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' }) + triggerType!: PluginTriggerType; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @Optional() + description?: string; + + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkflowFilterItemDto) + filters!: WorkflowFilterItemDto[]; + + @ValidateNested({ each: true }) + @Type(() => WorkflowActionItemDto) + actions!: WorkflowActionItemDto[]; +} + +export class WorkflowUpdateDto { + @IsString() + @IsNotEmpty() + @Optional() + name?: string; + + @IsString() + @Optional() + description?: string; + + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkflowFilterItemDto) + @Optional() + filters?: WorkflowFilterItemDto[]; + + @ValidateNested({ each: true }) + @Type(() => WorkflowActionItemDto) + @Optional() + actions?: WorkflowActionItemDto[]; +} + +export class WorkflowResponseDto { + id!: string; + ownerId!: string; + triggerType!: PluginTriggerType; + name!: string | null; + description!: string; + createdAt!: string; + enabled!: boolean; + filters!: WorkflowFilterResponseDto[]; + actions!: WorkflowActionResponseDto[]; +} + +export class WorkflowFilterResponseDto { + id!: string; + workflowId!: string; + filterId!: string; + filterConfig!: FilterConfig | null; + order!: number; +} + +export class WorkflowActionResponseDto { + id!: string; + workflowId!: string; + actionId!: string; + actionConfig!: ActionConfig | null; + order!: number; +} + +export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto { + return { + id: filter.id, + workflowId: filter.workflowId, + filterId: filter.filterId, + filterConfig: filter.filterConfig, + order: filter.order, + }; +} + +export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto { + return { + id: action.id, + workflowId: action.workflowId, + actionId: action.actionId, + actionConfig: action.actionConfig, + order: action.order, + }; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index 0755f75f70..6055ee85bf 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -71,6 +71,14 @@ export enum MemoryType { OnThisDay = 'on_this_day', } +export enum AssetOrderWithRandom { + // Include existing values + Asc = AssetOrder.Asc, + Desc = AssetOrder.Desc, + /** Randomly Ordered */ + Random = 'random', +} + export enum Permission { All = 'all', @@ -169,6 +177,11 @@ export enum Permission { PinCodeUpdate = 'pinCode.update', PinCodeDelete = 'pinCode.delete', + PluginCreate = 'plugin.create', + PluginRead = 'plugin.read', + PluginUpdate = 'plugin.update', + PluginDelete = 'plugin.delete', + ServerAbout = 'server.about', ServerApkLinks = 'server.apkLinks', ServerStorage = 'server.storage', @@ -232,6 +245,11 @@ export enum Permission { UserProfileImageUpdate = 'userProfileImage.update', UserProfileImageDelete = 'userProfileImage.delete', + WorkflowCreate = 'workflow.create', + WorkflowRead = 'workflow.read', + WorkflowUpdate = 'workflow.update', + WorkflowDelete = 'workflow.delete', + AdminUserCreate = 'adminUser.create', AdminUserRead = 'adminUser.read', AdminUserUpdate = 'adminUser.update', @@ -426,6 +444,8 @@ export enum LogLevel { export enum ApiCustomExtension { Permission = 'x-immich-permission', AdminOnly = 'x-immich-admin-only', + History = 'x-immich-history', + State = 'x-immich-state', } export enum MetadataKey { @@ -515,6 +535,7 @@ export enum QueueName { Notification = 'notifications', BackupDatabase = 'backupDatabase', Ocr = 'ocr', + Workflow = 'workflow', } export enum JobName { @@ -591,9 +612,12 @@ export enum JobName { // OCR OcrQueueAll = 'OcrQueueAll', Ocr = 'Ocr', + + // Workflow + WorkflowRun = 'WorkflowRun', } -export enum JobCommand { +export enum QueueCommand { Start = 'start', Pause = 'pause', Resume = 'resume', @@ -763,3 +787,51 @@ export enum CronJob { LibraryScan = 'LibraryScan', NightlyJobs = 'NightlyJobs', } + +export enum ApiTag { + Activities = 'Activities', + Albums = 'Albums', + ApiKeys = 'API keys', + Authentication = 'Authentication', + AuthenticationAdmin = 'Authentication (admin)', + Assets = 'Assets', + Deprecated = 'Deprecated', + Download = 'Download', + Duplicates = 'Duplicates', + Faces = 'Faces', + Jobs = 'Jobs', + Libraries = 'Libraries', + Map = 'Map', + Memories = 'Memories', + Notifications = 'Notifications', + NotificationsAdmin = 'Notifications (admin)', + Partners = 'Partners', + People = 'People', + Plugins = 'Plugins', + Search = 'Search', + Server = 'Server', + Sessions = 'Sessions', + SharedLinks = 'Shared links', + Stacks = 'Stacks', + Sync = 'Sync', + SystemConfig = 'System config', + SystemMetadata = 'System metadata', + Tags = 'Tags', + Timeline = 'Timeline', + Trash = 'Trash', + UsersAdmin = 'Users (admin)', + Users = 'Users', + Views = 'Views', + Workflows = 'Workflows', +} + +export enum PluginContext { + Asset = 'asset', + Album = 'album', + Person = 'person', +} + +export enum PluginTriggerType { + AssetCreate = 'AssetCreate', + PersonRecognized = 'PersonRecognized', +} diff --git a/server/src/plugins.ts b/server/src/plugins.ts new file mode 100644 index 0000000000..0c69483696 --- /dev/null +++ b/server/src/plugins.ts @@ -0,0 +1,37 @@ +import { PluginContext, PluginTriggerType } from 'src/enum'; +import { JSONSchema } from 'src/types/plugin-schema.types'; + +export type PluginTrigger = { + name: string; + type: PluginTriggerType; + description: string; + context: PluginContext; + schema: JSONSchema | null; +}; + +export const pluginTriggers: PluginTrigger[] = [ + { + name: 'Asset Uploaded', + type: PluginTriggerType.AssetCreate, + description: 'Triggered when a new asset is uploaded', + context: PluginContext.Asset, + schema: { + type: 'object', + properties: { + assetType: { + type: 'string', + description: 'Type of the asset', + default: 'ALL', + enum: ['Image', 'Video', 'All'], + }, + }, + }, + }, + { + name: 'Person Recognized', + type: PluginTriggerType.PersonRecognized, + description: 'Triggered when a person is detected in an asset', + context: PluginContext.Person, + schema: null, + }, +]; diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index e98c5c6d98..1239260dce 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -25,8 +25,8 @@ select "album"."id" from "album" - left join "album_user" as "albumUsers" on "albumUsers"."albumsId" = "album"."id" - left join "user" on "user"."id" = "albumUsers"."usersId" + left join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id" + left join "user" on "user"."id" = "albumUsers"."userId" and "user"."deletedAt" is null where "album"."id" in ($1) @@ -52,8 +52,8 @@ select "album"."id" from "album" - left join "album_user" on "album_user"."albumsId" = "album"."id" - left join "user" on "user"."id" = "album_user"."usersId" + left join "album_user" on "album_user"."albumId" = "album"."id" + left join "user" on "user"."id" = "album_user"."userId" and "user"."deletedAt" is null where "album"."id" in ($1) @@ -81,11 +81,11 @@ select "asset"."livePhotoVideoId" from "album" - inner join "album_asset" as "albumAssets" on "album"."id" = "albumAssets"."albumsId" - inner join "asset" on "asset"."id" = "albumAssets"."assetsId" + inner join "album_asset" as "albumAssets" on "album"."id" = "albumAssets"."albumId" + inner join "asset" on "asset"."id" = "albumAssets"."assetId" and "asset"."deletedAt" is null - left join "album_user" as "albumUsers" on "albumUsers"."albumsId" = "album"."id" - left join "user" on "user"."id" = "albumUsers"."usersId" + left join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id" + left join "user" on "user"."id" = "albumUsers"."userId" and "user"."deletedAt" is null cross join "target" where @@ -136,11 +136,11 @@ from "shared_link" left join "album" on "album"."id" = "shared_link"."albumId" and "album"."deletedAt" is null - left join "shared_link_asset" on "shared_link_asset"."sharedLinksId" = "shared_link"."id" - left join "asset" on "asset"."id" = "shared_link_asset"."assetsId" + left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id" + left join "asset" on "asset"."id" = "shared_link_asset"."assetId" and "asset"."deletedAt" is null - left join "album_asset" on "album_asset"."albumsId" = "album"."id" - left join "asset" as "albumAssets" on "albumAssets"."id" = "album_asset"."assetsId" + left join "album_asset" on "album_asset"."albumId" = "album"."id" + left join "asset" as "albumAssets" on "albumAssets"."id" = "album_asset"."assetId" and "albumAssets"."deletedAt" is null where "shared_link"."id" = $1 @@ -243,3 +243,12 @@ from where "partner"."sharedById" in ($1) and "partner"."sharedWithId" = $2 + +-- AccessRepository.workflow.checkOwnerAccess +select + "workflow"."id" +from + "workflow" +where + "workflow"."id" in ($1) + and "workflow"."ownerId" = $2 diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 1f4eda96a1..f62e769a17 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -43,13 +43,13 @@ select from "user" where - "user"."id" = "album_user"."usersId" + "user"."id" = "album_user"."userId" ) as obj ) as "user" from "album_user" where - "album_user"."albumsId" = "album"."id" + "album_user"."albumId" = "album"."id" ) as agg ) as "albumUsers", ( @@ -76,9 +76,9 @@ select from "asset" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" - inner join "album_asset" on "album_asset"."assetsId" = "asset"."id" + inner join "album_asset" on "album_asset"."assetId" = "asset"."id" where - "album_asset"."albumsId" = "album"."id" + "album_asset"."albumId" = "album"."id" and "asset"."deletedAt" is null and "asset"."visibility" in ('archive', 'timeline') order by @@ -134,18 +134,18 @@ select from "user" where - "user"."id" = "album_user"."usersId" + "user"."id" = "album_user"."userId" ) as obj ) as "user" from "album_user" where - "album_user"."albumsId" = "album"."id" + "album_user"."albumId" = "album"."id" ) as agg ) as "albumUsers" from "album" - inner join "album_asset" on "album_asset"."albumsId" = "album"."id" + inner join "album_asset" on "album_asset"."albumId" = "album"."id" where ( "album"."ownerId" = $1 @@ -154,11 +154,11 @@ where from "album_user" where - "album_user"."albumsId" = "album"."id" - and "album_user"."usersId" = $2 + "album_user"."albumId" = "album"."id" + and "album_user"."userId" = $2 ) ) - and "album_asset"."assetsId" = $3 + and "album_asset"."assetId" = $3 and "album"."deletedAt" is null order by "album"."createdAt" desc, @@ -166,7 +166,7 @@ order by -- AlbumRepository.getMetadataForIds select - "album_asset"."albumsId" as "albumId", + "album_asset"."albumId" as "albumId", min( ("asset"."localDateTime" AT TIME ZONE 'UTC'::text)::date ) as "startDate", @@ -177,13 +177,13 @@ select count("asset"."id")::int as "assetCount" from "asset" - inner join "album_asset" on "album_asset"."assetsId" = "asset"."id" + inner join "album_asset" on "album_asset"."assetId" = "asset"."id" where "asset"."visibility" in ('archive', 'timeline') - and "album_asset"."albumsId" in ($1) + and "album_asset"."albumId" in ($1) and "asset"."deletedAt" is null group by - "album_asset"."albumsId" + "album_asset"."albumId" -- AlbumRepository.getOwned select @@ -228,13 +228,13 @@ select from "user" where - "user"."id" = "album_user"."usersId" + "user"."id" = "album_user"."userId" ) as obj ) as "user" from "album_user" where - "album_user"."albumsId" = "album"."id" + "album_user"."albumId" = "album"."id" ) as agg ) as "albumUsers", ( @@ -283,13 +283,13 @@ select from "user" where - "user"."id" = "album_user"."usersId" + "user"."id" = "album_user"."userId" ) as obj ) as "user" from "album_user" where - "album_user"."albumsId" = "album"."id" + "album_user"."albumId" = "album"."id" ) as agg ) as "albumUsers", ( @@ -332,10 +332,10 @@ where from "album_user" where - "album_user"."albumsId" = "album"."id" + "album_user"."albumId" = "album"."id" and ( "album"."ownerId" = $1 - or "album_user"."usersId" = $2 + or "album_user"."userId" = $2 ) ) or exists ( @@ -382,7 +382,7 @@ where from "album_user" where - "album_user"."albumsId" = "album"."id" + "album_user"."albumId" = "album"."id" ) and not exists ( select @@ -397,7 +397,7 @@ order by -- AlbumRepository.removeAssetsFromAll delete from "album_asset" where - "album_asset"."assetsId" in ($1) + "album_asset"."assetId" in ($1) -- AlbumRepository.getAssetIds select @@ -405,8 +405,8 @@ select from "album_asset" where - "album_asset"."albumsId" = $1 - and "album_asset"."assetsId" in ($2) + "album_asset"."albumId" = $1 + and "album_asset"."assetId" in ($2) -- AlbumRepository.getContributorCounts select @@ -414,10 +414,10 @@ select count(*) as "assetCount" from "album_asset" - inner join "asset" on "asset"."id" = "assetsId" + inner join "asset" on "asset"."id" = "assetId" where "asset"."deletedAt" is null - and "album_asset"."albumsId" = $1 + and "album_asset"."albumId" = $1 group by "asset"."ownerId" order by @@ -427,10 +427,10 @@ order by insert into "album_asset" select - "album_asset"."albumsId", - $1 as "assetsId" + "album_asset"."albumId", + $1 as "assetId" from "album_asset" where - "album_asset"."assetsId" = $2 + "album_asset"."assetId" = $2 on conflict do nothing diff --git a/server/src/queries/album.user.repository.sql b/server/src/queries/album.user.repository.sql index e0fc0e7b74..a758ba1cf4 100644 --- a/server/src/queries/album.user.repository.sql +++ b/server/src/queries/album.user.repository.sql @@ -2,12 +2,12 @@ -- AlbumUserRepository.create insert into - "album_user" ("usersId", "albumsId") + "album_user" ("userId", "albumId") values ($1, $2) returning - "usersId", - "albumsId", + "userId", + "albumId", "role" -- AlbumUserRepository.update @@ -15,13 +15,13 @@ update "album_user" set "role" = $1 where - "usersId" = $2 - and "albumsId" = $3 + "userId" = $2 + and "albumId" = $3 returning * -- AlbumUserRepository.delete delete from "album_user" where - "usersId" = $1 - and "albumsId" = $2 + "userId" = $1 + and "albumId" = $2 diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index b55be413ea..ebfd1a08c9 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -31,9 +31,9 @@ select "tag"."value" from "tag" - inner join "tag_asset" on "tag"."id" = "tag_asset"."tagsId" + inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId" where - "asset"."id" = "tag_asset"."assetsId" + "asset"."id" = "tag_asset"."assetId" ) as agg ) as "tags" from diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index e3a0eb8c06..6cf3ec2f54 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -64,7 +64,7 @@ with from asset ), - date_part('year', current_date)::int - 1 + $3 ) as "year" ) select @@ -81,21 +81,21 @@ with where "asset_job_status"."previewAt" is not null and (asset."localDateTime" at time zone 'UTC')::date = today.date - and "asset"."ownerId" = any ($3::uuid[]) - and "asset"."visibility" = $4 + and "asset"."ownerId" = any ($4::uuid[]) + and "asset"."visibility" = $5 and exists ( select from "asset_file" where "assetId" = "asset"."id" - and "asset_file"."type" = $5 + and "asset_file"."type" = $6 ) and "asset"."deletedAt" is null order by (asset."localDateTime" at time zone 'UTC')::date desc limit - $6 + $7 ) as "a" on true inner join "asset_exif" on "a"."id" = "asset_exif"."assetId" ) @@ -160,9 +160,9 @@ select "tag"."parentId" from "tag" - inner join "tag_asset" on "tag"."id" = "tag_asset"."tagsId" + inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId" where - "asset"."id" = "tag_asset"."assetsId" + "asset"."id" = "tag_asset"."assetId" ) as agg ) as "tags", to_json("asset_exif") as "exifInfo" diff --git a/server/src/queries/map.repository.sql b/server/src/queries/map.repository.sql index df2136a422..d7e98b1cd2 100644 --- a/server/src/queries/map.repository.sql +++ b/server/src/queries/map.repository.sql @@ -23,8 +23,8 @@ where from "album_asset" where - "asset"."id" = "album_asset"."assetsId" - and "album_asset"."albumsId" in ($3) + "asset"."id" = "album_asset"."assetId" + and "album_asset"."albumId" in ($3) ) ) order by diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index b3cc7240ae..da970c2c78 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -37,7 +37,7 @@ select "asset".* from "asset" - inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId" + inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId" where "memory_asset"."memoriesId" = "memory"."id" and "asset"."visibility" = 'timeline' @@ -66,7 +66,7 @@ select "asset".* from "asset" - inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId" + inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId" where "memory_asset"."memoriesId" = "memory"."id" and "asset"."visibility" = 'timeline' @@ -104,7 +104,7 @@ select "asset".* from "asset" - inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId" + inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId" where "memory_asset"."memoriesId" = "memory"."id" and "asset"."visibility" = 'timeline' @@ -137,7 +137,7 @@ select "asset".* from "asset" - inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId" + inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId" where "memory_asset"."memoriesId" = "memory"."id" and "asset"."visibility" = 'timeline' @@ -159,15 +159,15 @@ where -- MemoryRepository.getAssetIds select - "assetsId" + "assetId" from "memory_asset" where "memoriesId" = $1 - and "assetsId" in ($2) + and "assetId" in ($2) -- MemoryRepository.addAssetIds insert into - "memory_asset" ("memoriesId", "assetsId") + "memory_asset" ("memoriesId", "assetId") values ($1, $2) diff --git a/server/src/queries/plugin.repository.sql b/server/src/queries/plugin.repository.sql new file mode 100644 index 0000000000..82c203dafd --- /dev/null +++ b/server/src/queries/plugin.repository.sql @@ -0,0 +1,159 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- PluginRepository.getPlugin +select + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."wasmPath" as "wasmPath", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_filter" + where + "plugin_filter"."pluginId" = "plugin"."id" + ) as agg + ) as "filters", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_action" + where + "plugin_action"."pluginId" = "plugin"."id" + ) as agg + ) as "actions" +from + "plugin" +where + "plugin"."id" = $1 + +-- PluginRepository.getPluginByName +select + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."wasmPath" as "wasmPath", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_filter" + where + "plugin_filter"."pluginId" = "plugin"."id" + ) as agg + ) as "filters", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_action" + where + "plugin_action"."pluginId" = "plugin"."id" + ) as agg + ) as "actions" +from + "plugin" +where + "plugin"."name" = $1 + +-- PluginRepository.getAllPlugins +select + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."wasmPath" as "wasmPath", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_filter" + where + "plugin_filter"."pluginId" = "plugin"."id" + ) as agg + ) as "filters", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_action" + where + "plugin_action"."pluginId" = "plugin"."id" + ) as agg + ) as "actions" +from + "plugin" +order by + "plugin"."name" + +-- PluginRepository.getFilter +select + * +from + "plugin_filter" +where + "id" = $1 + +-- PluginRepository.getFiltersByPlugin +select + * +from + "plugin_filter" +where + "pluginId" = $1 + +-- PluginRepository.getAction +select + * +from + "plugin_action" +where + "id" = $1 + +-- PluginRepository.getActionsByPlugin +select + * +from + "plugin_action" +where + "pluginId" = $1 diff --git a/server/src/queries/shared.link.asset.repository.sql b/server/src/queries/shared.link.asset.repository.sql index 7acee50812..7f9ebc03d1 100644 --- a/server/src/queries/shared.link.asset.repository.sql +++ b/server/src/queries/shared.link.asset.repository.sql @@ -4,10 +4,10 @@ insert into "shared_link_asset" select - $1 as "assetsId", - "shared_link_asset"."sharedLinksId" + $1 as "assetId", + "shared_link_asset"."sharedLinkId" from "shared_link_asset" where - "shared_link_asset"."assetsId" = $2 + "shared_link_asset"."assetId" = $2 on conflict do nothing diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 0f46846c14..8540da91c8 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -19,7 +19,7 @@ from to_json("exifInfo") as "exifInfo" from "shared_link_asset" - inner join "asset" on "asset"."id" = "shared_link_asset"."assetsId" + inner join "asset" on "asset"."id" = "shared_link_asset"."assetId" inner join lateral ( select "asset_exif".* @@ -29,7 +29,7 @@ from "asset_exif"."assetId" = "asset"."id" ) as "exifInfo" on true where - "shared_link"."id" = "shared_link_asset"."sharedLinksId" + "shared_link"."id" = "shared_link_asset"."sharedLinkId" and "asset"."deletedAt" is null order by "asset"."fileCreatedAt" asc @@ -51,7 +51,7 @@ from to_json("owner") as "owner" from "album" - left join "album_asset" on "album_asset"."albumsId" = "album"."id" + left join "album_asset" on "album_asset"."albumId" = "album"."id" left join lateral ( select "asset".*, @@ -67,7 +67,7 @@ from "asset_exif"."assetId" = "asset"."id" ) as "exifInfo" on true where - "album_asset"."assetsId" = "asset"."id" + "album_asset"."assetId" = "asset"."id" and "asset"."deletedAt" is null order by "asset"."fileCreatedAt" asc @@ -108,14 +108,14 @@ select distinct to_json("album") as "album" from "shared_link" - left join "shared_link_asset" on "shared_link_asset"."sharedLinksId" = "shared_link"."id" + left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id" left join lateral ( select json_agg("asset") as "assets" from "asset" where - "asset"."id" = "shared_link_asset"."assetsId" + "asset"."id" = "shared_link_asset"."assetId" and "asset"."deletedAt" is null ) as "assets" on true left join lateral ( diff --git a/server/src/queries/stack.repository.sql b/server/src/queries/stack.repository.sql index 0bfb5df2fb..64714e5665 100644 --- a/server/src/queries/stack.repository.sql +++ b/server/src/queries/stack.repository.sql @@ -89,9 +89,9 @@ select "tag"."parentId" from "tag" - inner join "tag_asset" on "tag"."id" = "tag_asset"."tagsId" + inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId" where - "tag_asset"."assetsId" = "asset"."id" + "tag_asset"."assetId" = "asset"."id" ) as agg ) as "tags", to_json("exifInfo") as "exifInfo" diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 809b59df10..7c1dc3b6b4 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -2,12 +2,12 @@ -- SyncRepository.album.getCreatedAfter select - "albumsId" as "id", + "albumId" as "id", "createId" from "album_user" where - "usersId" = $1 + "userId" = $1 and "createId" >= $2 and "createId" < $3 order by @@ -40,13 +40,13 @@ select distinct "album"."updateId" from "album" as "album" - left join "album_user" as "album_users" on "album"."id" = "album_users"."albumsId" + left join "album_user" as "album_users" on "album"."id" = "album_users"."albumId" where "album"."updateId" < $1 and "album"."updateId" > $2 and ( "album"."ownerId" = $3 - or "album_users"."usersId" = $4 + or "album_users"."userId" = $4 ) order by "album"."updateId" asc @@ -72,12 +72,12 @@ select "album_asset"."updateId" from "album_asset" as "album_asset" - inner join "asset" on "asset"."id" = "album_asset"."assetsId" + inner join "asset" on "asset"."id" = "album_asset"."assetId" where "album_asset"."updateId" < $1 and "album_asset"."updateId" <= $2 and "album_asset"."updateId" >= $3 - and "album_asset"."albumsId" = $4 + and "album_asset"."albumId" = $4 order by "album_asset"."updateId" asc @@ -102,16 +102,16 @@ select "asset"."updateId" from "asset" as "asset" - inner join "album_asset" on "album_asset"."assetsId" = "asset"."id" - inner join "album" on "album"."id" = "album_asset"."albumsId" - left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId" + inner join "album_asset" on "album_asset"."assetId" = "asset"."id" + inner join "album" on "album"."id" = "album_asset"."albumId" + left join "album_user" on "album_user"."albumId" = "album_asset"."albumId" where "asset"."updateId" < $1 and "asset"."updateId" > $2 and "album_asset"."updateId" <= $3 and ( "album"."ownerId" = $4 - or "album_user"."usersId" = $5 + or "album_user"."userId" = $5 ) order by "asset"."updateId" asc @@ -137,15 +137,15 @@ select "asset"."libraryId" from "album_asset" as "album_asset" - inner join "asset" on "asset"."id" = "album_asset"."assetsId" - inner join "album" on "album"."id" = "album_asset"."albumsId" - left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId" + inner join "asset" on "asset"."id" = "album_asset"."assetId" + inner join "album" on "album"."id" = "album_asset"."albumId" + left join "album_user" on "album_user"."albumId" = "album_asset"."albumId" where "album_asset"."updateId" < $1 and "album_asset"."updateId" > $2 and ( "album"."ownerId" = $3 - or "album_user"."usersId" = $4 + or "album_user"."userId" = $4 ) order by "album_asset"."updateId" asc @@ -180,12 +180,12 @@ select "album_asset"."updateId" from "album_asset" as "album_asset" - inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetsId" + inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetId" where "album_asset"."updateId" < $1 and "album_asset"."updateId" <= $2 and "album_asset"."updateId" >= $3 - and "album_asset"."albumsId" = $4 + and "album_asset"."albumId" = $4 order by "album_asset"."updateId" asc @@ -219,16 +219,16 @@ select "asset_exif"."updateId" from "asset_exif" as "asset_exif" - inner join "album_asset" on "album_asset"."assetsId" = "asset_exif"."assetId" - inner join "album" on "album"."id" = "album_asset"."albumsId" - left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId" + inner join "album_asset" on "album_asset"."assetId" = "asset_exif"."assetId" + inner join "album" on "album"."id" = "album_asset"."albumId" + left join "album_user" on "album_user"."albumId" = "album_asset"."albumId" where "asset_exif"."updateId" < $1 and "asset_exif"."updateId" > $2 and "album_asset"."updateId" <= $3 and ( "album"."ownerId" = $4 - or "album_user"."usersId" = $5 + or "album_user"."userId" = $5 ) order by "asset_exif"."updateId" asc @@ -263,23 +263,23 @@ select "asset_exif"."fps" from "album_asset" as "album_asset" - inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetsId" - inner join "album" on "album"."id" = "album_asset"."albumsId" - left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId" + inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetId" + inner join "album" on "album"."id" = "album_asset"."albumId" + left join "album_user" on "album_user"."albumId" = "album_asset"."albumId" where "album_asset"."updateId" < $1 and "album_asset"."updateId" > $2 and ( "album"."ownerId" = $3 - or "album_user"."usersId" = $4 + or "album_user"."userId" = $4 ) order by "album_asset"."updateId" asc -- SyncRepository.albumToAsset.getBackfill select - "album_asset"."assetsId" as "assetId", - "album_asset"."albumsId" as "albumId", + "album_asset"."assetId" as "assetId", + "album_asset"."albumId" as "albumId", "album_asset"."updateId" from "album_asset" as "album_asset" @@ -287,7 +287,7 @@ where "album_asset"."updateId" < $1 and "album_asset"."updateId" <= $2 and "album_asset"."updateId" >= $3 - and "album_asset"."albumsId" = $4 + and "album_asset"."albumId" = $4 order by "album_asset"."updateId" asc @@ -311,11 +311,11 @@ where union ( select - "album_user"."albumsId" as "id" + "album_user"."albumId" as "id" from "album_user" where - "album_user"."usersId" = $4 + "album_user"."userId" = $4 ) ) order by @@ -323,27 +323,27 @@ order by -- SyncRepository.albumToAsset.getUpserts select - "album_asset"."assetsId" as "assetId", - "album_asset"."albumsId" as "albumId", + "album_asset"."assetId" as "assetId", + "album_asset"."albumId" as "albumId", "album_asset"."updateId" from "album_asset" as "album_asset" - inner join "album" on "album"."id" = "album_asset"."albumsId" - left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId" + inner join "album" on "album"."id" = "album_asset"."albumId" + left join "album_user" on "album_user"."albumId" = "album_asset"."albumId" where "album_asset"."updateId" < $1 and "album_asset"."updateId" > $2 and ( "album"."ownerId" = $3 - or "album_user"."usersId" = $4 + or "album_user"."userId" = $4 ) order by "album_asset"."updateId" asc -- SyncRepository.albumUser.getBackfill select - "album_user"."albumsId" as "albumId", - "album_user"."usersId" as "userId", + "album_user"."albumId" as "albumId", + "album_user"."userId" as "userId", "album_user"."role", "album_user"."updateId" from @@ -352,7 +352,7 @@ where "album_user"."updateId" < $1 and "album_user"."updateId" <= $2 and "album_user"."updateId" >= $3 - and "albumsId" = $4 + and "albumId" = $4 order by "album_user"."updateId" asc @@ -376,11 +376,11 @@ where union ( select - "album_user"."albumsId" as "id" + "album_user"."albumId" as "id" from "album_user" where - "album_user"."usersId" = $4 + "album_user"."userId" = $4 ) ) order by @@ -388,8 +388,8 @@ order by -- SyncRepository.albumUser.getUpserts select - "album_user"."albumsId" as "albumId", - "album_user"."usersId" as "userId", + "album_user"."albumId" as "albumId", + "album_user"."userId" as "userId", "album_user"."role", "album_user"."updateId" from @@ -397,7 +397,7 @@ from where "album_user"."updateId" < $1 and "album_user"."updateId" > $2 - and "album_user"."albumsId" in ( + and "album_user"."albumId" in ( select "id" from @@ -407,11 +407,11 @@ where union ( select - "albumUsers"."albumsId" as "id" + "albumUsers"."albumId" as "id" from "album_user" as "albumUsers" where - "albumUsers"."usersId" = $4 + "albumUsers"."userId" = $4 ) ) order by @@ -656,7 +656,7 @@ order by -- SyncRepository.memoryToAsset.getUpserts select "memoriesId" as "memoryId", - "assetsId" as "assetId", + "assetId" as "assetId", "updateId" from "memory_asset" as "memory_asset" diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql index ee961f3801..c3b46dd9f3 100644 --- a/server/src/queries/tag.repository.sql +++ b/server/src/queries/tag.repository.sql @@ -84,19 +84,19 @@ where -- TagRepository.addAssetIds insert into - "tag_asset" ("tagsId", "assetsId") + "tag_asset" ("tagId", "assetId") values ($1, $2) -- TagRepository.removeAssetIds delete from "tag_asset" where - "tagsId" = $1 - and "assetsId" in ($2) + "tagId" = $1 + and "assetId" in ($2) -- TagRepository.upsertAssetIds insert into - "tag_asset" ("assetId", "tagsIds") + "tag_asset" ("assetId", "tagIds") values ($1, $2) on conflict do nothing @@ -107,9 +107,9 @@ returning begin delete from "tag_asset" where - "assetsId" = $1 + "assetId" = $1 insert into - "tag_asset" ("tagsId", "assetsId") + "tag_asset" ("tagId", "assetId") values ($1, $2) on conflict do nothing diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql new file mode 100644 index 0000000000..3797c5bb06 --- /dev/null +++ b/server/src/queries/workflow.repository.sql @@ -0,0 +1,68 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- WorkflowRepository.getWorkflow +select + * +from + "workflow" +where + "id" = $1 + +-- WorkflowRepository.getWorkflowsByOwner +select + * +from + "workflow" +where + "ownerId" = $1 +order by + "name" + +-- WorkflowRepository.getWorkflowsByTrigger +select + * +from + "workflow" +where + "triggerType" = $1 + and "enabled" = $2 + +-- WorkflowRepository.getWorkflowByOwnerAndTrigger +select + * +from + "workflow" +where + "ownerId" = $1 + and "triggerType" = $2 + and "enabled" = $3 + +-- WorkflowRepository.deleteWorkflow +delete from "workflow" +where + "id" = $1 + +-- WorkflowRepository.getFilters +select + * +from + "workflow_filter" +where + "workflowId" = $1 +order by + "order" asc + +-- WorkflowRepository.deleteFiltersByWorkflow +delete from "workflow_filter" +where + "workflowId" = $1 + +-- WorkflowRepository.getActions +select + * +from + "workflow_action" +where + "workflowId" = $1 +order by + "order" asc diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index ca12ff040b..533e74a311 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -52,8 +52,8 @@ class ActivityAccess { return this.db .selectFrom('album') .select('album.id') - .leftJoin('album_user as albumUsers', 'albumUsers.albumsId', 'album.id') - .leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.usersId').on('user.deletedAt', 'is', null)) + .leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id') + .leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null)) .where('album.id', 'in', [...albumIds]) .where('album.isActivityEnabled', '=', true) .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)])) @@ -96,8 +96,8 @@ class AlbumAccess { return this.db .selectFrom('album') .select('album.id') - .leftJoin('album_user', 'album_user.albumsId', 'album.id') - .leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.usersId').on('user.deletedAt', 'is', null)) + .leftJoin('album_user', 'album_user.albumId', 'album.id') + .leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null)) .where('album.id', 'in', [...albumIds]) .where('album.deletedAt', 'is', null) .where('user.id', '=', userId) @@ -138,12 +138,12 @@ class AssetAccess { return this.db .with('target', (qb) => qb.selectNoFrom(sql`array[${sql.join([...assetIds])}]::uuid[]`.as('ids'))) .selectFrom('album') - .innerJoin('album_asset as albumAssets', 'album.id', 'albumAssets.albumsId') + .innerJoin('album_asset as albumAssets', 'album.id', 'albumAssets.albumId') .innerJoin('asset', (join) => - join.onRef('asset.id', '=', 'albumAssets.assetsId').on('asset.deletedAt', 'is', null), + join.onRef('asset.id', '=', 'albumAssets.assetId').on('asset.deletedAt', 'is', null), ) - .leftJoin('album_user as albumUsers', 'albumUsers.albumsId', 'album.id') - .leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.usersId').on('user.deletedAt', 'is', null)) + .leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id') + .leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null)) .crossJoin('target') .select(['asset.id', 'asset.livePhotoVideoId']) .where((eb) => @@ -223,13 +223,13 @@ class AssetAccess { return this.db .selectFrom('shared_link') .leftJoin('album', (join) => join.onRef('album.id', '=', 'shared_link.albumId').on('album.deletedAt', 'is', null)) - .leftJoin('shared_link_asset', 'shared_link_asset.sharedLinksId', 'shared_link.id') + .leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id') .leftJoin('asset', (join) => - join.onRef('asset.id', '=', 'shared_link_asset.assetsId').on('asset.deletedAt', 'is', null), + join.onRef('asset.id', '=', 'shared_link_asset.assetId').on('asset.deletedAt', 'is', null), ) - .leftJoin('album_asset', 'album_asset.albumsId', 'album.id') + .leftJoin('album_asset', 'album_asset.albumId', 'album.id') .leftJoin('asset as albumAssets', (join) => - join.onRef('albumAssets.id', '=', 'album_asset.assetsId').on('albumAssets.deletedAt', 'is', null), + join.onRef('albumAssets.id', '=', 'album_asset.assetId').on('albumAssets.deletedAt', 'is', null), ) .select([ 'asset.id as assetId', @@ -462,6 +462,26 @@ class TagAccess { } } +class WorkflowAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, workflowIds: Set) { + if (workflowIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('workflow') + .select('workflow.id') + .where('workflow.id', 'in', [...workflowIds]) + .where('workflow.ownerId', '=', userId) + .execute() + .then((workflows) => new Set(workflows.map((workflow) => workflow.id))); + } +} + @Injectable() export class AccessRepository { activity: ActivityAccess; @@ -476,6 +496,7 @@ export class AccessRepository { stack: StackAccess; tag: TagAccess; timeline: TimelineAccess; + workflow: WorkflowAccess; constructor(@InjectKysely() db: Kysely) { this.activity = new ActivityAccess(db); @@ -490,5 +511,6 @@ export class AccessRepository { this.stack = new StackAccess(db); this.tag = new TagAccess(db); this.timeline = new TimelineAccess(db); + this.workflow = new WorkflowAccess(db); } } diff --git a/server/src/repositories/album-user.repository.ts b/server/src/repositories/album-user.repository.ts index 2fce797aff..1a1e58a77d 100644 --- a/server/src/repositories/album-user.repository.ts +++ b/server/src/repositories/album-user.repository.ts @@ -7,36 +7,36 @@ import { DB } from 'src/schema'; import { AlbumUserTable } from 'src/schema/tables/album-user.table'; export type AlbumPermissionId = { - albumsId: string; - usersId: string; + albumId: string; + userId: string; }; @Injectable() export class AlbumUserRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] }) + @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] }) create(albumUser: Insertable) { return this.db .insertInto('album_user') .values(albumUser) - .returning(['usersId', 'albumsId', 'role']) + .returning(['userId', 'albumId', 'role']) .executeTakeFirstOrThrow(); } - @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] }) - update({ usersId, albumsId }: AlbumPermissionId, dto: Updateable) { + @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] }) + update({ userId, albumId }: AlbumPermissionId, dto: Updateable) { return this.db .updateTable('album_user') .set(dto) - .where('usersId', '=', usersId) - .where('albumsId', '=', albumsId) + .where('userId', '=', userId) + .where('albumId', '=', albumId) .returningAll() .executeTakeFirstOrThrow(); } - @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] }) - async delete({ usersId, albumsId }: AlbumPermissionId): Promise { - await this.db.deleteFrom('album_user').where('usersId', '=', usersId).where('albumsId', '=', albumsId).execute(); + @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] }) + async delete({ userId, albumId }: AlbumPermissionId): Promise { + await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute(); } } diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index f5bfe44efe..100ab908c0 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -33,11 +33,11 @@ const withAlbumUsers = (eb: ExpressionBuilder) => { .selectFrom('album_user') .select('album_user.role') .select((eb) => - jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.usersId')) + jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.userId')) .$notNull() .as('user'), ) - .whereRef('album_user.albumsId', '=', 'album.id'), + .whereRef('album_user.albumId', '=', 'album.id'), ) .$notNull() .as('albumUsers'); @@ -57,8 +57,8 @@ const withAssets = (eb: ExpressionBuilder) => { .selectAll('asset') .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .select((eb) => eb.table('asset_exif').$castTo().as('exifInfo')) - .innerJoin('album_asset', 'album_asset.assetsId', 'asset.id') - .whereRef('album_asset.albumsId', '=', 'album.id') + .innerJoin('album_asset', 'album_asset.assetId', 'asset.id') + .whereRef('album_asset.albumId', '=', 'album.id') .where('asset.deletedAt', 'is', null) .$call(withDefaultVisibility) .orderBy('asset.fileCreatedAt', 'desc') @@ -92,19 +92,19 @@ export class AlbumRepository { return this.db .selectFrom('album') .selectAll('album') - .innerJoin('album_asset', 'album_asset.albumsId', 'album.id') + .innerJoin('album_asset', 'album_asset.albumId', 'album.id') .where((eb) => eb.or([ eb('album.ownerId', '=', ownerId), eb.exists( eb .selectFrom('album_user') - .whereRef('album_user.albumsId', '=', 'album.id') - .where('album_user.usersId', '=', ownerId), + .whereRef('album_user.albumId', '=', 'album.id') + .where('album_user.userId', '=', ownerId), ), ]), ) - .where('album_asset.assetsId', '=', assetId) + .where('album_asset.assetId', '=', assetId) .where('album.deletedAt', 'is', null) .orderBy('album.createdAt', 'desc') .select(withOwner) @@ -125,16 +125,16 @@ export class AlbumRepository { this.db .selectFrom('asset') .$call(withDefaultVisibility) - .innerJoin('album_asset', 'album_asset.assetsId', 'asset.id') - .select('album_asset.albumsId as albumId') + .innerJoin('album_asset', 'album_asset.assetId', 'asset.id') + .select('album_asset.albumId as albumId') .select((eb) => eb.fn.min(sql`("asset"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('startDate')) .select((eb) => eb.fn.max(sql`("asset"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('endDate')) // lastModifiedAssetTimestamp is only used in mobile app, please remove if not need .select((eb) => eb.fn.max('asset.updatedAt').as('lastModifiedAssetTimestamp')) .select((eb) => sql`${eb.fn.count('asset.id')}::int`.as('assetCount')) - .where('album_asset.albumsId', 'in', ids) + .where('album_asset.albumId', 'in', ids) .where('asset.deletedAt', 'is', null) - .groupBy('album_asset.albumsId') + .groupBy('album_asset.albumId') .execute() ); } @@ -166,8 +166,8 @@ export class AlbumRepository { eb.exists( eb .selectFrom('album_user') - .whereRef('album_user.albumsId', '=', 'album.id') - .where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.usersId', '=', ownerId)])), + .whereRef('album_user.albumId', '=', 'album.id') + .where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.userId', '=', ownerId)])), ), eb.exists( eb @@ -195,7 +195,7 @@ export class AlbumRepository { .selectAll('album') .where('album.ownerId', '=', ownerId) .where('album.deletedAt', 'is', null) - .where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumsId', '=', 'album.id')))) + .where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id')))) .where((eb) => eb.not(eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id')))) .select(withOwner) .orderBy('album.createdAt', 'desc') @@ -217,7 +217,7 @@ export class AlbumRepository { @GenerateSql({ params: [[DummyValue.UUID]] }) @Chunked() async removeAssetsFromAll(assetIds: string[]): Promise { - await this.db.deleteFrom('album_asset').where('album_asset.assetsId', 'in', assetIds).execute(); + await this.db.deleteFrom('album_asset').where('album_asset.assetId', 'in', assetIds).execute(); } @Chunked({ paramIndex: 1 }) @@ -228,8 +228,8 @@ export class AlbumRepository { await this.db .deleteFrom('album_asset') - .where('album_asset.albumsId', '=', albumId) - .where('album_asset.assetsId', 'in', assetIds) + .where('album_asset.albumId', '=', albumId) + .where('album_asset.assetId', 'in', assetIds) .execute(); } @@ -250,10 +250,10 @@ export class AlbumRepository { return this.db .selectFrom('album_asset') .selectAll() - .where('album_asset.albumsId', '=', albumId) - .where('album_asset.assetsId', 'in', assetIds) + .where('album_asset.albumId', '=', albumId) + .where('album_asset.assetId', 'in', assetIds) .execute() - .then((results) => new Set(results.map(({ assetsId }) => assetsId))); + .then((results) => new Set(results.map(({ assetId }) => assetId))); } async addAssetIds(albumId: string, assetIds: string[]): Promise { @@ -276,7 +276,7 @@ export class AlbumRepository { await tx .insertInto('album_user') .values( - albumUsers.map((albumUser) => ({ albumsId: newAlbum.id, usersId: albumUser.userId, role: albumUser.role })), + albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, role: albumUser.role })), ) .execute(); } @@ -317,12 +317,12 @@ export class AlbumRepository { await db .insertInto('album_asset') - .values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId }))) + .values(assetIds.map((assetId) => ({ albumId, assetId }))) .execute(); } @Chunked({ chunkSize: 30_000 }) - async addAssetIdsToAlbums(values: { albumsId: string; assetsId: string }[]): Promise { + async addAssetIdsToAlbums(values: { albumId: string; assetId: string }[]): Promise { if (values.length === 0) { return; } @@ -344,7 +344,7 @@ export class AlbumRepository { .updateTable('album') .set((eb) => ({ albumThumbnailAssetId: this.updateThumbnailBuilder(eb) - .select('album_asset.assetsId') + .select('album_asset.assetId') .orderBy('asset.fileCreatedAt', 'desc') .limit(1), })) @@ -360,7 +360,7 @@ export class AlbumRepository { eb.exists( this.updateThumbnailBuilder(eb) .select(sql`1`.as('1')) - .whereRef('album.albumThumbnailAssetId', '=', 'album_asset.assetsId'), // Has invalid assets + .whereRef('album.albumThumbnailAssetId', '=', 'album_asset.assetId'), // Has invalid assets ), ), ]), @@ -375,9 +375,9 @@ export class AlbumRepository { return eb .selectFrom('album_asset') .innerJoin('asset', (join) => - join.onRef('album_asset.assetsId', '=', 'asset.id').on('asset.deletedAt', 'is', null), + join.onRef('album_asset.assetId', '=', 'asset.id').on('asset.deletedAt', 'is', null), ) - .whereRef('album_asset.albumsId', '=', 'album.id'); + .whereRef('album_asset.albumId', '=', 'album.id'); } /** @@ -388,9 +388,9 @@ export class AlbumRepository { getContributorCounts(id: string) { return this.db .selectFrom('album_asset') - .innerJoin('asset', 'asset.id', 'assetsId') + .innerJoin('asset', 'asset.id', 'assetId') .where('asset.deletedAt', 'is', sql.lit(null)) - .where('album_asset.albumsId', '=', id) + .where('album_asset.albumId', '=', id) .select('asset.ownerId as userId') .select((eb) => eb.fn.countAll().as('assetCount')) .groupBy('asset.ownerId') @@ -405,8 +405,8 @@ export class AlbumRepository { .expression((eb) => eb .selectFrom('album_asset') - .select((eb) => ['album_asset.albumsId', eb.val(targetAssetId).as('assetsId')]) - .where('album_asset.assetsId', '=', sourceAssetId), + .select((eb) => ['album_asset.albumId', eb.val(targetAssetId).as('assetId')]) + .where('album_asset.assetId', '=', sourceAssetId), ) .onConflict((oc) => oc.doNothing()) .execute(); diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 6094708d1b..8d54e93c87 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -46,8 +46,8 @@ export class AssetJobRepository { eb .selectFrom('tag') .select(['tag.value']) - .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId') - .whereRef('asset.id', '=', 'tag_asset.assetsId'), + .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId') + .whereRef('asset.id', '=', 'tag_asset.assetId'), ).as('tags'), ) .limit(1) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index c4227e6412..d3d9ada80f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -73,9 +73,10 @@ export interface TimeBucketItem { count: number; } -export interface MonthDay { +export interface YearMonthDay { day: number; month: number; + year: number; } interface AssetExploreFieldOptions { @@ -259,8 +260,8 @@ export class AssetRepository { return this.db.insertInto('asset').values(assets).returningAll().execute(); } - @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) - getByDayOfYear(ownerIds: string[], { day, month }: MonthDay) { + @GenerateSql({ params: [DummyValue.UUID, { year: 2000, day: 1, month: 1 }] }) + getByDayOfYear(ownerIds: string[], { year, day, month }: YearMonthDay) { return this.db .with('res', (qb) => qb @@ -270,7 +271,7 @@ export class AssetRepository { eb .fn('generate_series', [ sql`(select date_part('year', min(("localDateTime" at time zone 'UTC')::date))::int from asset)`, - sql`date_part('year', current_date)::int - 1`, + sql`${year - 1}`, ]) .as('year'), ) @@ -563,8 +564,8 @@ export class AssetRepository { .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) .$if(!!options.albumId, (qb) => qb - .innerJoin('album_asset', 'asset.id', 'album_asset.assetsId') - .where('album_asset.albumsId', '=', asUuid(options.albumId!)), + .innerJoin('album_asset', 'asset.id', 'album_asset.assetId') + .where('album_asset.albumId', '=', asUuid(options.albumId!)), ) .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.withStacked, (qb) => @@ -641,8 +642,8 @@ export class AssetRepository { eb.exists( eb .selectFrom('album_asset') - .whereRef('album_asset.assetsId', '=', 'asset.id') - .where('album_asset.albumsId', '=', asUuid(options.albumId!)), + .whereRef('album_asset.assetId', '=', 'asset.id') + .where('album_asset.albumId', '=', asUuid(options.albumId!)), ), ), ) diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index d5c279099c..05d4bd2ac3 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -85,6 +85,7 @@ export interface EnvData { root: string; indexHtml: string; }; + corePlugin: string; }; redis: RedisOptions; @@ -102,6 +103,11 @@ export interface EnvData { workers: ImmichWorker[]; + plugins: { + enabled: boolean; + installFolder?: string; + }; + noColor: boolean; nodeVersion?: string; } @@ -304,6 +310,7 @@ const getEnv = (): EnvData => { root: folders.web, indexHtml: join(folders.web, 'index.html'), }, + corePlugin: join(buildFolder, 'corePlugin'), }, storage: { @@ -319,6 +326,11 @@ const getEnv = (): EnvData => { workers, + plugins: { + enabled: !!dto.IMMICH_PLUGINS_ENABLED, + installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER, + }, + noColor: !!dto.NO_COLOR, }; }; diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index c3136db456..bcd791ade2 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { compareSync, hash } from 'bcrypt'; +import jwt from 'jsonwebtoken'; import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; @@ -57,4 +58,12 @@ export class CryptoRepository { randomBytesAsText(bytes: number) { return randomBytes(bytes).toString('base64').replaceAll(/\W/g, ''); } + + signJwt(payload: string | object | Buffer, secret: string, options?: jwt.SignOptions): string { + return jwt.sign(payload, secret, { algorithm: 'HS256', ...options }); + } + + verifyJwt(token: string, secret: string): T { + return jwt.verify(token, secret, { algorithms: ['HS256'] }) as T; + } } diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 9c9245ea34..842576fafb 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -360,18 +360,7 @@ export class DatabaseRepository { async runMigrations(): Promise { this.logger.debug('Running migrations'); - const migrator = new Migrator({ - db: this.db, - migrationLockTableName: 'kysely_migrations_lock', - allowUnorderedMigrations: this.configRepository.isDev(), - migrationTableName: 'kysely_migrations', - provider: new FileMigrationProvider({ - fs: { readdir }, - path: { join }, - // eslint-disable-next-line unicorn/prefer-module - migrationFolder: join(__dirname, '..', 'schema/migrations'), - }), - }); + const migrator = this.createMigrator(); const { error, results } = await migrator.migrateToLatest(); @@ -477,4 +466,50 @@ export class DatabaseRepository { private async releaseLock(lock: DatabaseLock, connection: Kysely): Promise { await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection); } + + async revertLastMigration(): Promise { + this.logger.debug('Reverting last migration'); + + const migrator = this.createMigrator(); + const { error, results } = await migrator.migrateDown(); + + for (const result of results ?? []) { + if (result.status === 'Success') { + this.logger.log(`Reverted migration "${result.migrationName}"`); + } + + if (result.status === 'Error') { + this.logger.warn(`Failed to revert migration "${result.migrationName}"`); + } + } + + if (error) { + this.logger.error(`Failed to revert migrations: ${error}`); + throw error; + } + + const reverted = results?.find((result) => result.direction === 'Down' && result.status === 'Success'); + if (!reverted) { + this.logger.debug('No migrations to revert'); + return undefined; + } + + this.logger.debug('Finished reverting migration'); + return reverted.migrationName; + } + + private createMigrator(): Migrator { + return new Migrator({ + db: this.db, + migrationLockTableName: 'kysely_migrations_lock', + allowUnorderedMigrations: this.configRepository.isDev(), + migrationTableName: 'kysely_migrations', + provider: new FileMigrationProvider({ + fs: { readdir }, + path: { join }, + // eslint-disable-next-line unicorn/prefer-module + migrationFolder: join(__dirname, '..', 'schema/migrations'), + }), + }); + } } diff --git a/server/src/repositories/download.repository.ts b/server/src/repositories/download.repository.ts index ecc1e4d3ab..61a0f23d5e 100644 --- a/server/src/repositories/download.repository.ts +++ b/server/src/repositories/download.repository.ts @@ -26,8 +26,8 @@ export class DownloadRepository { downloadAlbumId(albumId: string) { return builder(this.db) - .innerJoin('album_asset', 'asset.id', 'album_asset.assetsId') - .where('album_asset.albumsId', '=', albumId) + .innerJoin('album_asset', 'asset.id', 'album_asset.assetId') + .where('album_asset.albumId', '=', albumId) .stream(); } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index c3e6cd20cf..80d411c5ae 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -4,6 +4,7 @@ import { ClassConstructor } from 'class-transformer'; import _ from 'lodash'; import { Socket } from 'socket.io'; import { SystemConfig } from 'src/config'; +import { Asset } from 'src/database'; import { EventConfig } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; @@ -41,6 +42,7 @@ type EventMap = { AlbumInvite: [{ id: string; userId: string }]; // asset events + AssetCreate: [{ asset: Asset }]; AssetTag: [{ assetId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index cf65cfcb2a..c69536a327 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -28,6 +28,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; @@ -46,6 +47,7 @@ import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; export const repositories = [ AccessRepository, @@ -78,6 +80,7 @@ export const repositories = [ OcrRepository, PartnerRepository, PersonRepository, + PluginRepository, ProcessRepository, SearchRepository, SessionRepository, @@ -96,4 +99,5 @@ export const repositories = [ ViewRepository, VersionHistoryRepository, WebsocketRepository, + WorkflowRepository, ]; diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 7f6e2a967a..304cf89c32 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -126,8 +126,8 @@ export class MapRepository { eb.exists((eb) => eb .selectFrom('album_asset') - .whereRef('asset.id', '=', 'album_asset.assetsId') - .where('album_asset.albumsId', 'in', albumIds), + .whereRef('asset.id', '=', 'album_asset.assetId') + .where('album_asset.albumId', 'in', albumIds), ), ); } diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 65b4cb3df7..e62c083839 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, sql, Updateable } from 'kysely'; +import { Insertable, Kysely, OrderByDirection, sql, Updateable } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { MemorySearchDto } from 'src/dtos/memory.dto'; -import { AssetVisibility } from 'src/enum'; +import { AssetOrderWithRandom, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { MemoryTable } from 'src/schema/tables/memory.table'; import { IBulkAsset } from 'src/types'; @@ -18,7 +18,7 @@ export class MemoryRepository implements IBulkAsset { await this.db .deleteFrom('memory_asset') .using('asset') - .whereRef('memory_asset.assetsId', '=', 'asset.id') + .whereRef('memory_asset.assetId', '=', 'asset.id') .where('asset.visibility', '!=', AssetVisibility.Timeline) .execute(); @@ -64,7 +64,7 @@ export class MemoryRepository implements IBulkAsset { eb .selectFrom('asset') .selectAll('asset') - .innerJoin('memory_asset', 'asset.id', 'memory_asset.assetsId') + .innerJoin('memory_asset', 'asset.id', 'memory_asset.assetId') .whereRef('memory_asset.memoriesId', '=', 'memory.id') .orderBy('asset.fileCreatedAt', 'asc') .where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) @@ -72,7 +72,12 @@ export class MemoryRepository implements IBulkAsset { ).as('assets'), ) .selectAll('memory') - .orderBy('memoryAt', 'desc') + .$call((qb) => + dto.order === AssetOrderWithRandom.Random + ? qb.orderBy(sql`RANDOM()`) + : qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection), + ) + .$if(dto.size !== undefined, (qb) => qb.limit(dto.size!)) .execute(); } @@ -86,7 +91,7 @@ export class MemoryRepository implements IBulkAsset { const { id } = await tx.insertInto('memory').values(memory).returning('id').executeTakeFirstOrThrow(); if (assetIds.size > 0) { - const values = [...assetIds].map((assetId) => ({ memoriesId: id, assetsId: assetId })); + const values = [...assetIds].map((assetId) => ({ memoriesId: id, assetId })); await tx.insertInto('memory_asset').values(values).execute(); } @@ -116,12 +121,12 @@ export class MemoryRepository implements IBulkAsset { const results = await this.db .selectFrom('memory_asset') - .select(['assetsId']) + .select(['assetId']) .where('memoriesId', '=', id) - .where('assetsId', 'in', assetIds) + .where('assetId', 'in', assetIds) .execute(); - return new Set(results.map(({ assetsId }) => assetsId)); + return new Set(results.map(({ assetId }) => assetId)); } @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) @@ -132,7 +137,7 @@ export class MemoryRepository implements IBulkAsset { await this.db .insertInto('memory_asset') - .values(assetIds.map((assetId) => ({ memoriesId: id, assetsId: assetId }))) + .values(assetIds.map((assetId) => ({ memoriesId: id, assetId }))) .execute(); } @@ -143,7 +148,7 @@ export class MemoryRepository implements IBulkAsset { return; } - await this.db.deleteFrom('memory_asset').where('memoriesId', '=', id).where('assetsId', 'in', assetIds).execute(); + await this.db.deleteFrom('memory_asset').where('memoriesId', '=', id).where('assetId', 'in', assetIds).execute(); } private getByIdBuilder(id: string) { @@ -155,7 +160,7 @@ export class MemoryRepository implements IBulkAsset { eb .selectFrom('asset') .selectAll('asset') - .innerJoin('memory_asset', 'asset.id', 'memory_asset.assetsId') + .innerJoin('memory_asset', 'asset.id', 'memory_asset.assetId') .whereRef('memory_asset.memoriesId', '=', 'memory.id') .orderBy('asset.fileCreatedAt', 'asc') .where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts new file mode 100644 index 0000000000..6217237947 --- /dev/null +++ b/server/src/repositories/plugin.repository.ts @@ -0,0 +1,176 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; +import { InjectKysely } from 'nestjs-kysely'; +import { readdir } from 'node:fs/promises'; +import { columns } from 'src/database'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { DB } from 'src/schema'; + +@Injectable() +export class PluginRepository { + constructor(@InjectKysely() private db: Kysely) {} + + /** + * Loads a plugin from a validated manifest file in a transaction. + * This ensures all plugin, filter, and action operations are atomic. + * @param manifest The validated plugin manifest + * @param basePath The base directory path where the plugin is located + */ + async loadPlugin(manifest: PluginManifestDto, basePath: string) { + return this.db.transaction().execute(async (tx) => { + // Upsert the plugin + const plugin = await tx + .insertInto('plugin') + .values({ + name: manifest.name, + title: manifest.title, + description: manifest.description, + author: manifest.author, + version: manifest.version, + wasmPath: `${basePath}/${manifest.wasm.path}`, + }) + .onConflict((oc) => + oc.column('name').doUpdateSet({ + title: manifest.title, + description: manifest.description, + author: manifest.author, + version: manifest.version, + wasmPath: `${basePath}/${manifest.wasm.path}`, + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + + const filters = manifest.filters + ? await tx + .insertInto('plugin_filter') + .values( + manifest.filters.map((filter) => ({ + pluginId: plugin.id, + methodName: filter.methodName, + title: filter.title, + description: filter.description, + supportedContexts: filter.supportedContexts, + schema: filter.schema, + })), + ) + .onConflict((oc) => + oc.column('methodName').doUpdateSet((eb) => ({ + pluginId: eb.ref('excluded.pluginId'), + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + supportedContexts: eb.ref('excluded.supportedContexts'), + schema: eb.ref('excluded.schema'), + })), + ) + .returningAll() + .execute() + : []; + + const actions = manifest.actions + ? await tx + .insertInto('plugin_action') + .values( + manifest.actions.map((action) => ({ + pluginId: plugin.id, + methodName: action.methodName, + title: action.title, + description: action.description, + supportedContexts: action.supportedContexts, + schema: action.schema, + })), + ) + .onConflict((oc) => + oc.column('methodName').doUpdateSet((eb) => ({ + pluginId: eb.ref('excluded.pluginId'), + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + supportedContexts: eb.ref('excluded.supportedContexts'), + schema: eb.ref('excluded.schema'), + })), + ) + .returningAll() + .execute() + : []; + + return { plugin, filters, actions }; + }); + } + + async readDirectory(path: string) { + return readdir(path, { withFileTypes: true }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getPlugin(id: string) { + return this.db + .selectFrom('plugin') + .select((eb) => [ + ...columns.plugin, + jsonArrayFrom( + eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), + ).as('filters'), + jsonArrayFrom( + eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), + ).as('actions'), + ]) + .where('plugin.id', '=', id) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.STRING] }) + getPluginByName(name: string) { + return this.db + .selectFrom('plugin') + .select((eb) => [ + ...columns.plugin, + jsonArrayFrom( + eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), + ).as('filters'), + jsonArrayFrom( + eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), + ).as('actions'), + ]) + .where('plugin.name', '=', name) + .executeTakeFirst(); + } + + @GenerateSql() + getAllPlugins() { + return this.db + .selectFrom('plugin') + .select((eb) => [ + ...columns.plugin, + jsonArrayFrom( + eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), + ).as('filters'), + jsonArrayFrom( + eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), + ).as('actions'), + ]) + .orderBy('plugin.name') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFilter(id: string) { + return this.db.selectFrom('plugin_filter').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFiltersByPlugin(pluginId: string) { + return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getAction(id: string) { + return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getActionsByPlugin(pluginId: string) { + return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute(); + } +} diff --git a/server/src/repositories/shared-link-asset.repository.ts b/server/src/repositories/shared-link-asset.repository.ts index ab164683ca..1136546455 100644 --- a/server/src/repositories/shared-link-asset.repository.ts +++ b/server/src/repositories/shared-link-asset.repository.ts @@ -6,15 +6,15 @@ import { DB } from 'src/schema'; export class SharedLinkAssetRepository { constructor(@InjectKysely() private db: Kysely) {} - async remove(sharedLinkId: string, assetsId: string[]) { + async remove(sharedLinkId: string, assetId: string[]) { const deleted = await this.db .deleteFrom('shared_link_asset') - .where('shared_link_asset.sharedLinksId', '=', sharedLinkId) - .where('shared_link_asset.assetsId', 'in', assetsId) - .returning('assetsId') + .where('shared_link_asset.sharedLinkId', '=', sharedLinkId) + .where('shared_link_asset.assetId', 'in', assetId) + .returning('assetId') .execute(); - return deleted.map((row) => row.assetsId); + return deleted.map((row) => row.assetId); } @GenerateSql({ params: [{ sourceAssetId: DummyValue.UUID, targetAssetId: DummyValue.UUID }] }) @@ -24,8 +24,8 @@ export class SharedLinkAssetRepository { .expression((eb) => eb .selectFrom('shared_link_asset') - .select((eb) => [eb.val(targetAssetId).as('assetsId'), 'shared_link_asset.sharedLinksId']) - .where('shared_link_asset.assetsId', '=', sourceAssetId), + .select((eb) => [eb.val(targetAssetId).as('assetId'), 'shared_link_asset.sharedLinkId']) + .where('shared_link_asset.assetId', '=', sourceAssetId), ) .onConflict((oc) => oc.doNothing()) .execute(); diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index cdade25f76..7bfa9ac6ae 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -28,8 +28,8 @@ export class SharedLinkRepository { (eb) => eb .selectFrom('shared_link_asset') - .whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinksId') - .innerJoin('asset', 'asset.id', 'shared_link_asset.assetsId') + .whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId') + .innerJoin('asset', 'asset.id', 'shared_link_asset.assetId') .where('asset.deletedAt', 'is', null) .selectAll('asset') .innerJoinLateral( @@ -53,13 +53,13 @@ export class SharedLinkRepository { .selectAll('album') .whereRef('album.id', '=', 'shared_link.albumId') .where('album.deletedAt', 'is', null) - .leftJoin('album_asset', 'album_asset.albumsId', 'album.id') + .leftJoin('album_asset', 'album_asset.albumId', 'album.id') .leftJoinLateral( (eb) => eb .selectFrom('asset') .selectAll('asset') - .whereRef('album_asset.assetsId', '=', 'asset.id') + .whereRef('album_asset.assetId', '=', 'asset.id') .where('asset.deletedAt', 'is', null) .innerJoinLateral( (eb) => @@ -123,13 +123,13 @@ export class SharedLinkRepository { .selectFrom('shared_link') .selectAll('shared_link') .where('shared_link.userId', '=', userId) - .leftJoin('shared_link_asset', 'shared_link_asset.sharedLinksId', 'shared_link.id') + .leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id') .leftJoinLateral( (eb) => eb .selectFrom('asset') .select((eb) => eb.fn.jsonAgg('asset').as('assets')) - .whereRef('asset.id', '=', 'shared_link_asset.assetsId') + .whereRef('asset.id', '=', 'shared_link_asset.assetId') .where('asset.deletedAt', 'is', null) .as('assets'), (join) => join.onTrue(), @@ -215,7 +215,7 @@ export class SharedLinkRepository { if (entity.assetIds && entity.assetIds.length > 0) { await this.db .insertInto('shared_link_asset') - .values(entity.assetIds!.map((assetsId) => ({ assetsId, sharedLinksId: id }))) + .values(entity.assetIds!.map((assetId) => ({ assetId, sharedLinkId: id }))) .execute(); } @@ -233,7 +233,7 @@ export class SharedLinkRepository { if (entity.assetIds && entity.assetIds.length > 0) { await this.db .insertInto('shared_link_asset') - .values(entity.assetIds!.map((assetsId) => ({ assetsId, sharedLinksId: id }))) + .values(entity.assetIds!.map((assetId) => ({ assetId, sharedLinkId: id }))) .execute(); } @@ -249,12 +249,12 @@ export class SharedLinkRepository { .selectFrom('shared_link') .selectAll('shared_link') .where('shared_link.id', '=', id) - .leftJoin('shared_link_asset', 'shared_link_asset.sharedLinksId', 'shared_link.id') + .leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id') .leftJoinLateral( (eb) => eb .selectFrom('asset') - .whereRef('asset.id', '=', 'shared_link_asset.assetsId') + .whereRef('asset.id', '=', 'shared_link_asset.assetId') .selectAll('asset') .innerJoinLateral( (eb) => diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index 44db6fbeb4..d313d682bd 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -33,8 +33,8 @@ const withAssets = (eb: ExpressionBuilder, withTags = false) => { eb .selectFrom('tag') .select(columns.tag) - .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId') - .whereRef('tag_asset.assetsId', '=', 'asset.id'), + .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId') + .whereRef('tag_asset.assetId', '=', 'asset.id'), ).as('tags'), ), ) diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 50f44d9f67..e901273b57 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -113,6 +113,10 @@ export class StorageRepository { } } + async readTextFile(filepath: string): Promise { + return fs.readFile(filepath, 'utf8'); + } + async checkFileExists(filepath: string, mode = constants.F_OK): Promise { try { await fs.access(filepath, mode); diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index d8be720f45..437e32da16 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -143,8 +143,8 @@ class AlbumSync extends BaseSync { getCreatedAfter({ nowId, userId, afterCreateId }: SyncCreatedAfterOptions) { return this.db .selectFrom('album_user') - .select(['albumsId as id', 'createId']) - .where('usersId', '=', userId) + .select(['albumId as id', 'createId']) + .where('userId', '=', userId) .$if(!!afterCreateId, (qb) => qb.where('createId', '>=', afterCreateId!)) .where('createId', '<', nowId) .orderBy('createId', 'asc') @@ -168,8 +168,8 @@ class AlbumSync extends BaseSync { const userId = options.userId; return this.upsertQuery('album', options) .distinctOn(['album.id', 'album.updateId']) - .leftJoin('album_user as album_users', 'album.id', 'album_users.albumsId') - .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_users.usersId', '=', userId)])) + .leftJoin('album_user as album_users', 'album.id', 'album_users.albumId') + .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_users.userId', '=', userId)])) .select([ 'album.id', 'album.ownerId', @@ -190,10 +190,10 @@ class AlbumAssetSync extends BaseSync { @GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true }) getBackfill(options: SyncBackfillOptions, albumId: string) { return this.backfillQuery('album_asset', options) - .innerJoin('asset', 'asset.id', 'album_asset.assetsId') + .innerJoin('asset', 'asset.id', 'album_asset.assetId') .select(columns.syncAsset) .select('album_asset.updateId') - .where('album_asset.albumsId', '=', albumId) + .where('album_asset.albumId', '=', albumId) .stream(); } @@ -201,13 +201,13 @@ class AlbumAssetSync extends BaseSync { getUpdates(options: SyncQueryOptions, albumToAssetAck: SyncAck) { const userId = options.userId; return this.upsertQuery('asset', options) - .innerJoin('album_asset', 'album_asset.assetsId', 'asset.id') + .innerJoin('album_asset', 'album_asset.assetId', 'asset.id') .select(columns.syncAsset) .select('asset.updateId') .where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about - .innerJoin('album', 'album.id', 'album_asset.albumsId') - .leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId') - .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)])) + .innerJoin('album', 'album.id', 'album_asset.albumId') + .leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId') + .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)])) .stream(); } @@ -216,11 +216,11 @@ class AlbumAssetSync extends BaseSync { const userId = options.userId; return this.upsertQuery('album_asset', options) .select('album_asset.updateId') - .innerJoin('asset', 'asset.id', 'album_asset.assetsId') + .innerJoin('asset', 'asset.id', 'album_asset.assetId') .select(columns.syncAsset) - .innerJoin('album', 'album.id', 'album_asset.albumsId') - .leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId') - .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)])) + .innerJoin('album', 'album.id', 'album_asset.albumId') + .leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId') + .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)])) .stream(); } } @@ -229,10 +229,10 @@ class AlbumAssetExifSync extends BaseSync { @GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true }) getBackfill(options: SyncBackfillOptions, albumId: string) { return this.backfillQuery('album_asset', options) - .innerJoin('asset_exif', 'asset_exif.assetId', 'album_asset.assetsId') + .innerJoin('asset_exif', 'asset_exif.assetId', 'album_asset.assetId') .select(columns.syncAssetExif) .select('album_asset.updateId') - .where('album_asset.albumsId', '=', albumId) + .where('album_asset.albumId', '=', albumId) .stream(); } @@ -240,13 +240,13 @@ class AlbumAssetExifSync extends BaseSync { getUpdates(options: SyncQueryOptions, albumToAssetAck: SyncAck) { const userId = options.userId; return this.upsertQuery('asset_exif', options) - .innerJoin('album_asset', 'album_asset.assetsId', 'asset_exif.assetId') + .innerJoin('album_asset', 'album_asset.assetId', 'asset_exif.assetId') .select(columns.syncAssetExif) .select('asset_exif.updateId') .where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send exif updates for assets that the client already knows about - .innerJoin('album', 'album.id', 'album_asset.albumsId') - .leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId') - .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)])) + .innerJoin('album', 'album.id', 'album_asset.albumId') + .leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId') + .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)])) .stream(); } @@ -255,11 +255,11 @@ class AlbumAssetExifSync extends BaseSync { const userId = options.userId; return this.upsertQuery('album_asset', options) .select('album_asset.updateId') - .innerJoin('asset_exif', 'asset_exif.assetId', 'album_asset.assetsId') + .innerJoin('asset_exif', 'asset_exif.assetId', 'album_asset.assetId') .select(columns.syncAssetExif) - .innerJoin('album', 'album.id', 'album_asset.albumsId') - .leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId') - .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)])) + .innerJoin('album', 'album.id', 'album_asset.albumId') + .leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId') + .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)])) .stream(); } } @@ -268,8 +268,8 @@ class AlbumToAssetSync extends BaseSync { @GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true }) getBackfill(options: SyncBackfillOptions, albumId: string) { return this.backfillQuery('album_asset', options) - .select(['album_asset.assetsId as assetId', 'album_asset.albumsId as albumId', 'album_asset.updateId']) - .where('album_asset.albumsId', '=', albumId) + .select(['album_asset.assetId as assetId', 'album_asset.albumId as albumId', 'album_asset.updateId']) + .where('album_asset.albumId', '=', albumId) .stream(); } @@ -290,8 +290,8 @@ class AlbumToAssetSync extends BaseSync { eb.parens( eb .selectFrom('album_user') - .select(['album_user.albumsId as id']) - .where('album_user.usersId', '=', userId), + .select(['album_user.albumId as id']) + .where('album_user.userId', '=', userId), ), ), ), @@ -307,10 +307,10 @@ class AlbumToAssetSync extends BaseSync { getUpserts(options: SyncQueryOptions) { const userId = options.userId; return this.upsertQuery('album_asset', options) - .select(['album_asset.assetsId as assetId', 'album_asset.albumsId as albumId', 'album_asset.updateId']) - .innerJoin('album', 'album.id', 'album_asset.albumsId') - .leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId') - .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)])) + .select(['album_asset.assetId as assetId', 'album_asset.albumId as albumId', 'album_asset.updateId']) + .innerJoin('album', 'album.id', 'album_asset.albumId') + .leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId') + .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)])) .stream(); } } @@ -321,7 +321,7 @@ class AlbumUserSync extends BaseSync { return this.backfillQuery('album_user', options) .select(columns.syncAlbumUser) .select('album_user.updateId') - .where('albumsId', '=', albumId) + .where('albumId', '=', albumId) .stream(); } @@ -342,8 +342,8 @@ class AlbumUserSync extends BaseSync { eb.parens( eb .selectFrom('album_user') - .select(['album_user.albumsId as id']) - .where('album_user.usersId', '=', userId), + .select(['album_user.albumId as id']) + .where('album_user.userId', '=', userId), ), ), ), @@ -363,7 +363,7 @@ class AlbumUserSync extends BaseSync { .select('album_user.updateId') .where((eb) => eb( - 'album_user.albumsId', + 'album_user.albumId', 'in', eb .selectFrom('album') @@ -373,8 +373,8 @@ class AlbumUserSync extends BaseSync { eb.parens( eb .selectFrom('album_user as albumUsers') - .select(['albumUsers.albumsId as id']) - .where('albumUsers.usersId', '=', userId), + .select(['albumUsers.albumId as id']) + .where('albumUsers.userId', '=', userId), ), ), ), @@ -550,7 +550,7 @@ class MemoryToAssetSync extends BaseSync { @GenerateSql({ params: [dummyQueryOptions], stream: true }) getUpserts(options: SyncQueryOptions) { return this.upsertQuery('memory_asset', options) - .select(['memoriesId as memoryId', 'assetsId as assetId']) + .select(['memoriesId as memoryId', 'assetId as assetId']) .select('updateId') .where('memoriesId', 'in', (eb) => eb.selectFrom('memory').select('id').where('ownerId', '=', options.userId)) .stream(); diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index d9c44f4ba4..d4572886af 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -97,9 +97,9 @@ export class TagRepository { const results = await this.db .selectFrom('tag_asset') - .select(['assetsId as assetId']) - .where('tagsId', '=', tagId) - .where('assetsId', 'in', assetIds) + .select(['assetId as assetId']) + .where('tagId', '=', tagId) + .where('assetId', 'in', assetIds) .execute(); return new Set(results.map(({ assetId }) => assetId)); @@ -114,7 +114,7 @@ export class TagRepository { await this.db .insertInto('tag_asset') - .values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId }))) + .values(assetIds.map((assetId) => ({ tagId, assetId }))) .execute(); } @@ -125,10 +125,10 @@ export class TagRepository { return; } - await this.db.deleteFrom('tag_asset').where('tagsId', '=', tagId).where('assetsId', 'in', assetIds).execute(); + await this.db.deleteFrom('tag_asset').where('tagId', '=', tagId).where('assetId', 'in', assetIds).execute(); } - @GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagsIds: [DummyValue.UUID] }]] }) + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagIds: DummyValue.UUID }]] }) @Chunked() upsertAssetIds(items: Insertable[]) { if (items.length === 0) { @@ -147,7 +147,7 @@ export class TagRepository { @Chunked({ paramIndex: 1 }) replaceAssetTags(assetId: string, tagIds: string[]) { return this.db.transaction().execute(async (tx) => { - await tx.deleteFrom('tag_asset').where('assetsId', '=', assetId).execute(); + await tx.deleteFrom('tag_asset').where('assetId', '=', assetId).execute(); if (tagIds.length === 0) { return; @@ -155,7 +155,7 @@ export class TagRepository { return tx .insertInto('tag_asset') - .values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId }))) + .values(tagIds.map((tagId) => ({ tagId, assetId }))) .onConflict((oc) => oc.doNothing()) .returningAll() .execute(); @@ -170,7 +170,7 @@ export class TagRepository { exists( selectFrom('tag_closure') .whereRef('tag.id', '=', 'tag_closure.id_ancestor') - .innerJoin('tag_asset', 'tag_closure.id_descendant', 'tag_asset.tagsId'), + .innerJoin('tag_asset', 'tag_closure.id_descendant', 'tag_asset.tagId'), ), ), ) diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts new file mode 100644 index 0000000000..4ae657cfbf --- /dev/null +++ b/server/src/repositories/workflow.repository.ts @@ -0,0 +1,139 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, Updateable } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { PluginTriggerType } from 'src/enum'; +import { DB } from 'src/schema'; +import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; + +@Injectable() +export class WorkflowRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID] }) + getWorkflow(id: string) { + return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getWorkflowsByOwner(ownerId: string) { + return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').execute(); + } + + @GenerateSql({ params: [PluginTriggerType.AssetCreate] }) + getWorkflowsByTrigger(type: PluginTriggerType) { + return this.db + .selectFrom('workflow') + .selectAll() + .where('triggerType', '=', type) + .where('enabled', '=', true) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, PluginTriggerType.AssetCreate] }) + getWorkflowByOwnerAndTrigger(ownerId: string, type: PluginTriggerType) { + return this.db + .selectFrom('workflow') + .selectAll() + .where('ownerId', '=', ownerId) + .where('triggerType', '=', type) + .where('enabled', '=', true) + .execute(); + } + + async createWorkflow( + workflow: Insertable, + filters: Insertable[], + actions: Insertable[], + ) { + return await this.db.transaction().execute(async (tx) => { + const createdWorkflow = await tx.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow(); + + if (filters.length > 0) { + const newFilters = filters.map((filter) => ({ + ...filter, + workflowId: createdWorkflow.id, + })); + + await tx.insertInto('workflow_filter').values(newFilters).execute(); + } + + if (actions.length > 0) { + const newActions = actions.map((action) => ({ + ...action, + workflowId: createdWorkflow.id, + })); + await tx.insertInto('workflow_action').values(newActions).execute(); + } + + return createdWorkflow; + }); + } + + async updateWorkflow( + id: string, + workflow: Updateable, + filters: Insertable[] | undefined, + actions: Insertable[] | undefined, + ) { + return await this.db.transaction().execute(async (trx) => { + if (Object.keys(workflow).length > 0) { + await trx.updateTable('workflow').set(workflow).where('id', '=', id).execute(); + } + + if (filters !== undefined) { + await trx.deleteFrom('workflow_filter').where('workflowId', '=', id).execute(); + if (filters.length > 0) { + const filtersWithWorkflowId = filters.map((filter) => ({ + ...filter, + workflowId: id, + })); + await trx.insertInto('workflow_filter').values(filtersWithWorkflowId).execute(); + } + } + + if (actions !== undefined) { + await trx.deleteFrom('workflow_action').where('workflowId', '=', id).execute(); + if (actions.length > 0) { + const actionsWithWorkflowId = actions.map((action) => ({ + ...action, + workflowId: id, + })); + await trx.insertInto('workflow_action').values(actionsWithWorkflowId).execute(); + } + } + + return await trx.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirstOrThrow(); + }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async deleteWorkflow(id: string) { + await this.db.deleteFrom('workflow').where('id', '=', id).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFilters(workflowId: string) { + return this.db + .selectFrom('workflow_filter') + .selectAll() + .where('workflowId', '=', workflowId) + .orderBy('order', 'asc') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async deleteFiltersByWorkflow(workflowId: string) { + await this.db.deleteFrom('workflow_filter').where('workflowId', '=', workflowId).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getActions(workflowId: string) { + return this.db + .selectFrom('workflow_action') + .selectAll() + .where('workflowId', '=', workflowId) + .orderBy('order', 'asc') + .execute(); + } +} diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index e255742b5d..385db37cf8 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -29,7 +29,7 @@ export const album_user_after_insert = registerFunction({ body: ` BEGIN UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp()) - WHERE "id" IN (SELECT DISTINCT "albumsId" FROM inserted_rows); + WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows); RETURN NULL; END`, }); @@ -139,8 +139,8 @@ export const album_asset_delete_audit = registerFunction({ body: ` BEGIN INSERT INTO album_asset_audit ("albumId", "assetId") - SELECT "albumsId", "assetsId" FROM OLD - WHERE "albumsId" IN (SELECT "id" FROM album WHERE "id" IN (SELECT "albumsId" FROM OLD)); + SELECT "albumId", "assetId" FROM OLD + WHERE "albumId" IN (SELECT "id" FROM album WHERE "id" IN (SELECT "albumId" FROM OLD)); RETURN NULL; END`, }); @@ -152,12 +152,12 @@ export const album_user_delete_audit = registerFunction({ body: ` BEGIN INSERT INTO album_audit ("albumId", "userId") - SELECT "albumsId", "usersId" + SELECT "albumId", "userId" FROM OLD; IF pg_trigger_depth() = 1 THEN INSERT INTO album_user_audit ("albumId", "userId") - SELECT "albumsId", "usersId" + SELECT "albumId", "userId" FROM OLD; END IF; @@ -185,7 +185,7 @@ export const memory_asset_delete_audit = registerFunction({ body: ` BEGIN INSERT INTO memory_asset_audit ("memoryId", "assetId") - SELECT "memoriesId", "assetsId" FROM OLD + SELECT "memoriesId", "assetId" FROM OLD WHERE "memoriesId" IN (SELECT "id" FROM memory WHERE "id" IN (SELECT "memoriesId" FROM OLD)); RETURN NULL; END`, diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 7f4bdbeed3..9e206826e6 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -53,6 +53,7 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; import { PersonTable } from 'src/schema/tables/person.table'; +import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; @@ -69,6 +70,7 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; +import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; import { Database, Extensions, Generated, Int8 } from 'src/sql-tools'; @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @@ -125,6 +127,12 @@ export class ImmichDatabase { UserMetadataAuditTable, UserTable, VersionHistoryTable, + PluginTable, + PluginFilterTable, + PluginActionTable, + WorkflowTable, + WorkflowFilterTable, + WorkflowActionTable, ]; functions = [ @@ -231,4 +239,12 @@ export interface DB { user_metadata_audit: UserMetadataAuditTable; version_history: VersionHistoryTable; + + plugin: PluginTable; + plugin_filter: PluginFilterTable; + plugin_action: PluginActionTable; + + workflow: WorkflowTable; + workflow_filter: WorkflowFilterTable; + workflow_action: WorkflowActionTable; } diff --git a/server/src/schema/migrations/1761755618862-FixColumnNames.ts b/server/src/schema/migrations/1761755618862-FixColumnNames.ts new file mode 100644 index 0000000000..25131a1640 --- /dev/null +++ b/server/src/schema/migrations/1761755618862-FixColumnNames.ts @@ -0,0 +1,99 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // rename columns + await sql`ALTER TABLE "album_asset" RENAME COLUMN "albumsId" TO "albumId";`.execute(db); + await sql`ALTER TABLE "album_asset" RENAME COLUMN "assetsId" TO "assetId";`.execute(db); + await sql`ALTER TABLE "album_user" RENAME COLUMN "albumsId" TO "albumId";`.execute(db); + await sql`ALTER TABLE "album_user" RENAME COLUMN "usersId" TO "userId";`.execute(db); + await sql`ALTER TABLE "memory_asset" RENAME COLUMN "assetsId" TO "assetId";`.execute(db); + await sql`ALTER TABLE "shared_link_asset" RENAME COLUMN "assetsId" TO "assetId";`.execute(db); + await sql`ALTER TABLE "shared_link_asset" RENAME COLUMN "sharedLinksId" TO "sharedLinkId";`.execute(db); + await sql`ALTER TABLE "tag_asset" RENAME COLUMN "assetsId" TO "assetId";`.execute(db); + await sql`ALTER TABLE "tag_asset" RENAME COLUMN "tagsId" TO "tagId";`.execute(db); + + // rename constraints + await sql`ALTER TABLE "album_asset" RENAME CONSTRAINT "album_asset_albumsId_fkey" TO "album_asset_albumId_fkey";`.execute(db); + await sql`ALTER TABLE "album_asset" RENAME CONSTRAINT "album_asset_assetsId_fkey" TO "album_asset_assetId_fkey";`.execute(db); + await sql`ALTER TABLE "album_user" RENAME CONSTRAINT "album_user_albumsId_fkey" TO "album_user_albumId_fkey";`.execute(db); + await sql`ALTER TABLE "album_user" RENAME CONSTRAINT "album_user_usersId_fkey" TO "album_user_userId_fkey";`.execute(db); + await sql`ALTER TABLE "memory_asset" RENAME CONSTRAINT "memory_asset_assetsId_fkey" TO "memory_asset_assetId_fkey";`.execute(db); + await sql`ALTER TABLE "shared_link_asset" RENAME CONSTRAINT "shared_link_asset_assetsId_fkey" TO "shared_link_asset_assetId_fkey";`.execute(db); + await sql`ALTER TABLE "shared_link_asset" RENAME CONSTRAINT "shared_link_asset_sharedLinksId_fkey" TO "shared_link_asset_sharedLinkId_fkey";`.execute(db); + await sql`ALTER TABLE "tag_asset" RENAME CONSTRAINT "tag_asset_assetsId_fkey" TO "tag_asset_assetId_fkey";`.execute(db); + await sql`ALTER TABLE "tag_asset" RENAME CONSTRAINT "tag_asset_tagsId_fkey" TO "tag_asset_tagId_fkey";`.execute(db); + + // rename indexes + await sql`ALTER INDEX "album_asset_albumsId_idx" RENAME TO "album_asset_albumId_idx";`.execute(db); + await sql`ALTER INDEX "album_asset_assetsId_idx" RENAME TO "album_asset_assetId_idx";`.execute(db); + await sql`ALTER INDEX "album_user_usersId_idx" RENAME TO "album_user_userId_idx";`.execute(db); + await sql`ALTER INDEX "album_user_albumsId_idx" RENAME TO "album_user_albumId_idx";`.execute(db); + await sql`ALTER INDEX "memory_asset_assetsId_idx" RENAME TO "memory_asset_assetId_idx";`.execute(db); + await sql`ALTER INDEX "shared_link_asset_sharedLinksId_idx" RENAME TO "shared_link_asset_sharedLinkId_idx";`.execute(db); + await sql`ALTER INDEX "shared_link_asset_assetsId_idx" RENAME TO "shared_link_asset_assetId_idx";`.execute(db); + await sql`ALTER INDEX "tag_asset_assetsId_idx" RENAME TO "tag_asset_assetId_idx";`.execute(db); + await sql`ALTER INDEX "tag_asset_tagsId_idx" RENAME TO "tag_asset_tagId_idx";`.execute(db); + await sql`ALTER INDEX "tag_asset_assetsId_tagsId_idx" RENAME TO "tag_asset_assetId_tagId_idx";`.execute(db); + + // update triggers and functions + await sql`CREATE OR REPLACE FUNCTION album_user_after_insert() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp()) + WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows); + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION album_asset_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO album_asset_audit ("albumId", "assetId") + SELECT "albumId", "assetId" FROM OLD + WHERE "albumId" IN (SELECT "id" FROM album WHERE "id" IN (SELECT "albumId" FROM OLD)); + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION album_user_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO album_audit ("albumId", "userId") + SELECT "albumId", "userId" + FROM OLD; + + IF pg_trigger_depth() = 1 THEN + INSERT INTO album_user_audit ("albumId", "userId") + SELECT "albumId", "userId" + FROM OLD; + END IF; + + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION memory_asset_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO memory_asset_audit ("memoryId", "assetId") + SELECT "memoriesId", "assetId" FROM OLD + WHERE "memoriesId" IN (SELECT "id" FROM memory WHERE "id" IN (SELECT "memoriesId" FROM OLD)); + RETURN NULL; + END + $$;`.execute(db); + + // update overrides + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"album_user_after_insert","sql":"CREATE OR REPLACE FUNCTION album_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE album SET \\"updatedAt\\" = clock_timestamp(), \\"updateId\\" = immich_uuid_v7(clock_timestamp())\\n WHERE \\"id\\" IN (SELECT DISTINCT \\"albumId\\" FROM inserted_rows);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_album_user_after_insert';`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"album_asset_delete_audit","sql":"CREATE OR REPLACE FUNCTION album_asset_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_asset_audit (\\"albumId\\", \\"assetId\\")\\n SELECT \\"albumId\\", \\"assetId\\" FROM OLD\\n WHERE \\"albumId\\" IN (SELECT \\"id\\" FROM album WHERE \\"id\\" IN (SELECT \\"albumId\\" FROM OLD));\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_album_asset_delete_audit';`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"album_user_delete_audit","sql":"CREATE OR REPLACE FUNCTION album_user_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"albumId\\", \\"userId\\"\\n FROM OLD;\\n\\n IF pg_trigger_depth() = 1 THEN\\n INSERT INTO album_user_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"albumId\\", \\"userId\\"\\n FROM OLD;\\n END IF;\\n\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_album_user_delete_audit';`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"memory_asset_delete_audit","sql":"CREATE OR REPLACE FUNCTION memory_asset_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO memory_asset_audit (\\"memoryId\\", \\"assetId\\")\\n SELECT \\"memoriesId\\", \\"assetId\\" FROM OLD\\n WHERE \\"memoriesId\\" IN (SELECT \\"id\\" FROM memory WHERE \\"id\\" IN (SELECT \\"memoriesId\\" FROM OLD));\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_memory_asset_delete_audit';`.execute(db); +} + +export function down() { + // not implemented +} diff --git a/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts b/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts new file mode 100644 index 0000000000..6dacc1056b --- /dev/null +++ b/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts @@ -0,0 +1,113 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "plugin" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "author" character varying NOT NULL, + "version" character varying NOT NULL, + "wasmPath" character varying NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "plugin_name_uq" UNIQUE ("name"), + CONSTRAINT "plugin_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db); + await sql`CREATE TABLE "plugin_filter" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "methodName" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "supportedContexts" character varying[] NOT NULL, + "schema" jsonb, + CONSTRAINT "plugin_filter_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_filter_methodName_uq" UNIQUE ("methodName"), + CONSTRAINT "plugin_filter_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_filter_supportedContexts_idx" ON "plugin_filter" USING gin ("supportedContexts");`.execute( + db, + ); + await sql`CREATE INDEX "plugin_filter_pluginId_idx" ON "plugin_filter" ("pluginId");`.execute(db); + await sql`CREATE INDEX "plugin_filter_methodName_idx" ON "plugin_filter" ("methodName");`.execute(db); + await sql`CREATE TABLE "plugin_action" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "methodName" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "supportedContexts" character varying[] NOT NULL, + "schema" jsonb, + CONSTRAINT "plugin_action_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_action_methodName_uq" UNIQUE ("methodName"), + CONSTRAINT "plugin_action_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_action_supportedContexts_idx" ON "plugin_action" USING gin ("supportedContexts");`.execute( + db, + ); + await sql`CREATE INDEX "plugin_action_pluginId_idx" ON "plugin_action" ("pluginId");`.execute(db); + await sql`CREATE INDEX "plugin_action_methodName_idx" ON "plugin_action" ("methodName");`.execute(db); + await sql`CREATE TABLE "workflow" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ownerId" uuid NOT NULL, + "triggerType" character varying NOT NULL, + "name" character varying, + "description" character varying NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "enabled" boolean NOT NULL DEFAULT true, + CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db); + await sql`CREATE TABLE "workflow_filter" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "workflowId" uuid NOT NULL, + "filterId" uuid NOT NULL, + "filterConfig" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_filter_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_filter_filterId_fkey" FOREIGN KEY ("filterId") REFERENCES "plugin_filter" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_filter_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_filter_filterId_idx" ON "workflow_filter" ("filterId");`.execute(db); + await sql`CREATE INDEX "workflow_filter_workflowId_order_idx" ON "workflow_filter" ("workflowId", "order");`.execute( + db, + ); + await sql`CREATE INDEX "workflow_filter_workflowId_idx" ON "workflow_filter" ("workflowId");`.execute(db); + await sql`CREATE TABLE "workflow_action" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "workflowId" uuid NOT NULL, + "actionId" uuid NOT NULL, + "actionConfig" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_action_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_action_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "plugin_action" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_action_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_action_actionId_idx" ON "workflow_action" ("actionId");`.execute(db); + await sql`CREATE INDEX "workflow_action_workflowId_order_idx" ON "workflow_action" ("workflowId", "order");`.execute( + db, + ); + await sql`CREATE INDEX "workflow_action_workflowId_idx" ON "workflow_action" ("workflowId");`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_filter_supportedContexts_idx', '{"type":"index","name":"plugin_filter_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_filter_supportedContexts_idx\\" ON \\"plugin_filter\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_action_supportedContexts_idx', '{"type":"index","name":"plugin_action_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_action_supportedContexts_idx\\" ON \\"plugin_action\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE "workflow";`.execute(db); + await sql`DROP TABLE "workflow_filter";`.execute(db); + await sql`DROP TABLE "workflow_action";`.execute(db); + + await sql`DROP TABLE "plugin";`.execute(db); + await sql`DROP TABLE "plugin_filter";`.execute(db); + await sql`DROP TABLE "plugin_action";`.execute(db); + + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db); +} diff --git a/server/src/schema/tables/activity.table.ts b/server/src/schema/tables/activity.table.ts index 128cf2eabd..dfa7c98e42 100644 --- a/server/src/schema/tables/activity.table.ts +++ b/server/src/schema/tables/activity.table.ts @@ -32,7 +32,7 @@ import { @ForeignKeyConstraint({ columns: ['albumId', 'assetId'], referenceTable: () => AlbumAssetTable, - referenceColumns: ['albumsId', 'assetsId'], + referenceColumns: ['albumId', 'assetId'], onUpdate: 'NO ACTION', onDelete: 'CASCADE', }) diff --git a/server/src/schema/tables/album-asset.table.ts b/server/src/schema/tables/album-asset.table.ts index c34546c3f3..dea271239b 100644 --- a/server/src/schema/tables/album-asset.table.ts +++ b/server/src/schema/tables/album-asset.table.ts @@ -22,10 +22,10 @@ import { }) export class AlbumAssetTable { @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true }) - albumsId!: string; + albumId!: string; @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true }) - assetsId!: string; + assetId!: string; @CreateDateColumn() createdAt!: Generated; diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 94383218da..761aabc1af 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -37,7 +37,7 @@ export class AlbumUserTable { nullable: false, primary: true, }) - albumsId!: string; + albumId!: string; @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', @@ -45,7 +45,7 @@ export class AlbumUserTable { nullable: false, primary: true, }) - usersId!: string; + userId!: string; @Column({ type: 'character varying', default: AlbumUserRole.Editor }) role!: Generated; diff --git a/server/src/schema/tables/memory-asset.table.ts b/server/src/schema/tables/memory-asset.table.ts index f535155233..b162000ca0 100644 --- a/server/src/schema/tables/memory-asset.table.ts +++ b/server/src/schema/tables/memory-asset.table.ts @@ -25,7 +25,7 @@ export class MemoryAssetTable { memoriesId!: string; @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) - assetsId!: string; + assetId!: string; @CreateDateColumn() createdAt!: Generated; diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts new file mode 100644 index 0000000000..3de7ca63c9 --- /dev/null +++ b/server/src/schema/tables/plugin.table.ts @@ -0,0 +1,95 @@ +import { PluginContext } from 'src/enum'; +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Index, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from 'src/sql-tools'; +import type { JSONSchema } from 'src/types/plugin-schema.types'; + +@Table('plugin') +export class PluginTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @Column({ index: true, unique: true }) + name!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column() + author!: string; + + @Column() + version!: string; + + @Column() + wasmPath!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; +} + +@Index({ columns: ['supportedContexts'], using: 'gin' }) +@Table('plugin_filter') +export class PluginFilterTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + @Column({ index: true }) + pluginId!: string; + + @Column({ index: true, unique: true }) + methodName!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column({ type: 'character varying', array: true }) + supportedContexts!: Generated; + + @Column({ type: 'jsonb', nullable: true }) + schema!: JSONSchema | null; +} + +@Index({ columns: ['supportedContexts'], using: 'gin' }) +@Table('plugin_action') +export class PluginActionTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + @Column({ index: true }) + pluginId!: string; + + @Column({ index: true, unique: true }) + methodName!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column({ type: 'character varying', array: true }) + supportedContexts!: Generated; + + @Column({ type: 'jsonb', nullable: true }) + schema!: JSONSchema | null; +} diff --git a/server/src/schema/tables/shared-link-asset.table.ts b/server/src/schema/tables/shared-link-asset.table.ts index 37b652c4ab..37e6a3d9f0 100644 --- a/server/src/schema/tables/shared-link-asset.table.ts +++ b/server/src/schema/tables/shared-link-asset.table.ts @@ -5,8 +5,8 @@ import { ForeignKeyColumn, Table } from 'src/sql-tools'; @Table('shared_link_asset') export class SharedLinkAssetTable { @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) - assetsId!: string; + assetId!: string; @ForeignKeyColumn(() => SharedLinkTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) - sharedLinksId!: string; + sharedLinkId!: string; } diff --git a/server/src/schema/tables/tag-asset.table.ts b/server/src/schema/tables/tag-asset.table.ts index bc02129217..3ea2361b4f 100644 --- a/server/src/schema/tables/tag-asset.table.ts +++ b/server/src/schema/tables/tag-asset.table.ts @@ -2,12 +2,12 @@ import { AssetTable } from 'src/schema/tables/asset.table'; import { TagTable } from 'src/schema/tables/tag.table'; import { ForeignKeyColumn, Index, Table } from 'src/sql-tools'; -@Index({ columns: ['assetsId', 'tagsId'] }) +@Index({ columns: ['assetId', 'tagId'] }) @Table('tag_asset') export class TagAssetTable { @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true, index: true }) - assetsId!: string; + assetId!: string; @ForeignKeyColumn(() => TagTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true, index: true }) - tagsId!: string; + tagId!: string; } diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts new file mode 100644 index 0000000000..8f7c9adb0d --- /dev/null +++ b/server/src/schema/tables/workflow.table.ts @@ -0,0 +1,78 @@ +import { PluginTriggerType } from 'src/enum'; +import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; +import { UserTable } from 'src/schema/tables/user.table'; +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Index, + PrimaryGeneratedColumn, + Table, + Timestamp, +} from 'src/sql-tools'; +import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; + +@Table('workflow') +export class WorkflowTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + ownerId!: string; + + @Column() + triggerType!: PluginTriggerType; + + @Column({ nullable: true }) + name!: string | null; + + @Column() + description!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; +} + +@Index({ columns: ['workflowId', 'order'] }) +@Index({ columns: ['filterId'] }) +@Table('workflow_filter') +export class WorkflowFilterTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: Generated; + + @ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + filterId!: string; + + @Column({ type: 'jsonb', nullable: true }) + filterConfig!: FilterConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} + +@Index({ columns: ['workflowId', 'order'] }) +@Index({ columns: ['actionId'] }) +@Table('workflow_action') +export class WorkflowActionTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: Generated; + + @ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + actionId!: string; + + @Column({ type: 'jsonb', nullable: true }) + actionConfig!: ActionConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index e22d486bba..fa8a9c6450 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -402,16 +402,16 @@ describe(AlbumService.name, () => { mocks.album.update.mockResolvedValue(albumStub.sharedWithAdmin); mocks.user.get.mockResolvedValue(userStub.user2); mocks.albumUser.create.mockResolvedValue({ - usersId: userStub.user2.id, - albumsId: albumStub.sharedWithAdmin.id, + userId: userStub.user2.id, + albumId: albumStub.sharedWithAdmin.id, role: AlbumUserRole.Editor, }); await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: authStub.user2.user.id }], }); expect(mocks.albumUser.create).toHaveBeenCalledWith({ - usersId: authStub.user2.user.id, - albumsId: albumStub.sharedWithAdmin.id, + userId: authStub.user2.user.id, + albumId: albumStub.sharedWithAdmin.id, }); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: albumStub.sharedWithAdmin.id, @@ -439,8 +439,8 @@ describe(AlbumService.name, () => { expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); expect(mocks.albumUser.delete).toHaveBeenCalledWith({ - albumsId: albumStub.sharedWithUser.id, - usersId: userStub.user1.id, + albumId: albumStub.sharedWithUser.id, + userId: userStub.user1.id, }); expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); }); @@ -467,8 +467,8 @@ describe(AlbumService.name, () => { expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); expect(mocks.albumUser.delete).toHaveBeenCalledWith({ - albumsId: albumStub.sharedWithUser.id, - usersId: authStub.user1.user.id, + albumId: albumStub.sharedWithUser.id, + userId: authStub.user1.user.id, }); }); @@ -480,8 +480,8 @@ describe(AlbumService.name, () => { expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); expect(mocks.albumUser.delete).toHaveBeenCalledWith({ - albumsId: albumStub.sharedWithUser.id, - usersId: authStub.user1.user.id, + albumId: albumStub.sharedWithUser.id, + userId: authStub.user1.user.id, }); }); @@ -515,7 +515,7 @@ describe(AlbumService.name, () => { role: AlbumUserRole.Editor, }); expect(mocks.albumUser.update).toHaveBeenCalledWith( - { albumsId: albumStub.sharedWithAdmin.id, usersId: userStub.admin.id }, + { albumId: albumStub.sharedWithAdmin.id, userId: userStub.admin.id }, { role: AlbumUserRole.Editor }, ); }); @@ -804,12 +804,12 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumsId: 'album-123', assetsId: 'asset-1' }, - { albumsId: 'album-123', assetsId: 'asset-2' }, - { albumsId: 'album-123', assetsId: 'asset-3' }, - { albumsId: 'album-321', assetsId: 'asset-1' }, - { albumsId: 'album-321', assetsId: 'asset-2' }, - { albumsId: 'album-321', assetsId: 'asset-3' }, + { albumId: 'album-123', assetId: 'asset-1' }, + { albumId: 'album-123', assetId: 'asset-2' }, + { albumId: 'album-123', assetId: 'asset-3' }, + { albumId: 'album-321', assetId: 'asset-1' }, + { albumId: 'album-321', assetId: 'asset-2' }, + { albumId: 'album-321', assetId: 'asset-3' }, ]); }); @@ -840,12 +840,12 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-id', }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumsId: 'album-123', assetsId: 'asset-1' }, - { albumsId: 'album-123', assetsId: 'asset-2' }, - { albumsId: 'album-123', assetsId: 'asset-3' }, - { albumsId: 'album-321', assetsId: 'asset-1' }, - { albumsId: 'album-321', assetsId: 'asset-2' }, - { albumsId: 'album-321', assetsId: 'asset-3' }, + { albumId: 'album-123', assetId: 'asset-1' }, + { albumId: 'album-123', assetId: 'asset-2' }, + { albumId: 'album-123', assetId: 'asset-3' }, + { albumId: 'album-321', assetId: 'asset-1' }, + { albumId: 'album-321', assetId: 'asset-2' }, + { albumId: 'album-321', assetId: 'asset-3' }, ]); }); @@ -876,12 +876,12 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumsId: 'album-123', assetsId: 'asset-1' }, - { albumsId: 'album-123', assetsId: 'asset-2' }, - { albumsId: 'album-123', assetsId: 'asset-3' }, - { albumsId: 'album-321', assetsId: 'asset-1' }, - { albumsId: 'album-321', assetsId: 'asset-2' }, - { albumsId: 'album-321', assetsId: 'asset-3' }, + { albumId: 'album-123', assetId: 'asset-1' }, + { albumId: 'album-123', assetId: 'asset-2' }, + { albumId: 'album-123', assetId: 'asset-3' }, + { albumId: 'album-321', assetId: 'asset-1' }, + { albumId: 'album-321', assetId: 'asset-2' }, + { albumId: 'album-321', assetId: 'asset-3' }, ]); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { id: 'album-123', @@ -936,9 +936,9 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumsId: 'album-123', assetsId: 'asset-1' }, - { albumsId: 'album-123', assetsId: 'asset-2' }, - { albumsId: 'album-123', assetsId: 'asset-3' }, + { albumId: 'album-123', assetId: 'asset-1' }, + { albumId: 'album-123', assetId: 'asset-2' }, + { albumId: 'album-123', assetId: 'asset-3' }, ]); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { id: 'album-123', @@ -977,12 +977,12 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumsId: 'album-123', assetsId: 'asset-1' }, - { albumsId: 'album-123', assetsId: 'asset-2' }, - { albumsId: 'album-123', assetsId: 'asset-3' }, - { albumsId: 'album-321', assetsId: 'asset-1' }, - { albumsId: 'album-321', assetsId: 'asset-2' }, - { albumsId: 'album-321', assetsId: 'asset-3' }, + { albumId: 'album-123', assetId: 'asset-1' }, + { albumId: 'album-123', assetId: 'asset-2' }, + { albumId: 'album-123', assetId: 'asset-3' }, + { albumId: 'album-321', assetId: 'asset-1' }, + { albumId: 'album-321', assetId: 'asset-2' }, + { albumId: 'album-321', assetId: 'asset-3' }, ]); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, @@ -1014,9 +1014,9 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ - { albumsId: 'album-321', assetsId: 'asset-1' }, - { albumsId: 'album-321', assetsId: 'asset-2' }, - { albumsId: 'album-321', assetsId: 'asset-3' }, + { albumId: 'album-321', assetId: 'asset-1' }, + { albumId: 'album-321', assetId: 'asset-2' }, + { albumId: 'album-321', assetId: 'asset-3' }, ]); }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index dd12e31892..18747dbc3a 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -215,7 +215,7 @@ export class AlbumService extends BaseService { return results; } - const albumAssetValues: { albumsId: string; assetsId: string }[] = []; + const albumAssetValues: { albumId: string; assetId: string }[] = []; const events: { id: string; recipients: string[] }[] = []; for (const albumId of allowedAlbumIds) { const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]); @@ -228,7 +228,7 @@ export class AlbumService extends BaseService { results.success = true; for (const assetId of notPresentAssetIds) { - albumAssetValues.push({ albumsId: albumId, assetsId: assetId }); + albumAssetValues.push({ albumId, assetId }); } await this.albumRepository.update(albumId, { id: albumId, @@ -289,7 +289,7 @@ export class AlbumService extends BaseService { throw new BadRequestException('User not found'); } - await this.albumUserRepository.create({ usersId: userId, albumsId: id, role }); + await this.albumUserRepository.create({ userId, albumId: id, role }); await this.eventRepository.emit('AlbumInvite', { id, userId }); } @@ -317,12 +317,12 @@ export class AlbumService extends BaseService { await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] }); } - await this.albumUserRepository.delete({ albumsId: id, usersId: userId }); + await this.albumUserRepository.delete({ albumId: id, userId }); } async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise { await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] }); - await this.albumUserRepository.update({ albumsId: id, usersId: userId }, { role: dto.role }); + await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); } private async findOrFail(id: string, options: AlbumInfoOptions) { diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index 143b470750..0ec2a65f92 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -7,7 +7,6 @@ import { ONE_HOUR } from 'src/constants'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { AuthService } from 'src/services/auth.service'; -import { JobService } from 'src/services/job.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { VersionService } from 'src/services/version.service'; import { OpenGraphTags } from 'src/utils/misc'; @@ -40,7 +39,6 @@ const render = (index: string, meta: OpenGraphTags) => { export class ApiService { constructor( private authService: AuthService, - private jobService: JobService, private sharedLinkService: SharedLinkService, private versionService: VersionService, private configRepository: ConfigRepository, @@ -65,9 +63,10 @@ export class ApiService { } return async (request: Request, res: Response, next: NextFunction) => { + const method = request.method.toLowerCase(); if ( request.url.startsWith('/api') || - request.method.toLowerCase() !== 'get' || + (method !== 'get' && method !== 'head') || excludePaths.some((item) => request.url.startsWith(item)) ) { return next(); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index a338c30d78..f32385b937 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -12,6 +12,7 @@ import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService } from 'src/services/asset-media.service'; +import { UploadBody } from 'src/types'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -35,10 +36,10 @@ const uploadFile = { size: 1000, }, }, - filename: (fieldName: UploadFieldName, filename: string) => { + filename: (fieldName: UploadFieldName, filename: string, body?: UploadBody) => { return { auth: authStub.admin, - body: {}, + body: body || {}, fieldName, file: { uuid: 'random-uuid', @@ -263,6 +264,15 @@ describe(AssetMediaService.name, () => { }); }); } + + it('should prefer filename from body over name from path', () => { + const pathFilename = 'invalid-file-name'; + const body = { filename: 'video.mov' }; + expect(() => sut.canUploadFile(uploadFile.filename(UploadFieldName.ASSET_DATA, pathFilename))).toThrowError( + BadRequestException, + ); + expect(sut.canUploadFile(uploadFile.filename(UploadFieldName.ASSET_DATA, pathFilename, body))).toEqual(true); + }); }); describe('getUploadFilename', () => { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 0747bd7b7b..4db60c349f 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -51,10 +51,10 @@ export class AssetMediaService extends BaseService { return { id: assetId, status: AssetMediaStatus.DUPLICATE }; } - canUploadFile({ auth, fieldName, file }: UploadRequest): true { + canUploadFile({ auth, fieldName, file, body }: UploadRequest): true { requireUploadAccess(auth); - const filename = file.originalName; + const filename = body.filename || file.originalName; switch (fieldName) { case UploadFieldName.ASSET_DATA: { @@ -426,6 +426,9 @@ export class AssetMediaService extends BaseService { } await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size }); + + await this.eventRepository.emit('AssetCreate', { asset }); + await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } }); return asset; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 51041c1b1a..2c6d07b635 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -35,6 +35,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; @@ -53,6 +54,7 @@ import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { UserTable } from 'src/schema/tables/user.table'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; @@ -88,6 +90,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ OcrRepository, PartnerRepository, PersonRepository, + PluginRepository, ProcessRepository, SearchRepository, ServerInfoRepository, @@ -105,6 +108,8 @@ export const BASE_SERVICE_DEPENDENCIES = [ UserRepository, VersionHistoryRepository, ViewRepository, + WebsocketRepository, + WorkflowRepository, ]; @Injectable() @@ -142,6 +147,7 @@ export class BaseService { protected ocrRepository: OcrRepository, protected partnerRepository: PartnerRepository, protected personRepository: PersonRepository, + protected pluginRepository: PluginRepository, protected processRepository: ProcessRepository, protected searchRepository: SearchRepository, protected serverInfoRepository: ServerInfoRepository, @@ -160,6 +166,7 @@ export class BaseService { protected versionRepository: VersionHistoryRepository, protected viewRepository: ViewRepository, protected websocketRepository: WebsocketRepository, + protected workflowRepository: WorkflowRepository, ) { this.logger.setContext(this.constructor.name); this.storageCore = StorageCore.create( diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 9a8b0fb2bf..9d09bdaa53 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -23,6 +23,8 @@ import { NotificationService } from 'src/services/notification.service'; import { OcrService } from 'src/services/ocr.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; +import { PluginService } from 'src/services/plugin.service'; +import { QueueService } from 'src/services/queue.service'; import { SearchService } from 'src/services/search.service'; import { ServerService } from 'src/services/server.service'; import { SessionService } from 'src/services/session.service'; @@ -42,6 +44,7 @@ import { UserAdminService } from 'src/services/user-admin.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; import { ViewService } from 'src/services/view.service'; +import { WorkflowService } from 'src/services/workflow.service'; export const services = [ ApiKeyService, @@ -69,6 +72,8 @@ export const services = [ OcrService, PartnerService, PersonService, + PluginService, + QueueService, SearchService, ServerService, SessionService, @@ -88,4 +93,5 @@ export const services = [ UserService, VersionService, ViewService, + WorkflowService, ]; diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 7a300ae7ae..c23b4f05df 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,6 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; -import { defaults, SystemConfig } from 'src/config'; -import { ImmichWorker, JobCommand, JobName, JobStatus, QueueName } from 'src/enum'; +import { ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { JobService } from 'src/services/job.service'; import { JobItem } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -20,209 +18,6 @@ describe(JobService.name, () => { expect(sut).toBeDefined(); }); - describe('onConfigUpdate', () => { - it('should update concurrency', () => { - sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); - - expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(16); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.StorageTemplateMigration, 1); - }); - }); - - describe('handleNightlyJobs', () => { - it('should run the scheduled jobs', async () => { - await sut.handleNightlyJobs(); - - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.AssetDeleteCheck }, - { name: JobName.UserDeleteCheck }, - { name: JobName.PersonCleanup }, - { name: JobName.MemoryCleanup }, - { name: JobName.SessionCleanup }, - { name: JobName.AuditTableCleanup }, - { name: JobName.AuditLogCleanup }, - { name: JobName.MemoryGenerate }, - { name: JobName.UserSyncUsage }, - { name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } }, - { name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } }, - ]); - }); - }); - - describe('getAllJobStatus', () => { - it('should get all job statuses', async () => { - mocks.job.getJobCounts.mockResolvedValue({ - active: 1, - completed: 1, - failed: 1, - delayed: 1, - waiting: 1, - paused: 1, - }); - mocks.job.getQueueStatus.mockResolvedValue({ - isActive: true, - isPaused: true, - }); - - const expectedJobStatus = { - jobCounts: { - active: 1, - completed: 1, - delayed: 1, - failed: 1, - waiting: 1, - paused: 1, - }, - queueStatus: { - isActive: true, - isPaused: true, - }, - }; - - await expect(sut.getAllJobsStatus()).resolves.toEqual({ - [QueueName.BackgroundTask]: expectedJobStatus, - [QueueName.DuplicateDetection]: expectedJobStatus, - [QueueName.SmartSearch]: expectedJobStatus, - [QueueName.MetadataExtraction]: expectedJobStatus, - [QueueName.Search]: expectedJobStatus, - [QueueName.StorageTemplateMigration]: expectedJobStatus, - [QueueName.Migration]: expectedJobStatus, - [QueueName.ThumbnailGeneration]: expectedJobStatus, - [QueueName.VideoConversion]: expectedJobStatus, - [QueueName.FaceDetection]: expectedJobStatus, - [QueueName.FacialRecognition]: expectedJobStatus, - [QueueName.Sidecar]: expectedJobStatus, - [QueueName.Library]: expectedJobStatus, - [QueueName.Notification]: expectedJobStatus, - [QueueName.BackupDatabase]: expectedJobStatus, - [QueueName.Ocr]: expectedJobStatus, - }); - }); - }); - - describe('handleCommand', () => { - it('should handle a pause command', async () => { - await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Pause, force: false }); - - expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.MetadataExtraction); - }); - - it('should handle a resume command', async () => { - await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Resume, force: false }); - - expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.MetadataExtraction); - }); - - it('should handle an empty command', async () => { - await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Empty, force: false }); - - expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.MetadataExtraction); - }); - - it('should not start a job that is already running', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); - - await expect( - sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false }), - ).rejects.toBeInstanceOf(BadRequestException); - - expect(mocks.job.queue).not.toHaveBeenCalled(); - expect(mocks.job.queueAll).not.toHaveBeenCalled(); - }); - - it('should handle a start video conversion command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEncodeVideoQueueAll, data: { force: false } }); - }); - - it('should handle a start storage template migration command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.StorageTemplateMigration, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.StorageTemplateMigration }); - }); - - it('should handle a start smart search command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.SmartSearch, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SmartSearchQueueAll, data: { force: false } }); - }); - - it('should handle a start metadata extraction command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.AssetExtractMetadataQueueAll, - data: { force: false }, - }); - }); - - it('should handle a start sidecar command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.Sidecar, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SidecarQueueAll, data: { force: false } }); - }); - - it('should handle a start thumbnail generation command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.ThumbnailGeneration, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.AssetGenerateThumbnailsQueueAll, - data: { force: false }, - }); - }); - - it('should handle a start face detection command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.FaceDetection, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetDetectFacesQueueAll, data: { force: false } }); - }); - - it('should handle a start facial recognition command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.FacialRecognition, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognitionQueueAll, data: { force: false } }); - }); - - it('should handle a start backup database command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await sut.handleCommand(QueueName.BackupDatabase, { command: JobCommand.Start, force: false }); - - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DatabaseBackup, data: { force: false } }); - }); - - it('should throw a bad request when an invalid queue is used', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await expect( - sut.handleCommand(QueueName.BackgroundTask, { command: JobCommand.Start, force: false }), - ).rejects.toBeInstanceOf(BadRequestException); - - expect(mocks.job.queue).not.toHaveBeenCalled(); - expect(mocks.job.queueAll).not.toHaveBeenCalled(); - }); - }); - describe('onJobRun', () => { it('should process a successful job', async () => { mocks.job.run.mockResolvedValue(JobStatus.Success); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index c483155b71..b57a203788 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,28 +1,12 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { ClassConstructor } from 'class-transformer'; -import { SystemConfig } from 'src/config'; import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; -import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; -import { - AssetType, - AssetVisibility, - BootstrapEventPriority, - CronJob, - DatabaseLock, - ImmichWorker, - JobCommand, - JobName, - JobStatus, - ManualJobName, - QueueCleanType, - QueueName, -} from 'src/enum'; -import { ArgOf, ArgsOf } from 'src/repositories/event.repository'; +import { JobCreateDto } from 'src/dtos/job.dto'; +import { AssetType, AssetVisibility, JobName, JobStatus, ManualJobName } from 'src/enum'; +import { ArgsOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; -import { ConcurrentQueueName, JobItem } from 'src/types'; +import { JobItem } from 'src/types'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; -import { handlePromiseError } from 'src/utils/misc'; const asJobItem = (dto: JobCreateDto): JobItem => { switch (dto.name) { @@ -56,196 +40,12 @@ const asJobItem = (dto: JobCreateDto): JobItem => { } }; -const asNightlyTasksCron = (config: SystemConfig) => { - const [hours, minutes] = config.nightlyTasks.startTime.split(':').map(Number); - return `${minutes} ${hours} * * *`; -}; - @Injectable() export class JobService extends BaseService { - private services: ClassConstructor[] = []; - private nightlyJobsLock = false; - - @OnEvent({ name: 'ConfigInit' }) - async onConfigInit({ newConfig: config }: ArgOf<'ConfigInit'>) { - if (this.worker === ImmichWorker.Microservices) { - this.updateQueueConcurrency(config); - return; - } - - this.nightlyJobsLock = await this.databaseRepository.tryLock(DatabaseLock.NightlyJobs); - if (this.nightlyJobsLock) { - const cronExpression = asNightlyTasksCron(config); - this.logger.debug(`Scheduling nightly jobs for ${cronExpression}`); - this.cronRepository.create({ - name: CronJob.NightlyJobs, - expression: cronExpression, - start: true, - onTick: () => handlePromiseError(this.handleNightlyJobs(), this.logger), - }); - } - } - - @OnEvent({ name: 'ConfigUpdate', server: true }) - onConfigUpdate({ newConfig: config }: ArgOf<'ConfigUpdate'>) { - if (this.worker === ImmichWorker.Microservices) { - this.updateQueueConcurrency(config); - return; - } - - if (this.nightlyJobsLock) { - const cronExpression = asNightlyTasksCron(config); - this.logger.debug(`Scheduling nightly jobs for ${cronExpression}`); - this.cronRepository.update({ name: CronJob.NightlyJobs, expression: cronExpression, start: true }); - } - } - - @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.JobService }) - onBootstrap() { - this.jobRepository.setup(this.services); - if (this.worker === ImmichWorker.Microservices) { - this.jobRepository.startWorkers(); - } - } - - private updateQueueConcurrency(config: SystemConfig) { - this.logger.debug(`Updating queue concurrency settings`); - for (const queueName of Object.values(QueueName)) { - let concurrency = 1; - if (this.isConcurrentQueue(queueName)) { - concurrency = config.job[queueName].concurrency; - } - this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); - this.jobRepository.setConcurrency(queueName, concurrency); - } - } - - setServices(services: ClassConstructor[]) { - this.services = services; - } - async create(dto: JobCreateDto): Promise { await this.jobRepository.queue(asJobItem(dto)); } - async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { - this.logger.debug(`Handling command: queue=${queueName},command=${dto.command},force=${dto.force}`); - - switch (dto.command) { - case JobCommand.Start: { - await this.start(queueName, dto); - break; - } - - case JobCommand.Pause: { - await this.jobRepository.pause(queueName); - break; - } - - case JobCommand.Resume: { - await this.jobRepository.resume(queueName); - break; - } - - case JobCommand.Empty: { - await this.jobRepository.empty(queueName); - break; - } - - case JobCommand.ClearFailed: { - const failedJobs = await this.jobRepository.clear(queueName, QueueCleanType.Failed); - this.logger.debug(`Cleared failed jobs: ${failedJobs}`); - break; - } - } - - return this.getJobStatus(queueName); - } - - async getJobStatus(queueName: QueueName): Promise { - const [jobCounts, queueStatus] = await Promise.all([ - this.jobRepository.getJobCounts(queueName), - this.jobRepository.getQueueStatus(queueName), - ]); - - return { jobCounts, queueStatus }; - } - - async getAllJobsStatus(): Promise { - const response = new AllJobStatusResponseDto(); - for (const queueName of Object.values(QueueName)) { - response[queueName] = await this.getJobStatus(queueName); - } - return response; - } - - private async start(name: QueueName, { force }: JobCommandDto): Promise { - const { isActive } = await this.jobRepository.getQueueStatus(name); - if (isActive) { - throw new BadRequestException(`Job is already running`); - } - - await this.eventRepository.emit('QueueStart', { name }); - - switch (name) { - case QueueName.VideoConversion: { - return this.jobRepository.queue({ name: JobName.AssetEncodeVideoQueueAll, data: { force } }); - } - - case QueueName.StorageTemplateMigration: { - return this.jobRepository.queue({ name: JobName.StorageTemplateMigration }); - } - - case QueueName.Migration: { - return this.jobRepository.queue({ name: JobName.FileMigrationQueueAll }); - } - - case QueueName.SmartSearch: { - return this.jobRepository.queue({ name: JobName.SmartSearchQueueAll, data: { force } }); - } - - case QueueName.DuplicateDetection: { - return this.jobRepository.queue({ name: JobName.AssetDetectDuplicatesQueueAll, data: { force } }); - } - - case QueueName.MetadataExtraction: { - return this.jobRepository.queue({ name: JobName.AssetExtractMetadataQueueAll, data: { force } }); - } - - case QueueName.Sidecar: { - return this.jobRepository.queue({ name: JobName.SidecarQueueAll, data: { force } }); - } - - case QueueName.ThumbnailGeneration: { - return this.jobRepository.queue({ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force } }); - } - - case QueueName.FaceDetection: { - return this.jobRepository.queue({ name: JobName.AssetDetectFacesQueueAll, data: { force } }); - } - - case QueueName.FacialRecognition: { - return this.jobRepository.queue({ name: JobName.FacialRecognitionQueueAll, data: { force } }); - } - - case QueueName.Library: { - return this.jobRepository.queue({ name: JobName.LibraryScanQueueAll, data: { force } }); - } - - case QueueName.BackupDatabase: { - return this.jobRepository.queue({ name: JobName.DatabaseBackup, data: { force } }); - } - - case QueueName.Ocr: { - return this.jobRepository.queue({ name: JobName.OcrQueueAll, data: { force } }); - } - - default: { - throw new BadRequestException(`Invalid job name: ${name}`); - } - } - } - @OnEvent({ name: 'JobRun' }) async onJobRun(...[queueName, job]: ArgsOf<'JobRun'>) { try { @@ -262,50 +62,6 @@ export class JobService extends BaseService { } } - private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { - return ![ - QueueName.FacialRecognition, - QueueName.StorageTemplateMigration, - QueueName.DuplicateDetection, - QueueName.BackupDatabase, - ].includes(name); - } - - async handleNightlyJobs() { - const config = await this.getConfig({ withCache: false }); - const jobs: JobItem[] = []; - - if (config.nightlyTasks.databaseCleanup) { - jobs.push( - { name: JobName.AssetDeleteCheck }, - { name: JobName.UserDeleteCheck }, - { name: JobName.PersonCleanup }, - { name: JobName.MemoryCleanup }, - { name: JobName.SessionCleanup }, - { name: JobName.AuditTableCleanup }, - { name: JobName.AuditLogCleanup }, - ); - } - - if (config.nightlyTasks.generateMemories) { - jobs.push({ name: JobName.MemoryGenerate }); - } - - if (config.nightlyTasks.syncQuotaUsage) { - jobs.push({ name: JobName.UserSyncUsage }); - } - - if (config.nightlyTasks.missingThumbnails) { - jobs.push({ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } }); - } - - if (config.nightlyTasks.clusterNewFaces) { - jobs.push({ name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } }); - } - - await this.jobRepository.queueAll(jobs); - } - /** * Queue follow up jobs */ diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 0adb390f6a..220216a2c8 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1,4 +1,5 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored'; +import { DateTime } from 'luxon'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { defaults } from 'src/config'; @@ -231,7 +232,7 @@ describe(MetadataService.name, () => { }); }); - it('should account for the server being in a non-UTC timezone', async () => { + it('should determine dateTimeOriginal regardless of the server time zone', async () => { process.env.TZ = 'America/Los_Angeles'; mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); @@ -239,7 +240,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - dateTimeOriginal: new Date('2022-01-01T08:00:00.000Z'), + dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'), }), ); @@ -856,6 +857,7 @@ describe(MetadataService.name, () => { tz: 'UTC-11:30', Rating: 3, }; + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags(tags); @@ -897,7 +899,7 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: dateForTest, - localDateTime: dateForTest, + localDateTime: DateTime.fromISO('1970-01-01T00:00:00.000Z').toJSDate(), }), ); }); @@ -1595,7 +1597,7 @@ describe(MetadataService.name, () => { const result = firstDateTime(tags); expect(result?.tag).toBe('SonyDateTime2'); - expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z'); + expect(result?.dateTime?.toISOString()).toBe('2023-07-07T07:00:00'); }); it('should respect full priority order with all date tags present', () => { @@ -1624,7 +1626,7 @@ describe(MetadataService.name, () => { const result = firstDateTime(tags); // Should use SubSecDateTimeOriginal as it has highest priority expect(result?.tag).toBe('SubSecDateTimeOriginal'); - expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-01-01T01:00:00.000Z'); + expect(result?.dateTime?.toISOString()).toBe('2023-01-01T01:00:00'); }); it('should handle missing SubSec tags and use available date tags', () => { @@ -1644,7 +1646,7 @@ describe(MetadataService.name, () => { const result = firstDateTime(tags); // Should use CreationDate when available expect(result?.tag).toBe('CreationDate'); - expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z'); + expect(result?.dateTime?.toISOString()).toBe('2023-07-07T07:00:00'); }); it('should handle invalid date formats gracefully', () => { @@ -1658,7 +1660,7 @@ describe(MetadataService.name, () => { const result = firstDateTime(tags); // Should skip invalid dates and use the first valid one expect(result?.tag).toBe('GPSDateTime'); - expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-10-10T10:00:00.000Z'); + expect(result?.dateTime?.toISOString()).toBe('2023-10-10T10:00:00'); }); it('should prefer CreationDate over CreateDate', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index b73a9b6bf0..746f62a944 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored'; import { Insertable } from 'kysely'; import _ from 'lodash'; -import { Duration } from 'luxon'; +import { DateTime, Duration } from 'luxon'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { join, parse } from 'node:path'; @@ -236,8 +236,8 @@ export class MetadataService extends BaseService { latitude: number | null = null, longitude: number | null = null; if (this.hasGeo(exifTags)) { - latitude = exifTags.GPSLatitude; - longitude = exifTags.GPSLongitude; + latitude = Number(exifTags.GPSLatitude); + longitude = Number(exifTags.GPSLongitude); if (reverseGeocoding.enabled) { geo = await this.mapRepository.reverseGeocode({ latitude, longitude }); } @@ -866,40 +866,47 @@ export class MetadataService extends BaseService { this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); } - let dateTimeOriginal = dateTime?.toDate(); - let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate(); + let dateTimeOriginal = dateTime?.toDateTime(); + + // do not let JavaScript use local timezone + if (dateTimeOriginal && !dateTime?.hasZone) { + dateTimeOriginal = dateTimeOriginal.setZone('UTC', { keepLocalTime: true }); + } + + // align with whatever timeZone we chose + dateTimeOriginal = dateTimeOriginal?.setZone(timeZone ?? 'UTC'); + + // store as "local time" + let localDateTime = dateTimeOriginal?.setZone('UTC', { keepLocalTime: true }); + if (!localDateTime || !dateTimeOriginal) { // FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet // birthtime is not available in Docker on macOS, so it appears as 0 - const earliestDate = new Date( + const earliestDate = DateTime.fromMillis( Math.min( asset.fileCreatedAt.getTime(), stats.birthtimeMs ? Math.min(stats.mtimeMs, stats.birthtimeMs) : stats.mtime.getTime(), ), ); this.logger.debug( - `No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`, + `No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`, ); dateTimeOriginal = localDateTime = earliestDate; } - this.logger.verbose( - `Found local date time ${localDateTime.toISOString()} for asset ${asset.id}: ${asset.originalPath}`, - ); + this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${asset.originalPath}`); return { - dateTimeOriginal, timeZone, - localDateTime, + localDateTime: localDateTime.toJSDate(), + dateTimeOriginal: dateTimeOriginal.toJSDate(), }; } - private hasGeo(tags: ImmichTags): tags is ImmichTags & { GPSLatitude: number; GPSLongitude: number } { - return ( - tags.GPSLatitude !== undefined && - tags.GPSLongitude !== undefined && - (tags.GPSLatitude !== 0 || tags.GPSLatitude !== 0) - ); + private hasGeo(tags: ImmichTags) { + const lat = Number(tags.GPSLatitude); + const lng = Number(tags.GPSLongitude); + return !Number.isNaN(lat) && !Number.isNaN(lng) && (lat !== 0 || lng !== 0); } private getAutoStackId(tags: ImmichTags | null): string | null { diff --git a/server/src/services/plugin-host.functions.ts b/server/src/services/plugin-host.functions.ts new file mode 100644 index 0000000000..50b1052b54 --- /dev/null +++ b/server/src/services/plugin-host.functions.ts @@ -0,0 +1,120 @@ +import { CurrentPlugin } from '@extism/extism'; +import { UnauthorizedException } from '@nestjs/common'; +import { Updateable } from 'kysely'; +import { Permission } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { requireAccess } from 'src/utils/access'; + +/** + * Plugin host functions that are exposed to WASM plugins via Extism. + * These functions allow plugins to interact with the Immich system. + */ +export class PluginHostFunctions { + constructor( + private assetRepository: AssetRepository, + private albumRepository: AlbumRepository, + private accessRepository: AccessRepository, + private cryptoRepository: CryptoRepository, + private logger: LoggingRepository, + private pluginJwtSecret: string, + ) {} + + /** + * Creates Extism host function bindings for the plugin. + * These are the functions that WASM plugins can call. + */ + getHostFunctions() { + return { + 'extism:host/user': { + updateAsset: (cp: CurrentPlugin, offs: bigint) => this.handleUpdateAsset(cp, offs), + addAssetToAlbum: (cp: CurrentPlugin, offs: bigint) => this.handleAddAssetToAlbum(cp, offs), + }, + }; + } + + /** + * Host function wrapper for updateAsset. + * Reads the input from the plugin, parses it, and calls the actual update function. + */ + private async handleUpdateAsset(cp: CurrentPlugin, offs: bigint) { + const input = JSON.parse(cp.read(offs)!.text()); + await this.updateAsset(input); + } + + /** + * Host function wrapper for addAssetToAlbum. + * Reads the input from the plugin, parses it, and calls the actual add function. + */ + private async handleAddAssetToAlbum(cp: CurrentPlugin, offs: bigint) { + const input = JSON.parse(cp.read(offs)!.text()); + await this.addAssetToAlbum(input); + } + + /** + * Validates the JWT token and returns the auth context. + */ + private validateToken(authToken: string): { userId: string } { + try { + const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.pluginJwtSecret); + if (!auth.userId) { + throw new UnauthorizedException('Invalid token: missing userId'); + } + return auth; + } catch (error) { + this.logger.error('Token validation failed:', error); + throw new UnauthorizedException('Invalid token'); + } + } + + /** + * Updates an asset with the given properties. + */ + async updateAsset(input: { authToken: string } & Updateable & { id: string }) { + const { authToken, id, ...assetData } = input; + + // Validate token + const auth = this.validateToken(authToken); + + // Check access to the asset + await requireAccess(this.accessRepository, { + auth: { user: { id: auth.userId } } as any, + permission: Permission.AssetUpdate, + ids: [id], + }); + + this.logger.log(`Updating asset ${id} -- ${JSON.stringify(assetData)}`); + await this.assetRepository.update({ id, ...assetData }); + } + + /** + * Adds an asset to an album. + */ + async addAssetToAlbum(input: { authToken: string; assetId: string; albumId: string }) { + const { authToken, assetId, albumId } = input; + + // Validate token + const auth = this.validateToken(authToken); + + // Check access to both the asset and the album + await requireAccess(this.accessRepository, { + auth: { user: { id: auth.userId } } as any, + permission: Permission.AssetRead, + ids: [assetId], + }); + + await requireAccess(this.accessRepository, { + auth: { user: { id: auth.userId } } as any, + permission: Permission.AlbumUpdate, + ids: [albumId], + }); + + this.logger.log(`Adding asset ${assetId} to album ${albumId}`); + await this.albumRepository.addAssetIds(albumId, [assetId]); + return 0; + } +} diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts new file mode 100644 index 0000000000..28d1ac56ca --- /dev/null +++ b/server/src/services/plugin.service.ts @@ -0,0 +1,317 @@ +import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validateOrReject } from 'class-validator'; +import { join } from 'node:path'; +import { Asset, WorkflowAction, WorkflowFilter } from 'src/database'; +import { OnEvent, OnJob } from 'src/decorators'; +import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto'; +import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +import { BaseService } from 'src/services/base.service'; +import { PluginHostFunctions } from 'src/services/plugin-host.functions'; +import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types'; + +interface WorkflowContext { + authToken: string; + asset: Asset; +} + +interface PluginInput { + authToken: string; + config: T; + data: { + asset: Asset; + }; +} + +@Injectable() +export class PluginService extends BaseService { + private pluginJwtSecret!: string; + private loadedPlugins: Map = new Map(); + private hostFunctions!: PluginHostFunctions; + + @OnEvent({ name: 'AppBootstrap' }) + async onBootstrap() { + this.pluginJwtSecret = this.cryptoRepository.randomBytesAsText(32); + + await this.loadPluginsFromManifests(); + + this.hostFunctions = new PluginHostFunctions( + this.assetRepository, + this.albumRepository, + this.accessRepository, + this.cryptoRepository, + this.logger, + this.pluginJwtSecret, + ); + + await this.loadPlugins(); + } + + // + // CRUD operations for plugins + // + async getAll(): Promise { + const plugins = await this.pluginRepository.getAllPlugins(); + return plugins.map((plugin) => mapPlugin(plugin)); + } + + async get(id: string): Promise { + const plugin = await this.pluginRepository.getPlugin(id); + if (!plugin) { + throw new BadRequestException('Plugin not found'); + } + return mapPlugin(plugin); + } + + /////////////////////////////////////////// + // Plugin Loader + ////////////////////////////////////////// + async loadPluginsFromManifests(): Promise { + // Load core plugin + const { resourcePaths, plugins } = this.configRepository.getEnv(); + const coreManifestPath = `${resourcePaths.corePlugin}/manifest.json`; + + const coreManifest = await this.readAndValidateManifest(coreManifestPath); + await this.loadPluginToDatabase(coreManifest, resourcePaths.corePlugin); + + this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`); + + // Load external plugins + if (plugins.enabled && plugins.installFolder) { + await this.loadExternalPlugins(plugins.installFolder); + } + } + + private async loadExternalPlugins(installFolder: string): Promise { + try { + const entries = await this.pluginRepository.readDirectory(installFolder); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const pluginFolder = join(installFolder, entry.name); + const manifestPath = join(pluginFolder, 'manifest.json'); + try { + const manifest = await this.readAndValidateManifest(manifestPath); + await this.loadPluginToDatabase(manifest, pluginFolder); + + this.logger.log(`Successfully processed external plugin: ${manifest.name} (version ${manifest.version})`); + } catch (error) { + this.logger.warn(`Failed to load external plugin from ${manifestPath}:`, error); + } + } + } catch (error) { + this.logger.error(`Failed to scan external plugins folder ${installFolder}:`, error); + } + } + + private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise { + const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name); + if (currentPlugin != null && currentPlugin.version === manifest.version) { + this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`); + return; + } + + const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath); + + this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`); + + for (const filter of filters) { + this.logger.log(`Upserted plugin filter: ${filter.methodName} (ID: ${filter.id})`); + } + + for (const action of actions) { + this.logger.log(`Upserted plugin action: ${action.methodName} (ID: ${action.id})`); + } + } + + private async readAndValidateManifest(manifestPath: string): Promise { + const content = await this.storageRepository.readTextFile(manifestPath); + const manifestData = JSON.parse(content); + const manifest = plainToInstance(PluginManifestDto, manifestData); + + await validateOrReject(manifest, { + whitelist: true, + forbidNonWhitelisted: true, + }); + + return manifest; + } + + /////////////////////////////////////////// + // Plugin Execution + /////////////////////////////////////////// + private async loadPlugins() { + const plugins = await this.pluginRepository.getAllPlugins(); + for (const plugin of plugins) { + try { + this.logger.debug(`Loading plugin: ${plugin.name} from ${plugin.wasmPath}`); + + const extismPlugin = await newPlugin(plugin.wasmPath, { + useWasi: true, + functions: this.hostFunctions.getHostFunctions(), + }); + + this.loadedPlugins.set(plugin.id, extismPlugin); + this.logger.log(`Successfully loaded plugin: ${plugin.name}`); + } catch (error) { + this.logger.error(`Failed to load plugin ${plugin.name}:`, error); + } + } + } + + @OnEvent({ name: 'AssetCreate' }) + async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) { + await this.handleTrigger(PluginTriggerType.AssetCreate, { + ownerId: asset.ownerId, + event: { userId: asset.ownerId, asset }, + }); + } + + private async handleTrigger( + triggerType: T, + params: { ownerId: string; event: WorkflowData[T] }, + ): Promise { + const workflows = await this.workflowRepository.getWorkflowByOwnerAndTrigger(params.ownerId, triggerType); + if (workflows.length === 0) { + return; + } + + const jobs: JobItem[] = workflows.map((workflow) => ({ + name: JobName.WorkflowRun, + data: { + id: workflow.id, + type: triggerType, + event: params.event, + } as IWorkflowJob, + })); + + await this.jobRepository.queueAll(jobs); + this.logger.debug(`Queued ${jobs.length} workflow execution jobs for trigger ${triggerType}`); + } + + @OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow }) + async handleWorkflowRun({ id: workflowId, type, event }: JobOf): Promise { + try { + const workflow = await this.workflowRepository.getWorkflow(workflowId); + if (!workflow) { + this.logger.error(`Workflow ${workflowId} not found`); + return JobStatus.Failed; + } + + const workflowFilters = await this.workflowRepository.getFilters(workflowId); + const workflowActions = await this.workflowRepository.getActions(workflowId); + + switch (type) { + case PluginTriggerType.AssetCreate: { + const data = event as WorkflowData[PluginTriggerType.AssetCreate]; + const asset = data.asset; + + const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret); + + const context = { + authToken, + asset, + }; + + const filtersPassed = await this.executeFilters(workflowFilters, context); + if (!filtersPassed) { + return JobStatus.Skipped; + } + + await this.executeActions(workflowActions, context); + this.logger.debug(`Workflow ${workflowId} executed successfully`); + return JobStatus.Success; + } + + case PluginTriggerType.PersonRecognized: { + this.logger.error('unimplemented'); + return JobStatus.Skipped; + } + + default: { + this.logger.error(`Unknown workflow trigger type: ${type}`); + return JobStatus.Failed; + } + } + } catch (error) { + this.logger.error(`Error executing workflow ${workflowId}:`, error); + return JobStatus.Failed; + } + } + + private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise { + for (const workflowFilter of workflowFilters) { + const filter = await this.pluginRepository.getFilter(workflowFilter.filterId); + if (!filter) { + this.logger.error(`Filter ${workflowFilter.filterId} not found`); + return false; + } + + const pluginInstance = this.loadedPlugins.get(filter.pluginId); + if (!pluginInstance) { + this.logger.error(`Plugin ${filter.pluginId} not loaded`); + return false; + } + + const filterInput: PluginInput = { + authToken: context.authToken, + config: workflowFilter.filterConfig, + data: { + asset: context.asset, + }, + }; + + this.logger.debug(`Calling filter ${filter.methodName} with input: ${JSON.stringify(filterInput)}`); + + const filterResult = await pluginInstance.call( + filter.methodName, + new TextEncoder().encode(JSON.stringify(filterInput)), + ); + + if (!filterResult) { + this.logger.error(`Filter ${filter.methodName} returned null`); + return false; + } + + const result = JSON.parse(filterResult.text()); + if (result.passed === false) { + this.logger.debug(`Filter ${filter.methodName} returned false, stopping workflow execution`); + return false; + } + } + + return true; + } + + private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise { + for (const workflowAction of workflowActions) { + const action = await this.pluginRepository.getAction(workflowAction.actionId); + if (!action) { + throw new Error(`Action ${workflowAction.actionId} not found`); + } + + const pluginInstance = this.loadedPlugins.get(action.pluginId); + if (!pluginInstance) { + throw new Error(`Plugin ${action.pluginId} not loaded`); + } + + const actionInput: PluginInput = { + authToken: context.authToken, + config: workflowAction.actionConfig, + data: { + asset: context.asset, + }, + }; + + this.logger.debug(`Calling action ${action.methodName} with input: ${JSON.stringify(actionInput)}`); + + await pluginInstance.call(action.methodName, JSON.stringify(actionInput)); + } + } +} diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts new file mode 100644 index 0000000000..5dce9476e2 --- /dev/null +++ b/server/src/services/queue.service.spec.ts @@ -0,0 +1,224 @@ +import { BadRequestException } from '@nestjs/common'; +import { defaults, SystemConfig } from 'src/config'; +import { ImmichWorker, JobName, QueueCommand, QueueName } from 'src/enum'; +import { QueueService } from 'src/services/queue.service'; +import { newTestService, ServiceMocks } from 'test/utils'; + +describe(QueueService.name, () => { + let sut: QueueService; + let mocks: ServiceMocks; + + beforeEach(() => { + ({ sut, mocks } = newTestService(QueueService)); + + mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('onConfigUpdate', () => { + it('should update concurrency', () => { + sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); + + expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(17); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.StorageTemplateMigration, 1); + }); + }); + + describe('handleNightlyJobs', () => { + it('should run the scheduled jobs', async () => { + await sut.handleNightlyJobs(); + + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.AssetDeleteCheck }, + { name: JobName.UserDeleteCheck }, + { name: JobName.PersonCleanup }, + { name: JobName.MemoryCleanup }, + { name: JobName.SessionCleanup }, + { name: JobName.AuditTableCleanup }, + { name: JobName.AuditLogCleanup }, + { name: JobName.MemoryGenerate }, + { name: JobName.UserSyncUsage }, + { name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } }, + { name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } }, + ]); + }); + }); + + describe('getAllJobStatus', () => { + it('should get all job statuses', async () => { + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + completed: 1, + failed: 1, + delayed: 1, + waiting: 1, + paused: 1, + }); + mocks.job.getQueueStatus.mockResolvedValue({ + isActive: true, + isPaused: true, + }); + + const expectedJobStatus = { + jobCounts: { + active: 1, + completed: 1, + delayed: 1, + failed: 1, + waiting: 1, + paused: 1, + }, + queueStatus: { + isActive: true, + isPaused: true, + }, + }; + + await expect(sut.getAll()).resolves.toEqual({ + [QueueName.BackgroundTask]: expectedJobStatus, + [QueueName.DuplicateDetection]: expectedJobStatus, + [QueueName.SmartSearch]: expectedJobStatus, + [QueueName.MetadataExtraction]: expectedJobStatus, + [QueueName.Search]: expectedJobStatus, + [QueueName.StorageTemplateMigration]: expectedJobStatus, + [QueueName.Migration]: expectedJobStatus, + [QueueName.ThumbnailGeneration]: expectedJobStatus, + [QueueName.VideoConversion]: expectedJobStatus, + [QueueName.FaceDetection]: expectedJobStatus, + [QueueName.FacialRecognition]: expectedJobStatus, + [QueueName.Sidecar]: expectedJobStatus, + [QueueName.Library]: expectedJobStatus, + [QueueName.Notification]: expectedJobStatus, + [QueueName.BackupDatabase]: expectedJobStatus, + [QueueName.Ocr]: expectedJobStatus, + [QueueName.Workflow]: expectedJobStatus, + }); + }); + }); + + describe('handleCommand', () => { + it('should handle a pause command', async () => { + await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Pause, force: false }); + + expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.MetadataExtraction); + }); + + it('should handle a resume command', async () => { + await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Resume, force: false }); + + expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.MetadataExtraction); + }); + + it('should handle an empty command', async () => { + await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Empty, force: false }); + + expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.MetadataExtraction); + }); + + it('should not start a job that is already running', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); + + await expect( + sut.runCommand(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + }); + + it('should handle a start video conversion command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEncodeVideoQueueAll, data: { force: false } }); + }); + + it('should handle a start storage template migration command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.StorageTemplateMigration, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.StorageTemplateMigration }); + }); + + it('should handle a start smart search command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.SmartSearch, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SmartSearchQueueAll, data: { force: false } }); + }); + + it('should handle a start metadata extraction command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.AssetExtractMetadataQueueAll, + data: { force: false }, + }); + }); + + it('should handle a start sidecar command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.Sidecar, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SidecarQueueAll, data: { force: false } }); + }); + + it('should handle a start thumbnail generation command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.ThumbnailGeneration, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.AssetGenerateThumbnailsQueueAll, + data: { force: false }, + }); + }); + + it('should handle a start face detection command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.FaceDetection, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetDetectFacesQueueAll, data: { force: false } }); + }); + + it('should handle a start facial recognition command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.FacialRecognition, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognitionQueueAll, data: { force: false } }); + }); + + it('should handle a start backup database command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.runCommand(QueueName.BackupDatabase, { command: QueueCommand.Start, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DatabaseBackup, data: { force: false } }); + }); + + it('should throw a bad request when an invalid queue is used', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await expect( + sut.runCommand(QueueName.BackgroundTask, { command: QueueCommand.Start, force: false }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/services/queue.service.ts b/server/src/services/queue.service.ts new file mode 100644 index 0000000000..bea665e8fd --- /dev/null +++ b/server/src/services/queue.service.ts @@ -0,0 +1,250 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ClassConstructor } from 'class-transformer'; +import { SystemConfig } from 'src/config'; +import { OnEvent } from 'src/decorators'; +import { QueueCommandDto, QueueResponseDto, QueuesResponseDto } from 'src/dtos/queue.dto'; +import { + BootstrapEventPriority, + CronJob, + DatabaseLock, + ImmichWorker, + JobName, + QueueCleanType, + QueueCommand, + QueueName, +} from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +import { BaseService } from 'src/services/base.service'; +import { ConcurrentQueueName, JobItem } from 'src/types'; +import { handlePromiseError } from 'src/utils/misc'; + +const asNightlyTasksCron = (config: SystemConfig) => { + const [hours, minutes] = config.nightlyTasks.startTime.split(':').map(Number); + return `${minutes} ${hours} * * *`; +}; + +@Injectable() +export class QueueService extends BaseService { + private services: ClassConstructor[] = []; + private nightlyJobsLock = false; + + @OnEvent({ name: 'ConfigInit' }) + async onConfigInit({ newConfig: config }: ArgOf<'ConfigInit'>) { + if (this.worker === ImmichWorker.Microservices) { + this.updateConcurrency(config); + return; + } + + this.nightlyJobsLock = await this.databaseRepository.tryLock(DatabaseLock.NightlyJobs); + if (this.nightlyJobsLock) { + const cronExpression = asNightlyTasksCron(config); + this.logger.debug(`Scheduling nightly jobs for ${cronExpression}`); + this.cronRepository.create({ + name: CronJob.NightlyJobs, + expression: cronExpression, + start: true, + onTick: () => handlePromiseError(this.handleNightlyJobs(), this.logger), + }); + } + } + + @OnEvent({ name: 'ConfigUpdate', server: true }) + onConfigUpdate({ newConfig: config }: ArgOf<'ConfigUpdate'>) { + if (this.worker === ImmichWorker.Microservices) { + this.updateConcurrency(config); + return; + } + + if (this.nightlyJobsLock) { + const cronExpression = asNightlyTasksCron(config); + this.logger.debug(`Scheduling nightly jobs for ${cronExpression}`); + this.cronRepository.update({ name: CronJob.NightlyJobs, expression: cronExpression, start: true }); + } + } + + @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.JobService }) + onBootstrap() { + this.jobRepository.setup(this.services); + if (this.worker === ImmichWorker.Microservices) { + this.jobRepository.startWorkers(); + } + } + + private updateConcurrency(config: SystemConfig) { + this.logger.debug(`Updating queue concurrency settings`); + for (const queueName of Object.values(QueueName)) { + let concurrency = 1; + if (this.isConcurrentQueue(queueName)) { + concurrency = config.job[queueName].concurrency; + } + this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); + this.jobRepository.setConcurrency(queueName, concurrency); + } + } + + setServices(services: ClassConstructor[]) { + this.services = services; + } + + async runCommand(name: QueueName, dto: QueueCommandDto): Promise { + this.logger.debug(`Handling command: queue=${name},command=${dto.command},force=${dto.force}`); + + switch (dto.command) { + case QueueCommand.Start: { + await this.start(name, dto); + break; + } + + case QueueCommand.Pause: { + await this.jobRepository.pause(name); + break; + } + + case QueueCommand.Resume: { + await this.jobRepository.resume(name); + break; + } + + case QueueCommand.Empty: { + await this.jobRepository.empty(name); + break; + } + + case QueueCommand.ClearFailed: { + const failedJobs = await this.jobRepository.clear(name, QueueCleanType.Failed); + this.logger.debug(`Cleared failed jobs: ${failedJobs}`); + break; + } + } + + return this.getByName(name); + } + + async getAll(): Promise { + const response = new QueuesResponseDto(); + for (const name of Object.values(QueueName)) { + response[name] = await this.getByName(name); + } + return response; + } + + async getByName(name: QueueName): Promise { + const [jobCounts, queueStatus] = await Promise.all([ + this.jobRepository.getJobCounts(name), + this.jobRepository.getQueueStatus(name), + ]); + + return { jobCounts, queueStatus }; + } + + private async start(name: QueueName, { force }: QueueCommandDto): Promise { + const { isActive } = await this.jobRepository.getQueueStatus(name); + if (isActive) { + throw new BadRequestException(`Job is already running`); + } + + await this.eventRepository.emit('QueueStart', { name }); + + switch (name) { + case QueueName.VideoConversion: { + return this.jobRepository.queue({ name: JobName.AssetEncodeVideoQueueAll, data: { force } }); + } + + case QueueName.StorageTemplateMigration: { + return this.jobRepository.queue({ name: JobName.StorageTemplateMigration }); + } + + case QueueName.Migration: { + return this.jobRepository.queue({ name: JobName.FileMigrationQueueAll }); + } + + case QueueName.SmartSearch: { + return this.jobRepository.queue({ name: JobName.SmartSearchQueueAll, data: { force } }); + } + + case QueueName.DuplicateDetection: { + return this.jobRepository.queue({ name: JobName.AssetDetectDuplicatesQueueAll, data: { force } }); + } + + case QueueName.MetadataExtraction: { + return this.jobRepository.queue({ name: JobName.AssetExtractMetadataQueueAll, data: { force } }); + } + + case QueueName.Sidecar: { + return this.jobRepository.queue({ name: JobName.SidecarQueueAll, data: { force } }); + } + + case QueueName.ThumbnailGeneration: { + return this.jobRepository.queue({ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force } }); + } + + case QueueName.FaceDetection: { + return this.jobRepository.queue({ name: JobName.AssetDetectFacesQueueAll, data: { force } }); + } + + case QueueName.FacialRecognition: { + return this.jobRepository.queue({ name: JobName.FacialRecognitionQueueAll, data: { force } }); + } + + case QueueName.Library: { + return this.jobRepository.queue({ name: JobName.LibraryScanQueueAll, data: { force } }); + } + + case QueueName.BackupDatabase: { + return this.jobRepository.queue({ name: JobName.DatabaseBackup, data: { force } }); + } + + case QueueName.Ocr: { + return this.jobRepository.queue({ name: JobName.OcrQueueAll, data: { force } }); + } + + default: { + throw new BadRequestException(`Invalid job name: ${name}`); + } + } + } + + private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { + return ![ + QueueName.FacialRecognition, + QueueName.StorageTemplateMigration, + QueueName.DuplicateDetection, + QueueName.BackupDatabase, + ].includes(name); + } + + async handleNightlyJobs() { + const config = await this.getConfig({ withCache: false }); + const jobs: JobItem[] = []; + + if (config.nightlyTasks.databaseCleanup) { + jobs.push( + { name: JobName.AssetDeleteCheck }, + { name: JobName.UserDeleteCheck }, + { name: JobName.PersonCleanup }, + { name: JobName.MemoryCleanup }, + { name: JobName.SessionCleanup }, + { name: JobName.AuditTableCleanup }, + { name: JobName.AuditLogCleanup }, + ); + } + + if (config.nightlyTasks.generateMemories) { + jobs.push({ name: JobName.MemoryGenerate }); + } + + if (config.nightlyTasks.syncQuotaUsage) { + jobs.push({ name: JobName.UserSyncUsage }); + } + + if (config.nightlyTasks.missingThumbnails) { + jobs.push({ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } }); + } + + if (config.nightlyTasks.clusterNewFaces) { + jobs.push({ name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } }); + } + + await this.jobRepository.queueAll(jobs); + } +} diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index b9a38e4b06..fbdd655bbc 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -40,6 +40,7 @@ const updatedConfig = Object.freeze({ [QueueName.VideoConversion]: { concurrency: 1 }, [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, + [QueueName.Workflow]: { concurrency: 5 }, }, backup: { database: { diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 6699c61970..6a630de6a1 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -192,12 +192,12 @@ describe(TagService.name, () => { mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.tag.upsertAssetIds.mockResolvedValue([ - { tagsId: 'tag-1', assetsId: 'asset-1' }, - { tagsId: 'tag-1', assetsId: 'asset-2' }, - { tagsId: 'tag-1', assetsId: 'asset-3' }, - { tagsId: 'tag-2', assetsId: 'asset-1' }, - { tagsId: 'tag-2', assetsId: 'asset-2' }, - { tagsId: 'tag-2', assetsId: 'asset-3' }, + { tagId: 'tag-1', assetId: 'asset-1' }, + { tagId: 'tag-1', assetId: 'asset-2' }, + { tagId: 'tag-1', assetId: 'asset-3' }, + { tagId: 'tag-2', assetId: 'asset-1' }, + { tagId: 'tag-2', assetId: 'asset-2' }, + { tagId: 'tag-2', assetId: 'asset-3' }, ]); await expect( sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }), @@ -205,12 +205,12 @@ describe(TagService.name, () => { count: 6, }); expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([ - { tagsId: 'tag-1', assetsId: 'asset-1' }, - { tagsId: 'tag-1', assetsId: 'asset-2' }, - { tagsId: 'tag-1', assetsId: 'asset-3' }, - { tagsId: 'tag-2', assetsId: 'asset-1' }, - { tagsId: 'tag-2', assetsId: 'asset-2' }, - { tagsId: 'tag-2', assetsId: 'asset-3' }, + { tagId: 'tag-1', assetId: 'asset-1' }, + { tagId: 'tag-1', assetId: 'asset-2' }, + { tagId: 'tag-1', assetId: 'asset-3' }, + { tagId: 'tag-2', assetId: 'asset-1' }, + { tagId: 'tag-2', assetId: 'asset-2' }, + { tagId: 'tag-2', assetId: 'asset-3' }, ]); }); }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 2fae4b55d0..3ee5d29b75 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -82,14 +82,14 @@ export class TagService extends BaseService { ]); const items: Insertable[] = []; - for (const tagsId of tagIds) { - for (const assetsId of assetIds) { - items.push({ tagsId, assetsId }); + for (const tagId of tagIds) { + for (const assetId of assetIds) { + items.push({ tagId, assetId }); } } const results = await this.tagRepository.upsertAssetIds(items); - for (const assetId of new Set(results.map((item) => item.assetsId))) { + for (const assetId of new Set(results.map((item) => item.assetId))) { await this.eventRepository.emit('AssetTag', { assetId }); } diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts new file mode 100644 index 0000000000..ae72187d7d --- /dev/null +++ b/server/src/services/workflow.service.ts @@ -0,0 +1,159 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Workflow } from 'src/database'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + mapWorkflowAction, + mapWorkflowFilter, + WorkflowCreateDto, + WorkflowResponseDto, + WorkflowUpdateDto, +} from 'src/dtos/workflow.dto'; +import { Permission, PluginContext, PluginTriggerType } from 'src/enum'; +import { pluginTriggers } from 'src/plugins'; + +import { BaseService } from 'src/services/base.service'; + +@Injectable() +export class WorkflowService extends BaseService { + async create(auth: AuthDto, dto: WorkflowCreateDto): Promise { + const trigger = this.getTriggerOrFail(dto.triggerType); + + const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context); + const actionInserts = await this.validateAndMapActions(dto.actions, trigger.context); + + const workflow = await this.workflowRepository.createWorkflow( + { + ownerId: auth.user.id, + triggerType: dto.triggerType, + name: dto.name, + description: dto.description || '', + enabled: dto.enabled ?? true, + }, + filterInserts, + actionInserts, + ); + + return this.mapWorkflow(workflow); + } + + async getAll(auth: AuthDto): Promise { + const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id); + + return Promise.all(workflows.map((workflow) => this.mapWorkflow(workflow))); + } + + async get(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] }); + const workflow = await this.findOrFail(id); + return this.mapWorkflow(workflow); + } + + async update(auth: AuthDto, id: string, dto: WorkflowUpdateDto): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [id] }); + + if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) { + throw new BadRequestException('No fields to update'); + } + + const workflow = await this.findOrFail(id); + const trigger = this.getTriggerOrFail(workflow.triggerType); + + const { filters, actions, ...workflowUpdate } = dto; + const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context)); + const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context)); + + const updatedWorkflow = await this.workflowRepository.updateWorkflow( + id, + workflowUpdate, + filterInserts, + actionInserts, + ); + + return this.mapWorkflow(updatedWorkflow); + } + + async delete(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] }); + await this.workflowRepository.deleteWorkflow(id); + } + + private async validateAndMapFilters( + filters: Array<{ filterId: string; filterConfig?: any }>, + requiredContext: PluginContext, + ) { + for (const dto of filters) { + const filter = await this.pluginRepository.getFilter(dto.filterId); + if (!filter) { + throw new BadRequestException(`Invalid filter ID: ${dto.filterId}`); + } + + if (!filter.supportedContexts.includes(requiredContext)) { + throw new BadRequestException( + `Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`, + ); + } + } + + return filters.map((dto, index) => ({ + filterId: dto.filterId, + filterConfig: dto.filterConfig || null, + order: index, + })); + } + + private async validateAndMapActions( + actions: Array<{ actionId: string; actionConfig?: any }>, + requiredContext: PluginContext, + ) { + for (const dto of actions) { + const action = await this.pluginRepository.getAction(dto.actionId); + if (!action) { + throw new BadRequestException(`Invalid action ID: ${dto.actionId}`); + } + if (!action.supportedContexts.includes(requiredContext)) { + throw new BadRequestException( + `Action "${action.title}" does not support ${requiredContext} context. Supported contexts: ${action.supportedContexts.join(', ')}`, + ); + } + } + + return actions.map((dto, index) => ({ + actionId: dto.actionId, + actionConfig: dto.actionConfig || null, + order: index, + })); + } + + private getTriggerOrFail(triggerType: PluginTriggerType) { + const trigger = pluginTriggers.find((t) => t.type === triggerType); + if (!trigger) { + throw new BadRequestException(`Invalid trigger type: ${triggerType}`); + } + return trigger; + } + + private async findOrFail(id: string) { + const workflow = await this.workflowRepository.getWorkflow(id); + if (!workflow) { + throw new BadRequestException('Workflow not found'); + } + return workflow; + } + + private async mapWorkflow(workflow: Workflow): Promise { + const filters = await this.workflowRepository.getFilters(workflow.id); + const actions = await this.workflowRepository.getActions(workflow.id); + + return { + id: workflow.id, + ownerId: workflow.ownerId, + triggerType: workflow.triggerType, + name: workflow.name, + description: workflow.description, + createdAt: workflow.createdAt.toISOString(), + enabled: workflow.enabled, + filters: filters.map((f) => mapWorkflowFilter(f)), + actions: actions.map((a) => mapWorkflowAction(a)), + }; + } +} diff --git a/server/src/types.ts b/server/src/types.ts index 66045521d0..ad947e3774 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,5 +1,6 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; +import { Asset } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -11,6 +12,7 @@ import { ImageFormat, JobName, MemoryType, + PluginTriggerType, QueueName, StorageFolder, SyncEntityType, @@ -263,6 +265,23 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { recipientId: string; } +export interface WorkflowData { + [PluginTriggerType.AssetCreate]: { + userId: string; + asset: Asset; + }; + [PluginTriggerType.PersonRecognized]: { + personId: string; + assetId: string; + }; +} + +export interface IWorkflowJob { + id: string; + type: T; + event: WorkflowData[T]; +} + export interface JobCounts { active: number; completed: number; @@ -374,7 +393,10 @@ export type JobItem = // OCR | { name: JobName.OcrQueueAll; data: IBaseJob } - | { name: JobName.Ocr; data: IEntityJob }; + | { name: JobName.Ocr; data: IEntityJob } + + // Workflow + | { name: JobName.WorkflowRun; data: IWorkflowJob }; export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; @@ -419,14 +441,16 @@ export interface UploadFile { size: number; } +export interface UploadBody { + filename?: string; + [key: string]: unknown; +} + export type UploadRequest = { auth: AuthDto | null; fieldName: UploadFieldName; file: UploadFile; - body: { - filename?: string; - [key: string]: unknown; - }; + body: UploadBody; }; export interface UploadFiles { @@ -497,6 +521,7 @@ export interface UserPreferences { }; memories: { enabled: boolean; + duration: number; }; people: { enabled: boolean; diff --git a/server/src/types/plugin-schema.types.ts b/server/src/types/plugin-schema.types.ts new file mode 100644 index 0000000000..793bb3c1ff --- /dev/null +++ b/server/src/types/plugin-schema.types.ts @@ -0,0 +1,35 @@ +/** + * JSON Schema types for plugin configuration schemas + * Based on JSON Schema Draft 7 + */ + +export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'; + +export interface JSONSchemaProperty { + type?: JSONSchemaType | JSONSchemaType[]; + description?: string; + default?: any; + enum?: any[]; + items?: JSONSchemaProperty; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | JSONSchemaProperty; +} + +export interface JSONSchema { + type: 'object'; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + description?: string; +} + +export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }; + +export interface FilterConfig { + [key: string]: ConfigValue; +} + +export interface ActionConfig { + [key: string]: ConfigValue; +} diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 7a0f701f74..f8d5f0ca08 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -298,6 +298,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.stack.checkOwnerAccess(auth.user.id, ids); } + case Permission.WorkflowRead: + case Permission.WorkflowUpdate: + case Permission.WorkflowDelete: { + return access.workflow.checkOwnerAccess(auth.user.id, ids); + } + default: { return new Set(); } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 8c05543eaa..0cc3788f1a 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -244,12 +244,12 @@ export function inAlbums(qb: SelectQueryBuilder, albumIds: st (eb) => eb .selectFrom('album_asset') - .select('assetsId') - .where('albumsId', '=', anyUuid(albumIds!)) - .groupBy('assetsId') - .having((eb) => eb.fn.count('albumsId').distinct(), '=', albumIds.length) + .select('assetId') + .where('albumId', '=', anyUuid(albumIds!)) + .groupBy('assetId') + .having((eb) => eb.fn.count('albumId').distinct(), '=', albumIds.length) .as('has_album'), - (join) => join.onRef('has_album.assetsId', '=', 'asset.id'), + (join) => join.onRef('has_album.assetId', '=', 'asset.id'), ); } @@ -258,13 +258,13 @@ export function hasTags(qb: SelectQueryBuilder, tagIds: strin (eb) => eb .selectFrom('tag_asset') - .select('assetsId') - .innerJoin('tag_closure', 'tag_asset.tagsId', 'tag_closure.id_descendant') + .select('assetId') + .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') .where('tag_closure.id_ancestor', '=', anyUuid(tagIds)) - .groupBy('assetsId') + .groupBy('assetId') .having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '>=', tagIds.length) .as('has_tags'), - (join) => join.onRef('has_tags.assetsId', '=', 'asset.id'), + (join) => join.onRef('has_tags.assetId', '=', 'asset.id'), ); } @@ -285,8 +285,8 @@ export function withTags(eb: ExpressionBuilder) { eb .selectFrom('tag') .select(columns.tag) - .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId') - .whereRef('asset.id', '=', 'tag_asset.assetsId'), + .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId') + .whereRef('asset.id', '=', 'tag_asset.assetId'), ).as('tags'); } @@ -299,8 +299,8 @@ export function withTagId(qb: SelectQueryBuilder, tagId: stri eb.exists( eb .selectFrom('tag_closure') - .innerJoin('tag_asset', 'tag_asset.tagsId', 'tag_closure.id_descendant') - .whereRef('tag_asset.assetsId', '=', 'asset.id') + .innerJoin('tag_asset', 'tag_asset.tagId', 'tag_closure.id_descendant') + .whereRef('tag_asset.assetId', '=', 'asset.id') .where('tag_closure.id_ancestor', '=', tagId), ), ); @@ -320,7 +320,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!)) .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) .$if(options.tagIds === null, (qb) => - qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('tag_asset').whereRef('assetsId', '=', 'asset.id')))), + qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('tag_asset').whereRef('assetId', '=', 'asset.id')))), ) .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) .$if(!!options.createdBefore, (qb) => qb.where('asset.createdAt', '<=', options.createdBefore!)) @@ -403,7 +403,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), ) .$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) => - qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetsId', '=', 'asset.id')))), + qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))), ) .$if(!!options.withExif, withExifInner) .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople)) diff --git a/server/src/utils/lifecycle.ts b/server/src/utils/lifecycle.ts deleted file mode 100644 index 16793f6922..0000000000 --- a/server/src/utils/lifecycle.ts +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env node -import { OpenAPIObject } from '@nestjs/swagger'; -import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { SemVer } from 'semver'; -import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION, NEXT_RELEASE } from 'src/constants'; - -const outputPath = resolve(process.cwd(), '../open-api/immich-openapi-specs.json'); -const spec = JSON.parse(readFileSync(outputPath).toString()) as OpenAPIObject; - -type Items = { - oldEndpoints: Endpoint[]; - newEndpoints: Endpoint[]; - oldProperties: Property[]; - newProperties: Property[]; -}; -type Endpoint = { url: string; method: string; endpoint: any }; -type Property = { schema: string; property: string }; - -const metadata: Record = {}; -const trackVersion = (version: string) => { - if (!metadata[version]) { - metadata[version] = { - oldEndpoints: [], - newEndpoints: [], - oldProperties: [], - newProperties: [], - }; - } - return metadata[version]; -}; - -for (const [url, methods] of Object.entries(spec.paths)) { - for (const [method, endpoint] of Object.entries(methods) as Array<[string, any]>) { - const deprecatedAt = endpoint[LIFECYCLE_EXTENSION]?.deprecatedAt; - if (deprecatedAt) { - trackVersion(deprecatedAt).oldEndpoints.push({ url, method, endpoint }); - } - - const addedAt = endpoint[LIFECYCLE_EXTENSION]?.addedAt; - if (addedAt) { - trackVersion(addedAt).newEndpoints.push({ url, method, endpoint }); - } - } -} - -for (const [schemaName, schema] of Object.entries(spec.components?.schemas || {})) { - for (const [propertyName, property] of Object.entries((schema as SchemaObject).properties || {})) { - const propertySchema = property as SchemaObject; - if (propertySchema.description?.startsWith(DEPRECATED_IN_PREFIX)) { - const deprecatedAt = propertySchema.description.replace(DEPRECATED_IN_PREFIX, '').trim(); - trackVersion(deprecatedAt).oldProperties.push({ schema: schemaName, property: propertyName }); - } - - if (propertySchema.description?.startsWith(ADDED_IN_PREFIX)) { - const addedAt = propertySchema.description.replace(ADDED_IN_PREFIX, '').trim(); - trackVersion(addedAt).newProperties.push({ schema: schemaName, property: propertyName }); - } - } -} - -const sortedVersions = Object.keys(metadata).sort((a, b) => { - if (a === NEXT_RELEASE) { - return -1; - } - - if (b === NEXT_RELEASE) { - return 1; - } - - return new SemVer(b).compare(new SemVer(a)); -}); - -for (const version of sortedVersions) { - const { oldEndpoints, newEndpoints, oldProperties, newProperties } = metadata[version]; - console.log(`\nChanges in ${version}`); - console.log('---------------------'); - for (const { url, method, endpoint } of oldEndpoints) { - console.log(`- Deprecated ${method.toUpperCase()} ${url} (${endpoint.operationId})`); - } - for (const { url, method, endpoint } of newEndpoints) { - console.log(`- Added ${method.toUpperCase()} ${url} (${endpoint.operationId})`); - } - for (const { schema, property } of oldProperties) { - console.log(`- Deprecated ${schema}.${property}`); - } - for (const { schema, property } of newProperties) { - console.log(`- Added ${schema}.${property}`); - } -} diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index b9741c3b44..eb548eab74 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -17,7 +17,7 @@ import path from 'node:path'; import picomatch from 'picomatch'; import parse from 'picomatch/lib/parse'; import { SystemConfig } from 'src/config'; -import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; +import { CLIP_MODEL_INFO, endpointTags, serverVersion } from 'src/constants'; import { extraSyncModels } from 'src/dtos/sync.dto'; import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -218,25 +218,16 @@ const patchOpenAPI = (document: OpenAPIObject) => { delete operation.summary; } + if (operation.description === '') { + delete operation.description; + } + if (operation.operationId) { // console.log(`${routeToErrorMessage(operation.operationId).padEnd(40)} (${operation.operationId})`); } - const adminOnly = operation[ApiCustomExtension.AdminOnly] ?? false; - const permission = operation[ApiCustomExtension.Permission]; - if (permission) { - let description = (operation.description || '').trim(); - if (description && !description.endsWith('.')) { - description += '. '; - } - - operation.description = - description + - `This endpoint ${adminOnly ? 'is an admin-only route, and ' : ''}requires the \`${permission}\` permission.`; - - if (operation.parameters) { - operation.parameters = _.orderBy(operation.parameters, 'name'); - } + if (operation.parameters) { + operation.parameters = _.orderBy(operation.parameters, 'name'); } } } @@ -245,7 +236,7 @@ const patchOpenAPI = (document: OpenAPIObject) => { }; export const useSwagger = (app: INestApplication, { write }: { write: boolean }) => { - const config = new DocumentBuilder() + const builder = new DocumentBuilder() .setTitle('Immich') .setDescription('Immich API') .setVersion(serverVersion.toString()) @@ -263,8 +254,12 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean }) }, MetadataKey.ApiKeySecurity, ) - .addServer('/api') - .build(); + .addServer('/api'); + + for (const [tag, description] of Object.entries(endpointTags)) { + builder.addTag(tag, description); + } + const config = builder.build(); const options: SwaggerDocumentOptions = { operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index 121bf2826d..b25369670a 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -16,6 +16,7 @@ const getDefaultPreferences = (): UserPreferences => { }, memories: { enabled: true, + duration: 5, }, people: { enabled: true, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index f332cc02e6..efcdc59793 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -2,6 +2,7 @@ import { Insertable, Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { createHash, randomBytes } from 'node:crypto'; +import { Stats } from 'node:fs'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; @@ -28,11 +29,14 @@ import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; +import { MapRepository } from 'src/repositories/map.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; +import { MetadataRepository } from 'src/repositories/metadata.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; @@ -46,6 +50,7 @@ import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { DB } from 'src/schema'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -61,7 +66,9 @@ import { TagAssetTable } from 'src/schema/tables/tag-asset.table'; import { TagTable } from 'src/schema/tables/tag.table'; import { UserTable } from 'src/schema/tables/user.table'; import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service'; +import { MetadataService } from 'src/services/metadata.service'; import { SyncService } from 'src/services/sync.service'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; import { automock, wait } from 'test/utils'; @@ -212,7 +219,7 @@ export class MediumTestContext { async newAlbumUser(dto: { albumId: string; userId: string; role?: AlbumUserRole }) { const { albumId, userId, role = AlbumUserRole.Editor } = dto; - const result = await this.get(AlbumUserRepository).create({ albumsId: albumId, usersId: userId, role }); + const result = await this.get(AlbumUserRepository).create({ albumId, userId, role }); return { albumUser: { albumId, userId, role }, result }; } @@ -255,9 +262,9 @@ export class MediumTestContext { async newTagAsset(tagBulkAssets: { tagIds: string[]; assetIds: string[] }) { const tagsAssets: Insertable[] = []; - for (const tagsId of tagBulkAssets.tagIds) { - for (const assetsId of tagBulkAssets.assetIds) { - tagsAssets.push({ tagsId, assetsId }); + for (const tagId of tagBulkAssets.tagIds) { + for (const assetId of tagBulkAssets.assetIds) { + tagsAssets.push({ tagId, assetId }); } } @@ -305,6 +312,63 @@ export class SyncTestContext extends MediumTestContext { } } +const mockDate = new Date('2024-06-01T12:00:00.000Z'); +const mockStats = { + mtime: mockDate, + atime: mockDate, + ctime: mockDate, + birthtime: mockDate, + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0, +}; + +export class ExifTestContext extends MediumTestContext { + constructor(database: Kysely) { + super(MetadataService, { + database, + real: [AssetRepository, AssetJobRepository, MetadataRepository, SystemMetadataRepository, TagRepository], + mock: [ConfigRepository, EventRepository, LoggingRepository, MapRepository, StorageRepository], + }); + + this.getMock(ConfigRepository).getEnv.mockReturnValue(mockEnvData({})); + this.getMock(EventRepository).emit.mockResolvedValue(); + this.getMock(MapRepository).reverseGeocode.mockResolvedValue({ country: null, state: null, city: null }); + this.getMock(StorageRepository).stat.mockResolvedValue(mockStats as Stats); + } + + getMockStats() { + return mockStats; + } + + getGps(assetId: string) { + return this.database + .selectFrom('asset_exif') + .select(['latitude', 'longitude']) + .where('assetId', '=', assetId) + .executeTakeFirstOrThrow(); + } + + getTags(assetId: string) { + return this.database + .selectFrom('tag') + .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId') + .where('tag_asset.assetId', '=', assetId) + .selectAll() + .execute(); + } + + getDates(assetId: string) { + return this.database + .selectFrom('asset') + .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') + .where('id', '=', assetId) + .select(['asset.fileCreatedAt', 'asset.localDateTime', 'asset_exif.dateTimeOriginal', 'asset_exif.timeZone']) + .executeTakeFirstOrThrow(); + } +} + const newRealRepository = (key: ClassConstructor, db: Kysely): T => { switch (key) { case AccessRepository: @@ -318,6 +382,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case OcrRepository: case PartnerRepository: case PersonRepository: + case PluginRepository: case SearchRepository: case SessionRepository: case SharedLinkRepository: @@ -327,7 +392,8 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case SyncCheckpointRepository: case SystemMetadataRepository: case UserRepository: - case VersionHistoryRepository: { + case VersionHistoryRepository: + case WorkflowRepository: { return new key(db); } @@ -344,6 +410,14 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { return new key(LoggingRepository.create()); } + case MetadataRepository: { + return new key(LoggingRepository.create()); + } + + case StorageRepository: { + return new key(LoggingRepository.create()); + } + case TagRepository: { return new key(db, LoggingRepository.create()); } @@ -371,16 +445,22 @@ const newMockRepository = (key: ClassConstructor) => { case OcrRepository: case PartnerRepository: case PersonRepository: + case PluginRepository: case SessionRepository: case SyncRepository: case SyncCheckpointRepository: case SystemMetadataRepository: case UserRepository: case VersionHistoryRepository: - case TagRepository: { + case TagRepository: + case WorkflowRepository: { return automock(key); } + case MapRepository: { + return automock(MapRepository, { args: [undefined, undefined, { setContext: () => {} }] }); + } + case TelemetryRepository: { return newTelemetryRepositoryMock(); } diff --git a/server/test/medium/specs/exif/exif-date-time.spec.ts b/server/test/medium/specs/exif/exif-date-time.spec.ts new file mode 100644 index 0000000000..e46f17855e --- /dev/null +++ b/server/test/medium/specs/exif/exif-date-time.spec.ts @@ -0,0 +1,65 @@ +import { Kysely } from 'kysely'; +import { DateTime } from 'luxon'; +import { resolve } from 'node:path'; +import { DB } from 'src/schema'; +import { ExifTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let database: Kysely; + +const setup = async (testAssetPath: string) => { + const ctx = new ExifTestContext(database); + + const { user } = await ctx.newUser(); + const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); + + return { ctx, sut: ctx.sut, asset }; +}; + +beforeAll(async () => { + database = await getKyselyDB(); +}); + +describe('exif date time', () => { + it('should prioritize DateTimeOriginal', async () => { + const { ctx, sut, asset } = await setup('metadata/dates/date-priority-test.jpg'); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.getDates(asset.id)).resolves.toEqual({ + timeZone: null, + dateTimeOriginal: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(), + localDateTime: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(), + fileCreatedAt: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(), + }); + }); + + it('should extract GPSDateTime with GPS coordinates ', async () => { + const { ctx, sut, asset } = await setup('metadata/dates/gps-datetime.jpg'); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.getDates(asset.id)).resolves.toEqual({ + timeZone: 'America/Los_Angeles', + dateTimeOriginal: DateTime.fromISO('2023-11-15T12:30:00.000Z').toJSDate(), + localDateTime: DateTime.fromISO('2023-11-15T04:30:00.000Z').toJSDate(), + fileCreatedAt: DateTime.fromISO('2023-11-15T12:30:00.000Z').toJSDate(), + }); + }); + + it('should ignore the TimeCreated tag', async () => { + const { ctx, sut, asset } = await setup('metadata/dates/time-created.jpg'); + + await sut.handleMetadataExtraction({ id: asset.id }); + + const stats = ctx.getMockStats(); + + await expect(ctx.getDates(asset.id)).resolves.toEqual({ + timeZone: null, + dateTimeOriginal: stats.mtime, + localDateTime: stats.mtime, + fileCreatedAt: stats.mtime, + }); + }); +}); diff --git a/server/test/medium/specs/exif/exif-gps.spec.ts b/server/test/medium/specs/exif/exif-gps.spec.ts new file mode 100644 index 0000000000..651321b599 --- /dev/null +++ b/server/test/medium/specs/exif/exif-gps.spec.ts @@ -0,0 +1,31 @@ +import { Kysely } from 'kysely'; +import { resolve } from 'node:path'; +import { DB } from 'src/schema'; +import { ExifTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let database: Kysely; + +const setup = async (testAssetPath: string) => { + const ctx = new ExifTestContext(database); + + const { user } = await ctx.newUser(); + const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); + + return { ctx, sut: ctx.sut, asset }; +}; + +beforeAll(async () => { + database = await getKyselyDB(); +}); + +describe('exif gps', () => { + it('should handle empty strings', async () => { + const { ctx, sut, asset } = await setup('metadata/gps-position/empty_gps.jpg'); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.getGps(asset.id)).resolves.toEqual({ latitude: null, longitude: null }); + }); +}); diff --git a/server/test/medium/specs/exif/exif-tags.spec.ts b/server/test/medium/specs/exif/exif-tags.spec.ts new file mode 100644 index 0000000000..33a81d24b6 --- /dev/null +++ b/server/test/medium/specs/exif/exif-tags.spec.ts @@ -0,0 +1,34 @@ +import { Kysely } from 'kysely'; +import { resolve } from 'node:path'; +import { DB } from 'src/schema'; +import { ExifTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let database: Kysely; + +const setup = async (testAssetPath: string) => { + const ctx = new ExifTestContext(database); + + const { user } = await ctx.newUser(); + const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); + + return { ctx, sut: ctx.sut, asset }; +}; + +beforeAll(async () => { + database = await getKyselyDB(); +}); + +describe('exif tags', () => { + it('should detect and regular tags', async () => { + const { ctx, sut, asset } = await setup('metadata/tags/picasa.jpg'); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.getTags(asset.id)).resolves.toEqual([ + expect.objectContaining({ assetId: asset.id, value: 'Frost', parentId: null }), + expect.objectContaining({ assetId: asset.id, value: 'Yard', parentId: null }), + ]); + }); +}); diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index 12df2f130e..b3a3da6010 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -153,6 +153,46 @@ describe(MemoryService.name, () => { ); }); + it('should create a memory from an asset - in advance', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2035, month: 2, day: 26 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime: now.minus({ years: 1 }).toISO() }); + await Promise.all([ + ctx.newExif({ assetId: asset.id, make: 'Canon' }), + ctx.newJobStatus({ assetId: asset.id }), + assetRepo.upsertFiles([ + { assetId: asset.id, type: AssetFileType.Preview, path: '/path/to/preview.jpg' }, + { assetId: asset.id, type: AssetFileType.Thumbnail, path: '/path/to/thumbnail.jpg' }, + ]), + ]); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, {}); + expect(memories.length).toBe(1); + expect(memories[0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + createdAt: expect.any(Date), + memoryAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null, + ownerId: user.id, + assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]), + isSaved: false, + showAt: now.startOf('day').toJSDate(), + hideAt: now.endOf('day').toJSDate(), + seenAt: null, + type: 'on_this_day', + data: { year: 2034 }, + }), + ); + }); + it('should not generate a memory twice for the same day', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); diff --git a/server/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 13b9867373..5d44079be5 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -65,42 +65,6 @@ describe(MetadataService.name, () => { timeZone: null, }, }, - { - description: 'should handle no time zone information and server behind UTC', - serverTimeZone: 'America/Los_Angeles', - exifData: { - DateTimeOriginal: '2022:01:01 00:00:00', - }, - expected: { - localDateTime: '2022-01-01T00:00:00.000Z', - dateTimeOriginal: '2022-01-01T08:00:00.000Z', - timeZone: null, - }, - }, - { - description: 'should handle no time zone information and server ahead of UTC', - serverTimeZone: 'Europe/Brussels', - exifData: { - DateTimeOriginal: '2022:01:01 00:00:00', - }, - expected: { - localDateTime: '2022-01-01T00:00:00.000Z', - dateTimeOriginal: '2021-12-31T23:00:00.000Z', - timeZone: null, - }, - }, - { - description: 'should handle no time zone information and server ahead of UTC in the summer', - serverTimeZone: 'Europe/Brussels', - exifData: { - DateTimeOriginal: '2022:06:01 00:00:00', - }, - expected: { - localDateTime: '2022-06-01T00:00:00.000Z', - dateTimeOriginal: '2022-05-31T22:00:00.000Z', - timeZone: null, - }, - }, { description: 'should handle a +13:00 time zone', exifData: { diff --git a/server/test/medium/specs/services/plugin.service.spec.ts b/server/test/medium/specs/services/plugin.service.spec.ts new file mode 100644 index 0000000000..b70e8e8d54 --- /dev/null +++ b/server/test/medium/specs/services/plugin.service.spec.ts @@ -0,0 +1,308 @@ +import { Kysely } from 'kysely'; +import { PluginContext } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { DB } from 'src/schema'; +import { PluginService } from 'src/services/plugin.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; +let pluginRepo: PluginRepository; + +const setup = (db?: Kysely) => { + return newMediumService(PluginService, { + database: db || defaultDatabase, + real: [PluginRepository, AccessRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); + pluginRepo = new PluginRepository(defaultDatabase); +}); + +afterEach(async () => { + await defaultDatabase.deleteFrom('plugin').execute(); +}); + +describe(PluginService.name, () => { + describe('getAll', () => { + it('should return empty array when no plugins exist', async () => { + const { sut } = setup(); + + const plugins = await sut.getAll(); + + expect(plugins).toEqual([]); + }); + + it('should return plugin without filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'test-plugin', + title: 'Test Plugin', + description: 'A test plugin', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/test.wasm' }, + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0]).toMatchObject({ + id: result.plugin.id, + name: 'test-plugin', + description: 'A test plugin', + author: 'Test Author', + version: '1.0.0', + filters: [], + actions: [], + }); + }); + + it('should return plugin with filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'full-plugin', + title: 'Full Plugin', + description: 'A plugin with filters and actions', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/full.wasm' }, + filters: [ + { + methodName: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + actions: [ + { + methodName: 'test-action', + title: 'Test Action', + description: 'A test action', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0]).toMatchObject({ + id: result.plugin.id, + name: 'full-plugin', + filters: [ + { + id: result.filters[0].id, + pluginId: result.plugin.id, + methodName: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + actions: [ + { + id: result.actions[0].id, + pluginId: result.plugin.id, + methodName: 'test-action', + title: 'Test Action', + description: 'A test action', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + }); + }); + + it('should return multiple plugins with their respective filters and actions', async () => { + const { sut } = setup(); + + await pluginRepo.loadPlugin( + { + name: 'plugin-1', + title: 'Plugin 1', + description: 'First plugin', + author: 'Author 1', + version: '1.0.0', + wasm: { path: '/path/to/plugin1.wasm' }, + filters: [ + { + methodName: 'filter-1', + title: 'Filter 1', + description: 'Filter for plugin 1', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + await pluginRepo.loadPlugin( + { + name: 'plugin-2', + title: 'Plugin 2', + description: 'Second plugin', + author: 'Author 2', + version: '2.0.0', + wasm: { path: '/path/to/plugin2.wasm' }, + actions: [ + { + methodName: 'action-2', + title: 'Action 2', + description: 'Action for plugin 2', + supportedContexts: [PluginContext.Album], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(2); + expect(plugins[0].name).toBe('plugin-1'); + expect(plugins[0].filters).toHaveLength(1); + expect(plugins[0].actions).toHaveLength(0); + + expect(plugins[1].name).toBe('plugin-2'); + expect(plugins[1].filters).toHaveLength(0); + expect(plugins[1].actions).toHaveLength(1); + }); + + it('should handle plugin with multiple filters and actions', async () => { + const { sut } = setup(); + + await pluginRepo.loadPlugin( + { + name: 'multi-plugin', + title: 'Multi Plugin', + description: 'Plugin with multiple items', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/multi.wasm' }, + filters: [ + { + methodName: 'filter-a', + title: 'Filter A', + description: 'First filter', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + { + methodName: 'filter-b', + title: 'Filter B', + description: 'Second filter', + supportedContexts: [PluginContext.Album], + schema: undefined, + }, + ], + actions: [ + { + methodName: 'action-x', + title: 'Action X', + description: 'First action', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + { + methodName: 'action-y', + title: 'Action Y', + description: 'Second action', + supportedContexts: [PluginContext.Person], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0].filters).toHaveLength(2); + expect(plugins[0].actions).toHaveLength(2); + }); + }); + + describe('get', () => { + it('should throw error when plugin does not exist', async () => { + const { sut } = setup(); + + await expect(sut.get('00000000-0000-0000-0000-000000000000')).rejects.toThrow('Plugin not found'); + }); + + it('should return single plugin with filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'single-plugin', + title: 'Single Plugin', + description: 'A single plugin', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/single.wasm' }, + filters: [ + { + methodName: 'single-filter', + title: 'Single Filter', + description: 'A single filter', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + actions: [ + { + methodName: 'single-action', + title: 'Single Action', + description: 'A single action', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + const pluginResult = await sut.get(result.plugin.id); + + expect(pluginResult).toMatchObject({ + id: result.plugin.id, + name: 'single-plugin', + filters: [ + { + id: result.filters[0].id, + methodName: 'single-filter', + title: 'Single Filter', + }, + ], + actions: [ + { + id: result.actions[0].id, + methodName: 'single-action', + title: 'Single Action', + }, + ], + }); + }); + }); +}); diff --git a/server/test/medium/specs/services/workflow.service.spec.ts b/server/test/medium/specs/services/workflow.service.spec.ts new file mode 100644 index 0000000000..af12019ef6 --- /dev/null +++ b/server/test/medium/specs/services/workflow.service.spec.ts @@ -0,0 +1,697 @@ +import { Kysely } from 'kysely'; +import { PluginContext, PluginTriggerType } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; +import { DB } from 'src/schema'; +import { WorkflowService } from 'src/services/workflow.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(WorkflowService, { + database: db || defaultDatabase, + real: [WorkflowRepository, PluginRepository, AccessRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(WorkflowService.name, () => { + let testPluginId: string; + let testFilterId: string; + let testActionId: string; + + beforeAll(async () => { + // Create a test plugin with filters and actions once for all tests + const pluginRepo = new PluginRepository(defaultDatabase); + const result = await pluginRepo.loadPlugin( + { + name: 'test-core-plugin', + title: 'Test Core Plugin', + description: 'A test core plugin for workflow tests', + author: 'Test Author', + version: '1.0.0', + wasm: { + path: '/test/path.wasm', + }, + filters: [ + { + methodName: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + actions: [ + { + methodName: 'test-action', + title: 'Test Action', + description: 'A test action', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + }, + '/plugins/test-core-plugin', + ); + + testPluginId = result.plugin.id; + testFilterId = result.filters[0].id; + testActionId = result.actions[0].id; + }); + + afterAll(async () => { + await defaultDatabase.deleteFrom('plugin').where('id', '=', testPluginId).execute(); + }); + + describe('create', () => { + it('should create a workflow without filters or actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [], + actions: [], + }); + + expect(workflow).toMatchObject({ + id: expect.any(String), + ownerId: user.id, + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [], + actions: [], + }); + }); + + it('should create a workflow with filters and actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow-with-relations', + description: 'A test workflow with filters and actions', + enabled: true, + filters: [ + { + filterId: testFilterId, + filterConfig: { key: 'value' }, + }, + ], + actions: [ + { + actionId: testActionId, + actionConfig: { action: 'test' }, + }, + ], + }); + + expect(workflow).toMatchObject({ + id: expect.any(String), + ownerId: user.id, + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow-with-relations', + enabled: true, + }); + + expect(workflow.filters).toHaveLength(1); + expect(workflow.filters[0]).toMatchObject({ + id: expect.any(String), + workflowId: workflow.id, + filterId: testFilterId, + filterConfig: { key: 'value' }, + order: 0, + }); + + expect(workflow.actions).toHaveLength(1); + expect(workflow.actions[0]).toMatchObject({ + id: expect.any(String), + workflowId: workflow.id, + actionId: testActionId, + actionConfig: { action: 'test' }, + order: 0, + }); + }); + + it('should throw error when creating workflow with invalid filter', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-workflow', + description: 'A workflow with invalid filter', + enabled: true, + filters: [ + { + filterId: '66da82df-e424-4bf4-b6f3-5d8e71620dae', + filterConfig: { key: 'value' }, + }, + ], + actions: [], + }), + ).rejects.toThrow('Invalid filter ID'); + }); + + it('should throw error when creating workflow with invalid action', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-workflow', + description: 'A workflow with invalid action', + enabled: true, + filters: [], + actions: [ + { + actionId: '66da82df-e424-4bf4-b6f3-5d8e71620dae', + actionConfig: { action: 'test' }, + }, + ], + }), + ).rejects.toThrow('Invalid action ID'); + }); + + it('should throw error when filter does not support trigger context', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + // Create a plugin with a filter that only supports Album context + const pluginRepo = new PluginRepository(defaultDatabase); + const result = await pluginRepo.loadPlugin( + { + name: 'album-only-plugin', + title: 'Album Only Plugin', + description: 'Plugin with album-only filter', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/test/album-plugin.wasm' }, + filters: [ + { + methodName: 'album-filter', + title: 'Album Filter', + description: 'A filter that only works with albums', + supportedContexts: [PluginContext.Album], + schema: undefined, + }, + ], + }, + '/plugins/test-core-plugin', + ); + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-context-workflow', + description: 'A workflow with context mismatch', + enabled: true, + filters: [{ filterId: result.filters[0].id }], + actions: [], + }), + ).rejects.toThrow('does not support asset context'); + }); + + it('should throw error when action does not support trigger context', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + // Create a plugin with an action that only supports Person context + const pluginRepo = new PluginRepository(defaultDatabase); + const result = await pluginRepo.loadPlugin( + { + name: 'person-only-plugin', + title: 'Person Only Plugin', + description: 'Plugin with person-only action', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/test/person-plugin.wasm' }, + actions: [ + { + methodName: 'person-action', + title: 'Person Action', + description: 'An action that only works with persons', + supportedContexts: [PluginContext.Person], + schema: undefined, + }, + ], + }, + '/plugins/test-core-plugin', + ); + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-context-workflow', + description: 'A workflow with context mismatch', + enabled: true, + filters: [], + actions: [{ actionId: result.actions[0].id }], + }), + ).rejects.toThrow('does not support asset context'); + }); + + it('should create workflow with multiple filters and actions in correct order', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'multi-step-workflow', + description: 'A workflow with multiple filters and actions', + enabled: true, + filters: [ + { filterId: testFilterId, filterConfig: { step: 1 } }, + { filterId: testFilterId, filterConfig: { step: 2 } }, + ], + actions: [ + { actionId: testActionId, actionConfig: { step: 1 } }, + { actionId: testActionId, actionConfig: { step: 2 } }, + { actionId: testActionId, actionConfig: { step: 3 } }, + ], + }); + + expect(workflow.filters).toHaveLength(2); + expect(workflow.filters[0].order).toBe(0); + expect(workflow.filters[0].filterConfig).toEqual({ step: 1 }); + expect(workflow.filters[1].order).toBe(1); + expect(workflow.filters[1].filterConfig).toEqual({ step: 2 }); + + expect(workflow.actions).toHaveLength(3); + expect(workflow.actions[0].order).toBe(0); + expect(workflow.actions[1].order).toBe(1); + expect(workflow.actions[2].order).toBe(2); + }); + }); + + describe('getAll', () => { + it('should return all workflows for a user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow1 = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'workflow-1', + description: 'First workflow', + enabled: true, + filters: [], + actions: [], + }); + + const workflow2 = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'workflow-2', + description: 'Second workflow', + enabled: false, + filters: [], + actions: [], + }); + + const workflows = await sut.getAll(auth); + + expect(workflows).toHaveLength(2); + expect(workflows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: workflow1.id, name: 'workflow-1' }), + expect.objectContaining({ id: workflow2.id, name: 'workflow-2' }), + ]), + ); + }); + + it('should return empty array when user has no workflows', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflows = await sut.getAll(auth); + + expect(workflows).toEqual([]); + }); + + it('should not return workflows from other users', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'user1-workflow', + description: 'User 1 workflow', + enabled: true, + filters: [], + actions: [], + }); + + const user2Workflows = await sut.getAll(auth2); + + expect(user2Workflows).toEqual([]); + }); + }); + + describe('get', () => { + it('should return a specific workflow by id', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }], + actions: [{ actionId: testActionId, actionConfig: { action: 'test' } }], + }); + + const workflow = await sut.get(auth, created.id); + + expect(workflow).toMatchObject({ + id: created.id, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + }); + expect(workflow.filters).toHaveLength(1); + expect(workflow.actions).toHaveLength(1); + }); + + it('should throw error when workflow does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect(sut.get(auth, '66da82df-e424-4bf4-b6f3-5d8e71620dae')).rejects.toThrow(); + }); + + it('should throw error when user does not have access to workflow', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + const workflow = await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'private-workflow', + description: 'Private workflow', + enabled: true, + filters: [], + actions: [], + }); + + await expect(sut.get(auth2, workflow.id)).rejects.toThrow(); + }); + }); + + describe('update', () => { + it('should update workflow basic fields', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'original-workflow', + description: 'Original description', + enabled: true, + filters: [], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + name: 'updated-workflow', + description: 'Updated description', + enabled: false, + }); + + expect(updated).toMatchObject({ + id: created.id, + name: 'updated-workflow', + description: 'Updated description', + enabled: false, + }); + }); + + it('should update workflow filters', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { old: 'config' } }], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + filters: [ + { filterId: testFilterId, filterConfig: { new: 'config' } }, + { filterId: testFilterId, filterConfig: { second: 'filter' } }, + ], + }); + + expect(updated.filters).toHaveLength(2); + expect(updated.filters[0].filterConfig).toEqual({ new: 'config' }); + expect(updated.filters[1].filterConfig).toEqual({ second: 'filter' }); + }); + + it('should update workflow actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [{ actionId: testActionId, actionConfig: { old: 'config' } }], + }); + + const updated = await sut.update(auth, created.id, { + actions: [ + { actionId: testActionId, actionConfig: { new: 'config' } }, + { actionId: testActionId, actionConfig: { second: 'action' } }, + ], + }); + + expect(updated.actions).toHaveLength(2); + expect(updated.actions[0].actionConfig).toEqual({ new: 'config' }); + expect(updated.actions[1].actionConfig).toEqual({ second: 'action' }); + }); + + it('should clear filters when updated with empty array', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + filters: [], + }); + + expect(updated.filters).toHaveLength(0); + }); + + it('should throw error when no fields to update', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await expect(sut.update(auth, created.id, {})).rejects.toThrow('No fields to update'); + }); + + it('should throw error when updating non-existent workflow', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect( + sut.update(auth, 'non-existent-id', { + name: 'updated-name', + }), + ).rejects.toThrow(); + }); + + it('should throw error when user does not have access to update workflow', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + const workflow = await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'private-workflow', + description: 'Private', + enabled: true, + filters: [], + actions: [], + }); + + await expect( + sut.update(auth2, workflow.id, { + name: 'hacked-workflow', + }), + ).rejects.toThrow(); + }); + + it('should throw error when updating with invalid filter', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await expect( + sut.update(auth, created.id, { + filters: [{ filterId: 'invalid-filter-id', filterConfig: {} }], + }), + ).rejects.toThrow(); + }); + + it('should throw error when updating with invalid action', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await expect( + sut.update(auth, created.id, { + actions: [{ actionId: 'invalid-action-id', actionConfig: {} }], + }), + ).rejects.toThrow(); + }); + }); + + describe('delete', () => { + it('should delete a workflow', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await sut.delete(auth, workflow.id); + + await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); + }); + + it('should delete workflow with filters and actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: {} }], + actions: [{ actionId: testActionId, actionConfig: {} }], + }); + + await sut.delete(auth, workflow.id); + + await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); + }); + + it('should throw error when deleting non-existent workflow', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect(sut.delete(auth, 'non-existent-id')).rejects.toThrow(); + }); + + it('should throw error when user does not have access to delete workflow', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + const workflow = await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'private-workflow', + description: 'Private', + enabled: true, + filters: [], + actions: [], + }); + + await expect(sut.delete(auth2, workflow.id)).rejects.toThrow(); + }); + }); +}); diff --git a/server/test/medium/specs/sync/sync-album-user.spec.ts b/server/test/medium/specs/sync/sync-album-user.spec.ts index d779ffd9f3..4970995d28 100644 --- a/server/test/medium/specs/sync/sync-album-user.spec.ts +++ b/server/test/medium/specs/sync/sync-album-user.spec.ts @@ -74,7 +74,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { await ctx.syncAckAll(auth, response); await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]); - await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.Viewer }); + await albumUserRepo.update({ albumId: album.id, userId: user1.id }, { role: AlbumUserRole.Viewer }); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(newResponse).toEqual([ { @@ -104,7 +104,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { await ctx.syncAckAll(auth, response); await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]); - await albumUserRepo.delete({ albumsId: album.id, usersId: user1.id }); + await albumUserRepo.delete({ albumId: album.id, userId: user1.id }); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(newResponse).toEqual([ { @@ -171,7 +171,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { await ctx.syncAckAll(auth, response); await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]); - await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.Viewer }); + await albumUserRepo.update({ albumId: album.id, userId: user.id }, { role: AlbumUserRole.Viewer }); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(newResponse).toEqual([ { @@ -208,7 +208,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { await ctx.syncAckAll(auth, response); await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]); - await albumUserRepo.delete({ albumsId: album.id, usersId: user.id }); + await albumUserRepo.delete({ albumId: album.id, userId: user.id }); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(newResponse).toEqual([ diff --git a/server/test/medium/specs/sync/sync-album.spec.ts b/server/test/medium/specs/sync/sync-album.spec.ts index 591d7e1f3c..02536e3a8d 100644 --- a/server/test/medium/specs/sync/sync-album.spec.ts +++ b/server/test/medium/specs/sync/sync-album.spec.ts @@ -217,7 +217,7 @@ describe(SyncRequestType.AlbumsV1, () => { await ctx.syncAckAll(auth, response); await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]); - await albumUserRepo.delete({ albumsId: album.id, usersId: auth.user.id }); + await albumUserRepo.delete({ albumId: album.id, userId: auth.user.id }); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]); expect(newResponse).toEqual([ { diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 50db983cba..208b09c120 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -65,5 +65,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { tag: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + + workflow: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, }; }; diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index e31e1a3348..656027fab5 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -72,6 +72,7 @@ const envData: EnvData = { root: '/build/www', indexHtml: '/build/www/index.html', }, + corePlugin: '/build/corePlugin', }, storage: { @@ -86,6 +87,11 @@ const envData: EnvData = { workers: [ImmichWorker.Api, ImmichWorker.Microservices], + plugins: { + enabled: true, + installFolder: '/app/data/plugins', + }, + noColor: false, }; diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index 1167923c0c..773891206e 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -13,5 +13,7 @@ export const newCryptoRepositoryMock = (): Mocked Buffer.from(`${input.toString()} (hashed)`)), hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`), randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')), + signJwt: vitest.fn().mockReturnValue('mock-jwt-token'), + verifyJwt: vitest.fn().mockImplementation((token) => ({ verified: true, token })), }; }; diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index 3664730be2..0ff869ca28 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -19,6 +19,7 @@ export const newDatabaseRepositoryMock = (): Mocked() => Promise) => function_()), tryLock: vitest.fn(), isBusy: vitest.fn(), diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 9752a39441..31451da82f 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -49,6 +49,7 @@ export const newStorageRepositoryMock = (): Mocked = T extends RepositoryInterface ? U : never; @@ -308,6 +312,7 @@ export const newTestService = ( oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: automock(PersonRepository, { strict: false }), + plugin: automock(PluginRepository, { strict: true }), process: automock(ProcessRepository), search: automock(SearchRepository, { strict: false }), // eslint-disable-next-line no-sparse-arrays @@ -330,6 +335,7 @@ export const newTestService = ( view: automock(ViewRepository), // eslint-disable-next-line no-sparse-arrays websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }), + workflow: automock(WorkflowRepository, { strict: true }), }; const sut = new Service( @@ -363,6 +369,7 @@ export const newTestService = ( overrides.ocr || (mocks.ocr as As), overrides.partner || (mocks.partner as As), overrides.person || (mocks.person as As), + overrides.plugin || (mocks.plugin as As), overrides.process || (mocks.process as As), overrides.search || (mocks.search as As), overrides.serverInfo || (mocks.serverInfo as As), @@ -381,6 +388,7 @@ export const newTestService = ( overrides.versionHistory || (mocks.versionHistory as As), overrides.view || (mocks.view as As), overrides.websocket || (mocks.websocket as As), + overrides.workflow || (mocks.workflow as As), ); return { diff --git a/web/mise.toml b/web/mise.toml new file mode 100644 index 0000000000..5aca2d737d --- /dev/null +++ b/web/mise.toml @@ -0,0 +1,62 @@ +[tasks.install] +run = "pnpm install --filter immich-web --frozen-lockfile" + +[tasks."svelte-kit-sync"] +env._.path = "./node_modules/.bin" +run = "svelte-kit sync" + +[tasks.build] +env._.path = "./node_modules/.bin" +run = "vite build" + +[tasks."build-stats"] +env.BUILD_STATS = "true" +env._.path = "./node_modules/.bin" +run = "vite build" + +[tasks.preview] +env._.path = "./node_modules/.bin" +run = "vite preview" + +[tasks.start] +env._.path = "./node_modules/.bin" +run = "vite dev --host 0.0.0.0 --port 3000" + +[tasks.test] +depends = ["svelte-kit-sync"] +env._.path = "./node_modules/.bin" +run = "vitest" + +[tasks.format] +env._.path = "./node_modules/.bin" +run = "prettier --check ." + +[tasks."format-fix"] +env._.path = "./node_modules/.bin" +run = "prettier --write ." + +[tasks.lint] +env._.path = "./node_modules/.bin" +run = "eslint . --max-warnings 0 --concurrency 4" + +[tasks."lint-fix"] +run = { task = "lint --fix" } + +[tasks.check] +depends = ["svelte-kit-sync"] +env._.path = "./node_modules/.bin" +run = "tsc --noEmit" + +[tasks."check-svelte"] +depends = ["svelte-kit-sync"] +env._.path = "./node_modules/.bin" +run = "svelte-check --no-tsconfig --fail-on-warnings" + +[tasks.checklist] +run = [ + { task = ":install" }, + { task = ":format" }, + { task = ":check" }, + { task = ":test --run" }, + { task = ":lint" }, +] diff --git a/web/package.json b/web/package.json index dd582af438..2a93230c24 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "2.2.2", + "version": "2.2.3", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { @@ -28,7 +28,7 @@ "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.40.2", + "@immich/ui": "^0.43.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -57,7 +57,7 @@ "socket.io-client": "~4.8.0", "svelte-gestures": "^5.2.2", "svelte-i18n": "^4.0.1", - "svelte-maplibre": "^1.2.0", + "svelte-maplibre": "^1.2.5", "svelte-persisted-store": "^0.12.0", "tabbable": "^6.2.0", "thumbhash": "^0.1.1" @@ -96,7 +96,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.41.3", + "svelte": "5.43.0", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte new file mode 100644 index 0000000000..37bc09fb73 --- /dev/null +++ b/web/src/lib/components/ActionButton.svelte @@ -0,0 +1,16 @@ + + +{#if action.$if?.() ?? true} + +{/if} diff --git a/web/src/lib/components/HeaderButton.svelte b/web/src/lib/components/HeaderButton.svelte new file mode 100644 index 0000000000..9021d2d1cb --- /dev/null +++ b/web/src/lib/components/HeaderButton.svelte @@ -0,0 +1,18 @@ + + +{#if action.$if?.() ?? true} + +{/if} diff --git a/web/src/lib/components/OnEvents.svelte b/web/src/lib/components/OnEvents.svelte new file mode 100644 index 0000000000..3933f4df7b --- /dev/null +++ b/web/src/lib/components/OnEvents.svelte @@ -0,0 +1,33 @@ + diff --git a/web/src/lib/components/SharedLinkExpiration.svelte b/web/src/lib/components/SharedLinkExpiration.svelte new file mode 100644 index 0000000000..735d9f8712 --- /dev/null +++ b/web/src/lib/components/SharedLinkExpiration.svelte @@ -0,0 +1,76 @@ + + +
+ +
diff --git a/web/src/lib/components/TableButton.svelte b/web/src/lib/components/TableButton.svelte new file mode 100644 index 0000000000..4bd82e4dd9 --- /dev/null +++ b/web/src/lib/components/TableButton.svelte @@ -0,0 +1,16 @@ + + +{#if action.$if?.() ?? true} + +{/if} diff --git a/web/src/lib/components/admin-settings/AdminSettings.svelte b/web/src/lib/components/admin-settings/AdminSettings.svelte deleted file mode 100644 index 54be8bea96..0000000000 --- a/web/src/lib/components/admin-settings/AdminSettings.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - -{#if savedConfig && defaultConfig} - {@render children({ savedConfig, defaultConfig })} -{/if} diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index 34cafd3e9b..c53060706e 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -1,50 +1,44 @@
diff --git a/web/src/lib/components/admin-settings/FFmpegSettings.svelte b/web/src/lib/components/admin-settings/FFmpegSettings.svelte index d176839543..83596069f9 100644 --- a/web/src/lib/components/admin-settings/FFmpegSettings.svelte +++ b/web/src/lib/components/admin-settings/FFmpegSettings.svelte @@ -1,12 +1,14 @@
-
+ event.preventDefault()}>

@@ -75,7 +64,7 @@ label={$t('admin.transcoding_transcode_policy')} {disabled} desc={$t('admin.transcoding_transcode_policy_description')} - bind:value={config.ffmpeg.transcode} + bind:value={configToEdit.ffmpeg.transcode} name="transcode" options={[ { value: TranscodePolicy.All, text: $t('all_videos') }, @@ -96,14 +85,14 @@ text: $t('admin.transcoding_disabled_description'), }, ]} - isEdited={config.ffmpeg.transcode !== savedConfig.ffmpeg.transcode} + isEdited={configToEdit.ffmpeg.transcode !== config.ffmpeg.transcode} /> @@ -121,7 +110,7 @@ label={$t('admin.transcoding_accepted_audio_codecs')} {disabled} desc={$t('admin.transcoding_accepted_audio_codecs_description')} - bind:value={config.ffmpeg.acceptedAudioCodecs} + bind:value={configToEdit.ffmpeg.acceptedAudioCodecs} name="audioCodecs" options={[ { value: AudioCodec.Aac, text: 'AAC' }, @@ -130,8 +119,8 @@ { value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' }, ]} isEdited={!isEqual( + sortBy(configToEdit.ffmpeg.acceptedAudioCodecs), sortBy(config.ffmpeg.acceptedAudioCodecs), - sortBy(savedConfig.ffmpeg.acceptedAudioCodecs), )} /> @@ -139,7 +128,7 @@ label={$t('admin.transcoding_accepted_containers')} {disabled} desc={$t('admin.transcoding_accepted_containers_description')} - bind:value={config.ffmpeg.acceptedContainers} + bind:value={configToEdit.ffmpeg.acceptedContainers} name="videoContainers" options={[ { value: VideoContainer.Mov, text: 'MOV' }, @@ -147,8 +136,8 @@ { value: VideoContainer.Webm, text: 'WebM' }, ]} isEdited={!isEqual( + sortBy(configToEdit.ffmpeg.acceptedContainers), sortBy(config.ffmpeg.acceptedContainers), - sortBy(savedConfig.ffmpeg.acceptedContainers), )} />

@@ -164,7 +153,7 @@ label={$t('admin.transcoding_video_codec')} {disabled} desc={$t('admin.transcoding_video_codec_description')} - bind:value={config.ffmpeg.targetVideoCodec} + bind:value={configToEdit.ffmpeg.targetVideoCodec} options={[ { value: VideoCodec.H264, text: 'h264' }, { value: VideoCodec.Hevc, text: 'hevc' }, @@ -172,8 +161,8 @@ { value: VideoCodec.Av1, text: 'av1' }, ]} name="vcodec" - isEdited={config.ffmpeg.targetVideoCodec !== savedConfig.ffmpeg.targetVideoCodec} - onSelect={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} + isEdited={configToEdit.ffmpeg.targetVideoCodec !== config.ffmpeg.targetVideoCodec} + onSelect={() => (configToEdit.ffmpeg.acceptedVideoCodecs = [configToEdit.ffmpeg.targetVideoCodec])} /> @@ -181,25 +170,25 @@ label={$t('admin.transcoding_audio_codec')} {disabled} desc={$t('admin.transcoding_audio_codec_description')} - bind:value={config.ffmpeg.targetAudioCodec} + bind:value={configToEdit.ffmpeg.targetAudioCodec} options={[ { value: AudioCodec.Aac, text: 'aac' }, { value: AudioCodec.Mp3, text: 'mp3' }, { value: AudioCodec.Libopus, text: 'opus' }, ]} name="acodec" - isEdited={config.ffmpeg.targetAudioCodec !== savedConfig.ffmpeg.targetAudioCodec} + isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec} onSelect={() => - config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec) + configToEdit.ffmpeg.acceptedAudioCodecs.includes(configToEdit.ffmpeg.targetAudioCodec) ? null - : config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec)} + : configToEdit.ffmpeg.acceptedAudioCodecs.push(configToEdit.ffmpeg.targetAudioCodec)} />
@@ -307,7 +296,7 @@ label={$t('admin.transcoding_acceleration_api')} {disabled} desc={$t('admin.transcoding_acceleration_api_description')} - bind:value={config.ffmpeg.accel} + bind:value={configToEdit.ffmpeg.accel} name="accel" options={[ { value: TranscodeHWAccel.Nvenc, text: $t('admin.transcoding_acceleration_nvenc') }, @@ -328,27 +317,27 @@ text: $t('disabled'), }, ]} - isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel} + isEdited={configToEdit.ffmpeg.accel !== config.ffmpeg.accel} /> @@ -356,16 +345,16 @@ title={$t('admin.transcoding_temporal_aq')} {disabled} subtitle={$t('admin.transcoding_temporal_aq_description')} - bind:checked={config.ffmpeg.temporalAQ} - isEdited={config.ffmpeg.temporalAQ !== savedConfig.ffmpeg.temporalAQ} + bind:checked={configToEdit.ffmpeg.temporalAQ} + isEdited={configToEdit.ffmpeg.temporalAQ !== config.ffmpeg.temporalAQ} />
@@ -381,8 +370,8 @@ inputType={SettingInputFieldType.NUMBER} label={$t('admin.transcoding_max_b_frames')} description={$t('admin.transcoding_max_b_frames_description')} - bind:value={config.ffmpeg.bframes} - isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes} + bind:value={configToEdit.ffmpeg.bframes} + isEdited={configToEdit.ffmpeg.bframes !== config.ffmpeg.bframes} {disabled} /> @@ -390,8 +379,8 @@ inputType={SettingInputFieldType.NUMBER} label={$t('admin.transcoding_reference_frames')} description={$t('admin.transcoding_reference_frames_description')} - bind:value={config.ffmpeg.refs} - isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs} + bind:value={configToEdit.ffmpeg.refs} + isEdited={configToEdit.ffmpeg.refs !== config.ffmpeg.refs} {disabled} /> @@ -399,8 +388,8 @@ inputType={SettingInputFieldType.NUMBER} label={$t('admin.transcoding_max_keyframe_interval')} description={$t('admin.transcoding_max_keyframe_interval_description')} - bind:value={config.ffmpeg.gopSize} - isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize} + bind:value={configToEdit.ffmpeg.gopSize} + isEdited={configToEdit.ffmpeg.gopSize !== config.ffmpeg.gopSize} {disabled} />
@@ -408,12 +397,7 @@
- onReset({ ...options, configKeys: ['ffmpeg'] })} - onSave={() => onSave({ ffmpeg: config.ffmpeg })} - showResetToDefault={!isEqual(savedConfig.ffmpeg, defaultConfig.ffmpeg)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/ImageSettings.svelte b/web/src/lib/components/admin-settings/ImageSettings.svelte index fd2ac29c6b..afed6b3738 100644 --- a/web/src/lib/components/admin-settings/ImageSettings.svelte +++ b/web/src/lib/components/admin-settings/ImageSettings.svelte @@ -1,62 +1,41 @@
-
+ event.preventDefault()}>
@@ -64,7 +43,7 @@ label={$t('admin.image_resolution')} desc={$t('admin.image_resolution_description')} number - bind:value={config.image.thumbnail.size} + bind:value={configToEdit.image.thumbnail.size} options={[ { value: 1080, text: '1080p' }, { value: 720, text: '720p' }, @@ -73,7 +52,7 @@ { value: 200, text: '200p' }, ]} name="resolution" - isEdited={config.image.thumbnail.size !== savedConfig.image.thumbnail.size} + isEdited={configToEdit.image.thumbnail.size !== config.image.thumbnail.size} {disabled} /> @@ -81,8 +60,8 @@ inputType={SettingInputFieldType.NUMBER} label={$t('admin.image_quality')} description={$t('admin.image_thumbnail_quality_description')} - bind:value={config.image.thumbnail.quality} - isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality} + bind:value={configToEdit.image.thumbnail.quality} + isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality} {disabled} /> @@ -91,18 +70,17 @@ key="preview-settings" title={$t('admin.image_preview_title')} subtitle={$t('admin.image_preview_description')} - isOpen={openByDefault} > @@ -110,7 +88,7 @@ label={$t('admin.image_resolution')} desc={$t('admin.image_resolution_description')} number - bind:value={config.image.preview.size} + bind:value={configToEdit.image.preview.size} options={[ { value: 2160, text: '4K' }, { value: 1440, text: '1440p' }, @@ -118,7 +96,7 @@ { value: 720, text: '720p' }, ]} name="resolution" - isEdited={config.image.preview.size !== savedConfig.image.preview.size} + isEdited={configToEdit.image.preview.size !== config.image.preview.size} {disabled} /> @@ -126,8 +104,8 @@ inputType={SettingInputFieldType.NUMBER} label={$t('admin.image_quality')} description={$t('admin.image_preview_quality_description')} - bind:value={config.image.preview.quality} - isEdited={config.image.preview.quality !== savedConfig.image.preview.quality} + bind:value={configToEdit.image.preview.quality} + isEdited={configToEdit.image.preview.quality !== config.image.preview.quality} {disabled} /> @@ -136,14 +114,13 @@ key="fullsize-settings" title={$t('admin.image_fullsize_title')} subtitle={$t('admin.image_fullsize_description')} - isOpen={openByDefault} > (config.image.fullsize.enabled = isChecked)} - isEdited={config.image.fullsize.enabled !== savedConfig.image.fullsize.enabled} + checked={configToEdit.image.fullsize.enabled} + onToggle={(isChecked) => (configToEdit.image.fullsize.enabled = isChecked)} + isEdited={configToEdit.image.fullsize.enabled !== config.image.fullsize.enabled} {disabled} /> @@ -152,23 +129,23 @@ @@ -176,9 +153,9 @@ (config.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)} - isEdited={config.image.colorspace !== savedConfig.image.colorspace} + checked={configToEdit.image.colorspace === Colorspace.P3} + onToggle={(isChecked) => (configToEdit.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)} + isEdited={configToEdit.image.colorspace !== config.image.colorspace} {disabled} />
@@ -187,21 +164,16 @@ (config.image.extractEmbedded = !config.image.extractEmbedded)} - isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded} + checked={configToEdit.image.extractEmbedded} + onToggle={() => (configToEdit.image.extractEmbedded = !configToEdit.image.extractEmbedded)} + isEdited={configToEdit.image.extractEmbedded !== config.image.extractEmbedded} {disabled} />
- onReset({ ...options, configKeys: ['image'] })} - onSave={() => onSave({ image: config.image })} - showResetToDefault={!isEqual(savedConfig.image, defaultConfig.image)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/JobSettings.svelte b/web/src/lib/components/admin-settings/JobSettings.svelte index 70de73f81b..94b4426dbb 100644 --- a/web/src/lib/components/admin-settings/JobSettings.svelte +++ b/web/src/lib/components/admin-settings/JobSettings.svelte @@ -1,68 +1,56 @@
-
- {#each jobNames as jobName (jobName)} + event.preventDefault()}> + {#each queueNames as queueName (queueName)}
- {#if isSystemConfigJobDto(jobName)} + {#if isSystemConfigJobDto(queueName)} {:else} - onReset({ ...options, configKeys: ['job'] })} - onSave={() => onSave({ job: config.job })} - showResetToDefault={!isEqual(savedConfig.job, defaultConfig.job)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/LibrarySettings.svelte b/web/src/lib/components/admin-settings/LibrarySettings.svelte index 82ce13ae2c..a91a5eb97a 100644 --- a/web/src/lib/components/admin-settings/LibrarySettings.svelte +++ b/web/src/lib/components/admin-settings/LibrarySettings.svelte @@ -1,36 +1,19 @@
-
+ event.preventDefault()}>
diff --git a/web/src/lib/components/admin-settings/LoggingSettings.svelte b/web/src/lib/components/admin-settings/LoggingSettings.svelte index 90bd04d9a6..6052b8ea9f 100644 --- a/web/src/lib/components/admin-settings/LoggingSettings.svelte +++ b/web/src/lib/components/admin-settings/LoggingSettings.svelte @@ -1,42 +1,31 @@
-
+ event.preventDefault()}>
- onReset({ ...options, configKeys: ['logging'] })} - onSave={() => onSave({ logging: config.logging })} - showResetToDefault={!isEqual(savedConfig.logging, defaultConfig.logging)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/MachineLearningSettings.svelte b/web/src/lib/components/admin-settings/MachineLearningSettings.svelte index 7649ee8d17..579efef916 100644 --- a/web/src/lib/components/admin-settings/MachineLearningSettings.svelte +++ b/web/src/lib/components/admin-settings/MachineLearningSettings.svelte @@ -1,65 +1,53 @@
-
+ event.preventDefault()}>

- {#each config.machineLearning.urls as _, i (i)} + {#each configToEdit.machineLearning.urls as _, i (i)} {#snippet trailingSnippet()} - {#if config.machineLearning.urls.length > 1} + {#if configToEdit.machineLearning.urls.length > 1} config.machineLearning.urls.splice(i, 1)} + onclick={() => configToEdit.machineLearning.urls.splice(i, 1)} icon={mdiTrashCanOutline} color="danger" /> @@ -75,8 +63,8 @@ size="small" shape="round" leadingIcon={mdiPlus} - onclick={() => config.machineLearning.urls.push('')} - disabled={disabled || !config.machineLearning.enabled}>{$t('add_url')} configToEdit.machineLearning.urls.push('')} + disabled={disabled || !configToEdit.machineLearning.enabled}>{$t('add_url')}
@@ -89,8 +77,8 @@

@@ -98,21 +86,25 @@
@@ -126,8 +118,8 @@
@@ -135,10 +127,10 @@ {#snippet descriptionSnippet()}

@@ -162,8 +154,8 @@


@@ -171,14 +163,14 @@
@@ -192,8 +184,8 @@
@@ -202,54 +194,62 @@ label={$t('admin.machine_learning_facial_recognition_model')} desc={$t('admin.machine_learning_facial_recognition_model_description')} name="facial-recognition-model" - bind:value={config.machineLearning.facialRecognition.modelName} + bind:value={configToEdit.machineLearning.facialRecognition.modelName} options={[ { value: 'antelopev2', text: 'antelopev2' }, { value: 'buffalo_l', text: 'buffalo_l' }, { value: 'buffalo_m', text: 'buffalo_m' }, { value: 'buffalo_s', text: 'buffalo_s' }, ]} - disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} - isEdited={config.machineLearning.facialRecognition.modelName !== - savedConfig.machineLearning.facialRecognition.modelName} + disabled={disabled || + !configToEdit.machineLearning.enabled || + !configToEdit.machineLearning.facialRecognition.enabled} + isEdited={configToEdit.machineLearning.facialRecognition.modelName !== + config.machineLearning.facialRecognition.modelName} />
@@ -263,8 +263,8 @@
@@ -273,58 +273,59 @@ label={$t('admin.machine_learning_ocr_model')} desc={$t('admin.machine_learning_ocr_model_description')} name="ocr-model" - bind:value={config.machineLearning.ocr.modelName} + bind:value={configToEdit.machineLearning.ocr.modelName} options={[ - { value: 'PP-OCRv5_server', text: 'PP-OCRv5_server' }, - { value: 'PP-OCRv5_mobile', text: 'PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_server (Chinese, Japanese and English)', value: 'PP-OCRv5_server' }, + { text: 'PP-OCRv5_mobile (Chinese, Japanese and English)', value: 'PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (English-only)', value: 'EN__PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (Greek and English)', value: 'EL__PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (Korean and English)', value: 'KOREAN__PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (Latin script languages)', value: 'LATIN__PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (Russian, Belarusian, Ukrainian and English)', value: 'ESLAV__PP-OCRv5_mobile' }, + { text: 'PP-OCRv5_mobile (Thai and English)', value: 'TH__PP-OCRv5_mobile' }, ]} - disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.ocr.enabled} - isEdited={config.machineLearning.ocr.modelName !== savedConfig.machineLearning.ocr.modelName} + disabled={disabled || !configToEdit.machineLearning.enabled || !configToEdit.machineLearning.ocr.enabled} + isEdited={configToEdit.machineLearning.ocr.modelName !== config.machineLearning.ocr.modelName} />
- onReset({ ...options, configKeys: ['machineLearning'] })} - onSave={() => onSave({ machineLearning: config.machineLearning })} - showResetToDefault={!isEqual(savedConfig.machineLearning, defaultConfig.machineLearning)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/MapSettings.svelte b/web/src/lib/components/admin-settings/MapSettings.svelte index 4db210d8dc..692a5cfcf5 100644 --- a/web/src/lib/components/admin-settings/MapSettings.svelte +++ b/web/src/lib/components/admin-settings/MapSettings.svelte @@ -1,35 +1,23 @@
-
+ event.preventDefault()}>
@@ -37,7 +25,7 @@ title={$t('admin.map_enable_description')} subtitle={$t('admin.map_implications')} {disabled} - bind:checked={config.map.enabled} + bind:checked={configToEdit.map.enabled} />
@@ -46,17 +34,17 @@ inputType={SettingInputFieldType.TEXT} label={$t('admin.map_light_style')} description={$t('admin.map_style_description')} - bind:value={config.map.lightStyle} - disabled={disabled || !config.map.enabled} - isEdited={config.map.lightStyle !== savedConfig.map.lightStyle} + bind:value={configToEdit.map.lightStyle} + disabled={disabled || !configToEdit.map.enabled} + isEdited={configToEdit.map.lightStyle !== config.map.lightStyle} />
@@ -82,20 +70,12 @@
- onReset({ ...options, configKeys: ['map', 'reverseGeocoding'] })} - onSave={() => onSave({ map: config.map, reverseGeocoding: config.reverseGeocoding })} - showResetToDefault={!isEqual( - { map: savedConfig.map, reverseGeocoding: savedConfig.reverseGeocoding }, - { map: defaultConfig.map, reverseGeocoding: defaultConfig.reverseGeocoding }, - )} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/MetadataSettings.svelte b/web/src/lib/components/admin-settings/MetadataSettings.svelte index 04e2d010e1..0db36e0e82 100644 --- a/web/src/lib/components/admin-settings/MetadataSettings.svelte +++ b/web/src/lib/components/admin-settings/MetadataSettings.svelte @@ -1,46 +1,28 @@
-
+ event.preventDefault()}>
- onReset({ ...options, configKeys: ['metadata'] })} - onSave={() => onSave({ metadata: config.metadata })} - showResetToDefault={!isEqual(savedConfig.metadata.faces.import, defaultConfig.metadata.faces.import)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte b/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte index b713f906c0..d8a79d6236 100644 --- a/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte +++ b/web/src/lib/components/admin-settings/NewVersionCheckSettings.svelte @@ -1,44 +1,26 @@
-
+ event.preventDefault()}>
- onReset({ ...options, configKeys: ['newVersionCheck'] })} - onSave={() => onSave({ newVersionCheck: config.newVersionCheck })} - showResetToDefault={!isEqual(savedConfig.newVersionCheck, defaultConfig.newVersionCheck)} + bind:checked={configToEdit.newVersionCheck.enabled} {disabled} /> +
diff --git a/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte b/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte index 9ba4e4e3b8..9647f0c7c3 100644 --- a/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte +++ b/web/src/lib/components/admin-settings/NightlyTasksSettings.svelte @@ -1,81 +1,64 @@
-
+ event.preventDefault()}>
- onReset({ ...options, configKeys: ['nightlyTasks'] })} - onSave={() => onSave({ nightlyTasks: config.nightlyTasks })} - showResetToDefault={!isEqual(savedConfig.nightlyTasks, defaultConfig.nightlyTasks)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/NotificationSettings.svelte b/web/src/lib/components/admin-settings/NotificationSettings.svelte index 35f13da5a0..e97af356df 100644 --- a/web/src/lib/components/admin-settings/NotificationSettings.svelte +++ b/web/src/lib/components/admin-settings/NotificationSettings.svelte @@ -1,29 +1,23 @@
-
+ event.preventDefault()}>

@@ -87,9 +77,9 @@ required label={$t('host')} description={$t('admin.notification_email_host_description')} - disabled={disabled || !config.notifications.smtp.enabled} - bind:value={config.notifications.smtp.transport.host} - isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host} + disabled={disabled || !configToEdit.notifications.smtp.enabled} + bind:value={configToEdit.notifications.smtp.transport.host} + isEdited={configToEdit.notifications.smtp.transport.host !== config.notifications.smtp.transport.host} />
@@ -143,16 +133,16 @@ required label={$t('admin.notification_email_from_address')} description={$t('admin.notification_email_from_address_description')} - disabled={disabled || !config.notifications.smtp.enabled} - bind:value={config.notifications.smtp.from} - isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from} + disabled={disabled || !configToEdit.notifications.smtp.enabled} + bind:value={configToEdit.notifications.smtp.from} + isEdited={configToEdit.notifications.smtp.from !== config.notifications.smtp.from} />
- + - onReset({ ...options, configKeys: ['notifications', 'templates'] })} - onSave={() => onSave({ notifications: config.notifications, templates: config.templates })} - showResetToDefault={!isEqual(savedConfig, defaultConfig)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/ServerSettings.svelte b/web/src/lib/components/admin-settings/ServerSettings.svelte index d04b351eff..936c4f406e 100644 --- a/web/src/lib/components/admin-settings/ServerSettings.svelte +++ b/web/src/lib/components/admin-settings/ServerSettings.svelte @@ -1,64 +1,47 @@
-
+ event.preventDefault()}>
- onReset({ ...options, configKeys: ['server'] })} - onSave={() => onSave({ server: config.server })} - showResetToDefault={!isEqual(savedConfig.server, defaultConfig.server)} - {disabled} - /> +
diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index a779b57f2d..3dbf697de1 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -2,50 +2,35 @@ import { resolve } from '$app/paths'; import SupportedDatetimePanel from '$lib/components/admin-settings/SupportedDatetimePanel.svelte'; import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte'; - import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { AppRoute, SettingInputFieldType } from '$lib/constants'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { handleSystemConfigSave } from '$lib/services/system-config.service'; import { user } from '$lib/stores/user.store'; - import { - getStorageTemplateOptions, - type SystemConfigDto, - type SystemConfigTemplateStorageOptionDto, - } from '@immich/sdk'; + import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; import { LoadingSpinner } from '@immich/ui'; import handlebar from 'handlebars'; - import { isEqual } from 'lodash-es'; import * as luxon from 'luxon'; - import type { Snippet } from 'svelte'; + import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; import { createBubbler, preventDefault } from 'svelte/legacy'; import { fade } from 'svelte/transition'; - import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings'; - interface Props { - savedConfig: SystemConfigDto; - defaultConfig: SystemConfigDto; - config: SystemConfigDto; - disabled?: boolean; + type Props = { minified?: boolean; - onReset: SettingsResetEvent; - onSave: SettingsSaveEvent; duration?: number; - children?: Snippet; - } + saveOnClose?: boolean; + }; - let { - savedConfig, - defaultConfig, - config = $bindable(), - disabled = false, - minified = false, - onReset, - onSave, - duration = 500, - children, - }: Props = $props(); + const { minified = false, duration = 500, saveOnClose = false }: Props = $props(); + + const disabled = $derived(featureFlagsManager.value.configFile); + const config = $derived(systemConfigManager.value); + let configToEdit = $state(systemConfigManager.cloneValue()); const bubble = createBubbler(); let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state(); @@ -53,7 +38,7 @@ const getTemplateOptions = async () => { templateOptions = await getStorageTemplateOptions(); - selectedPreset = savedConfig.storageTemplate.template; + selectedPreset = config.storageTemplate.template; }; const getSupportDateTimeFormat = () => getStorageTemplateOptions(); @@ -101,15 +86,21 @@ }; const handlePresetSelection = () => { - config.storageTemplate.template = selectedPreset; + configToEdit.storageTemplate.template = selectedPreset; }; let parsedTemplate = $derived(() => { try { - return renderTemplate(config.storageTemplate.template); + return renderTemplate(configToEdit.storageTemplate.template); } catch { return 'error'; } }); + + onDestroy(async () => { + if (saveOnClose) { + await handleSystemConfigSave({ storageTemplate: configToEdit.storageTemplate }); + } + });
@@ -145,8 +136,8 @@ {#if !minified} @@ -154,14 +145,14 @@ title={$t('admin.storage_template_hash_verification_enabled')} {disabled} subtitle={$t('admin.storage_template_hash_verification_enabled_description')} - bind:checked={config.storageTemplate.hashVerificationEnabled} + bind:checked={configToEdit.storageTemplate.hashVerificationEnabled} isEdited={!( - config.storageTemplate.hashVerificationEnabled === savedConfig.storageTemplate.hashVerificationEnabled + configToEdit.storageTemplate.hashVerificationEnabled === config.storageTemplate.hashVerificationEnabled )} /> {/if} - {#if config.storageTemplate.enabled} + {#if configToEdit.storageTemplate.enabled}

{$t('variables')}

@@ -220,7 +211,7 @@ -
- -
+ @@ -208,20 +93,13 @@ - - {#if editingLink} - - - - {/if}
- {#if editingLink} - - {:else} - - {/if} + + + + diff --git a/web/src/lib/modals/SharedLinkUpdateModal.svelte b/web/src/lib/modals/SharedLinkUpdateModal.svelte new file mode 100644 index 0000000000..f3bdd42a89 --- /dev/null +++ b/web/src/lib/modals/SharedLinkUpdateModal.svelte @@ -0,0 +1,98 @@ + + + + + {#if shareType === SharedLinkType.Album} +
+ {$t('public_album')} | + {sharedLink.album?.albumName} +
+ {/if} + + {#if shareType === SharedLinkType.Individual} +
+ {$t('individual_share')} | + {sharedLink.description || ''} +
+ {/if} + +
+
+ + + + {#if slug} + /s/{encodeURIComponent(slug)} + {/if} +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +
diff --git a/web/src/lib/modals/UserCreateModal.svelte b/web/src/lib/modals/UserCreateModal.svelte index 1a1f46d1d5..7dd0449119 100644 --- a/web/src/lib/modals/UserCreateModal.svelte +++ b/web/src/lib/modals/UserCreateModal.svelte @@ -1,11 +1,9 @@
- {#if error} - - {/if} - {#if success}

{$t('new_user_created')}

{/if} @@ -100,17 +85,17 @@ - {#if $featureFlags.email} + {#if featureFlagsManager.value.email} {/if} - + - + {passwordMismatchMessage} diff --git a/web/src/lib/modals/UserDeleteConfirmModal.svelte b/web/src/lib/modals/UserDeleteConfirmModal.svelte index 9c9223707e..c4cf7033c6 100644 --- a/web/src/lib/modals/UserDeleteConfirmModal.svelte +++ b/web/src/lib/modals/UserDeleteConfirmModal.svelte @@ -1,14 +1,15 @@ {#snippet children({ message })} {message} diff --git a/web/src/lib/modals/UserEditModal.svelte b/web/src/lib/modals/UserEditModal.svelte index 657f6dee61..84e4edc32a 100644 --- a/web/src/lib/modals/UserEditModal.svelte +++ b/web/src/lib/modals/UserEditModal.svelte @@ -1,10 +1,10 @@ diff --git a/web/src/lib/modals/UserRestoreConfirmModal.svelte b/web/src/lib/modals/UserRestoreConfirmModal.svelte index 03c36e27cd..0a01f846b9 100644 --- a/web/src/lib/modals/UserRestoreConfirmModal.svelte +++ b/web/src/lib/modals/UserRestoreConfirmModal.svelte @@ -1,30 +1,39 @@ - - + + {#snippet promptSnippet()}

{#snippet children({ message })} @@ -32,16 +41,5 @@ {/snippet}

-
- - - - - - - -
+ {/snippet} +
diff --git a/web/src/lib/modals/timezone-utils.ts b/web/src/lib/modals/timezone-utils.ts index c7bb00fd69..fc4081d3bb 100644 --- a/web/src/lib/modals/timezone-utils.ts +++ b/web/src/lib/modals/timezone-utils.ts @@ -70,9 +70,14 @@ export function getModernOffsetForZoneAndDate( function zoneOptionForDate(zone: string, date: string) { const { offsetMinutes, offsetFormat: zoneOffsetAtDate } = getModernOffsetForZoneAndDate(zone, date); - // For validity, we still need to check if the exact date/time exists in the *original* timezone (for gaps/overlaps). - const dateForValidity = DateTime.fromISO(date, { zone }); - const valid = dateForValidity.isValid && date === dateForValidity.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); + // For validity, we still need to check if the exact date/time exists in the *original* timezone. + // Use the fact that in DST gaps Luxon advances the missing time by an hour. + // Ignore milliseconds: + // - milliseconds are not relevant for TZ calculations + // - browsers strip insignificant .000 making string comparison with milliseconds more fragile. + const dateInTimezone = DateTime.fromISO(date, { zone }); + const exists = date.replace(/\.\d+/, '') === dateInTimezone.toFormat("yyyy-MM-dd'T'HH:mm:ss"); + const valid = dateInTimezone.isValid && exists; return { value: zone, offsetMinutes, diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts new file mode 100644 index 0000000000..702f84d6f9 --- /dev/null +++ b/web/src/lib/services/album.service.ts @@ -0,0 +1,52 @@ +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { downloadArchive } from '$lib/utils/asset-utils'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { deleteAlbum, type AlbumResponseDto } from '@immich/sdk'; +import { modalManager, toastManager } from '@immich/ui'; + +export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { prompt?: boolean; notify?: boolean }) => { + const $t = await getFormatter(); + const { prompt = true, notify = true } = options ?? {}; + + if (prompt) { + const confirmation = + album.albumName.length > 0 + ? $t('album_delete_confirmation', { values: { album: album.albumName } }) + : $t('unnamed_album_delete_confirmation'); + const description = $t('album_delete_confirmation_description'); + const success = await modalManager.showDialog({ prompt: `${confirmation} ${description}` }); + if (!success) { + return false; + } + } + + try { + await deleteAlbum({ id: album.id }); + eventManager.emit('AlbumDelete', album); + if (notify) { + toastManager.success(); + } + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_delete_album')); + return false; + } +}; + +export const handleDownloadAlbum = async (album: AlbumResponseDto) => { + await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }); +}; + +export const handleConfirmAlbumDelete = async (album: AlbumResponseDto) => { + const $t = await getFormatter(); + const confirmation = + album.albumName.length > 0 + ? $t('album_delete_confirmation', { values: { album: album.albumName } }) + : $t('unnamed_album_delete_confirmation'); + + const description = $t('album_delete_confirmation_description'); + const prompt = `${confirmation} ${description}`; + + return modalManager.showDialog({ prompt }); +}; diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts new file mode 100644 index 0000000000..3ce6f4222d --- /dev/null +++ b/web/src/lib/services/shared-link.service.ts @@ -0,0 +1,162 @@ +import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; +import { authManager } from '$lib/managers/auth-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; +import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; +import { copyToClipboard } from '$lib/utils'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { + createSharedLink, + removeSharedLink, + removeSharedLinkAssets, + updateSharedLink, + type SharedLinkCreateDto, + type SharedLinkEditDto, + type SharedLinkResponseDto, +} from '@immich/sdk'; +import { MenuItemType, menuManager, modalManager, toastManager, type MenuItem } from '@immich/ui'; +import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiDotsVertical, mdiQrcode } from '@mdi/js'; +import type { MessageFormatter } from 'svelte-i18n'; + +export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLinkResponseDto) => { + const Edit: MenuItem = { + title: $t('edit_link'), + icon: mdiCircleEditOutline, + onSelect: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`), + }; + + const Delete: MenuItem = { + title: $t('delete_link'), + icon: mdiDelete, + color: 'danger', + onSelect: () => void handleDeleteSharedLink(sharedLink), + }; + + const Copy: MenuItem = { + title: $t('copy_link'), + icon: mdiContentCopy, + onSelect: () => void copyToClipboard(asUrl(sharedLink)), + }; + + const ViewQrCode: MenuItem = { + title: $t('view_qr_code'), + icon: mdiQrcode, + onSelect: () => void handleShowSharedLinkQrCode(sharedLink), + }; + + const ContextMenu: MenuItem = { + title: $t('shared_link_options'), + icon: mdiDotsVertical, + onSelect: ({ event }) => + void menuManager.show({ + target: event.currentTarget as HTMLElement, + position: 'top-right', + items: [Edit, Copy, MenuItemType.Divider, Delete], + }), + }; + + return { Edit, Delete, Copy, ViewQrCode, ContextMenu }; +}; + +const asUrl = (sharedLink: SharedLinkResponseDto) => { + const path = sharedLink.slug ? `s/${sharedLink.slug}` : `share/${sharedLink.key}`; + return new URL(path, serverConfigManager.value.externalDomain || globalThis.location.origin).href; +}; + +export const handleCreateSharedLink = async (dto: SharedLinkCreateDto) => { + const $t = await getFormatter(); + + try { + const sharedLink = await createSharedLink({ sharedLinkCreateDto: dto }); + + eventManager.emit('SharedLinkCreate', sharedLink); + + // prevent nested modal + void handleShowSharedLinkQrCode(sharedLink); + + return true; + } catch (error) { + handleError(error, $t('errors.failed_to_create_shared_link')); + return false; + } +}; + +export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto, dto: SharedLinkEditDto) => { + const $t = await getFormatter(); + + try { + const response = await updateSharedLink({ id: sharedLink.id, sharedLinkEditDto: dto }); + + eventManager.emit('SharedLinkUpdate', { album: sharedLink.album, ...response }); + toastManager.success($t('saved')); + + return true; + } catch (error) { + handleError(error, $t('errors.failed_to_edit_shared_link')); + return false; + } +}; + +export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto): Promise => { + const $t = await getFormatter(); + const success = await modalManager.showDialog({ + title: $t('delete_shared_link'), + prompt: $t('confirm_delete_shared_link'), + confirmText: $t('delete'), + }); + if (!success) { + return false; + } + + try { + await removeSharedLink({ id: sharedLink.id }); + eventManager.emit('SharedLinkDelete', sharedLink); + toastManager.success($t('deleted_shared_link')); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_delete_shared_link')); + return false; + } +}; + +export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkResponseDto, assetIds: string[]) => { + const $t = await getFormatter(); + const success = await modalManager.showDialog({ + title: $t('remove_assets_title'), + prompt: $t('remove_assets_shared_link_confirmation', { values: { count: assetIds.length } }), + confirmText: $t('remove'), + }); + if (!success) { + return false; + } + + try { + const results = await removeSharedLinkAssets({ + ...authManager.params, + id: sharedLink.id, + assetIdsDto: { assetIds }, + }); + + for (const result of results) { + if (!result.success) { + continue; + } + + sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== result.assetId); + } + + const count = results.filter((item) => item.success).length; + toastManager.success($t('assets_removed_count', { values: { count } })); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_remove_assets_from_shared_link')); + return false; + } +}; + +const handleShowSharedLinkQrCode = async (sharedLink: SharedLinkResponseDto) => { + const $t = await getFormatter(); + await modalManager.show(QrCodeModal, { title: $t('view_link'), value: asUrl(sharedLink) }); +}; diff --git a/web/src/lib/services/system-config.service.ts b/web/src/lib/services/system-config.service.ts new file mode 100644 index 0000000000..b555c425ef --- /dev/null +++ b/web/src/lib/services/system-config.service.ts @@ -0,0 +1,105 @@ +import { downloadManager } from '$lib/managers/download-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import type { ActionItem } from '$lib/types'; +import { copyToClipboard } from '$lib/utils'; +import { downloadBlob } from '$lib/utils/asset-utils'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { getConfig, updateConfig, type ServerFeaturesDto, type SystemConfigDto } from '@immich/sdk'; +import { toastManager } from '@immich/ui'; +import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js'; +import { isEqual } from 'lodash-es'; +import type { MessageFormatter } from 'svelte-i18n'; + +export const getSystemConfigActions = ( + $t: MessageFormatter, + featureFlags: ServerFeaturesDto, + config: SystemConfigDto, +) => { + const CopyToClipboard: ActionItem = { + title: $t('copy_to_clipboard'), + icon: mdiContentCopy, + onSelect: () => void handleCopyToClipboard(config), + }; + + const Download: ActionItem = { + title: $t('export_as_json'), + icon: mdiDownload, + onSelect: () => handleDownloadConfig(config), + }; + + const Upload: ActionItem = { + title: $t('import_from_json'), + icon: mdiUpload, + $if: () => !featureFlags.configFile, + onSelect: () => handleUploadConfig(), + }; + + return { CopyToClipboard, Download, Upload }; +}; + +export const handleSystemConfigSave = async (update: Partial) => { + const $t = await getFormatter(); + const config = await getConfig(); + const systemConfigDto = { ...config, ...update }; + + if (isEqual(config, systemConfigDto)) { + return; + } + + try { + const newConfig = await updateConfig({ systemConfigDto }); + + eventManager.emit('SystemConfigUpdate', newConfig); + toastManager.success($t('settings_saved')); + } catch (error) { + handleError(error, $t('errors.unable_to_save_settings')); + } +}; + +// https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793 +const jsonReplacer = (_key: string, value: unknown) => + value instanceof Object && !Array.isArray(value) + ? Object.keys(value) + .sort() + // eslint-disable-next-line unicorn/no-array-reduce + .reduce((sorted: { [key: string]: unknown }, key) => { + sorted[key] = (value as { [key: string]: unknown })[key]; + return sorted; + }, {}) + : value; + +export const handleCopyToClipboard = async (config: SystemConfigDto) => { + await copyToClipboard(JSON.stringify(config, jsonReplacer, 2)); +}; + +export const handleDownloadConfig = (config: SystemConfigDto) => { + const blob = new Blob([JSON.stringify(config, jsonReplacer, 2)], { type: 'application/json' }); + const downloadKey = 'immich-config.json'; + downloadManager.add(downloadKey, blob.size); + downloadManager.update(downloadKey, blob.size); + downloadBlob(blob, downloadKey); + setTimeout(() => downloadManager.clear(downloadKey), 5000); +}; + +export const handleUploadConfig = () => { + const input = globalThis.document.createElement('input'); + input.setAttribute('type', 'file'); + input.setAttribute('accept', 'json'); + input.setAttribute('style', 'display: none'); + + input.addEventListener('change', ({ target }) => { + const file = (target as HTMLInputElement).files?.[0]; + if (!file) { + return; + } + const reader = async () => { + const text = await file.text(); + const newConfig = JSON.parse(text); + await handleSystemConfigSave(newConfig); + }; + reader().catch((error) => console.error('Error handling JSON config upload', error)); + globalThis.document.append(input); + }); + input.remove(); +}; diff --git a/web/src/lib/services/user-admin.service.ts b/web/src/lib/services/user-admin.service.ts new file mode 100644 index 0000000000..93b8800b11 --- /dev/null +++ b/web/src/lib/services/user-admin.service.ts @@ -0,0 +1,230 @@ +import { goto } from '$app/navigation'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; +import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte'; +import UserCreateModal from '$lib/modals/UserCreateModal.svelte'; +import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; +import UserEditModal from '$lib/modals/UserEditModal.svelte'; +import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte'; +import { user as authUser } from '$lib/stores/user.store'; +import type { ActionItem } from '$lib/types'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { + createUserAdmin, + deleteUserAdmin, + restoreUserAdmin, + updateUserAdmin, + UserStatus, + type UserAdminCreateDto, + type UserAdminDeleteDto, + type UserAdminResponseDto, + type UserAdminUpdateDto, +} from '@immich/sdk'; +import { MenuItemType, menuManager, modalManager, toastManager } from '@immich/ui'; +import { + mdiDeleteRestore, + mdiDotsVertical, + mdiEyeOutline, + mdiLockReset, + mdiLockSmart, + mdiPencilOutline, + mdiPlusBoxOutline, + mdiTrashCanOutline, +} from '@mdi/js'; +import { DateTime } from 'luxon'; +import type { MessageFormatter } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +const getDeleteDate = (deletedAt: string): Date => + DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate(); + +export const getUserAdminsActions = ($t: MessageFormatter) => { + const Create: ActionItem = { + title: $t('create_user'), + icon: mdiPlusBoxOutline, + onSelect: () => void modalManager.show(UserCreateModal, {}), + }; + + return { Create }; +}; + +export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => { + const View: ActionItem = { + icon: mdiEyeOutline, + title: $t('view'), + onSelect: () => void goto(`/admin/users/${user.id}`), + }; + + const Update: ActionItem = { + icon: mdiPencilOutline, + title: $t('edit'), + onSelect: () => void modalManager.show(UserEditModal, { user }), + }; + + const Delete: ActionItem = { + icon: mdiTrashCanOutline, + title: $t('delete'), + color: 'danger', + $if: () => get(authUser).id !== user.id && !user.deletedAt, + onSelect: () => void modalManager.show(UserDeleteConfirmModal, { user }), + }; + + const Restore: ActionItem = { + icon: mdiDeleteRestore, + title: $t('restore'), + color: 'primary', + $if: () => !!user.deletedAt && user.status === UserStatus.Deleted, + onSelect: () => void modalManager.show(UserRestoreConfirmModal, { user }), + props: { + title: $t('admin.user_restore_scheduled_removal', { + values: { date: getDeleteDate(user.deletedAt!) }, + }), + }, + }; + + const ResetPassword: ActionItem = { + icon: mdiLockReset, + title: $t('reset_password'), + $if: () => get(authUser).id !== user.id, + onSelect: () => void handleResetPasswordUserAdmin(user), + }; + + const ResetPinCode: ActionItem = { + icon: mdiLockSmart, + title: $t('reset_pin_code'), + onSelect: () => void handleResetPinCodeUserAdmin(user), + }; + + const ContextMenu: ActionItem = { + icon: mdiDotsVertical, + title: $t('actions'), + onSelect: ({ event }) => + void menuManager.show({ + target: event.currentTarget as HTMLElement, + position: 'top-right', + items: [ + View, + Update, + ResetPassword, + ResetPinCode, + get(authUser).id === user.id ? undefined : MenuItemType.Divider, + Restore, + Delete, + ].filter(Boolean), + }), + }; + + return { View, Update, Delete, Restore, ResetPassword, ResetPinCode, ContextMenu }; +}; + +export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => { + const $t = await getFormatter(); + + try { + const response = await createUserAdmin({ userAdminCreateDto: dto }); + eventManager.emit('UserAdminCreate', response); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_create_user')); + } +}; + +export const handleUpdateUserAdmin = async (user: UserAdminResponseDto, dto: UserAdminUpdateDto) => { + const $t = await getFormatter(); + + try { + const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto }); + eventManager.emit('UserAdminUpdate', response); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_update_user')); + return false; + } +}; + +export const handleDeleteUserAdmin = async (user: UserAdminResponseDto, dto: UserAdminDeleteDto) => { + const $t = await getFormatter(); + + try { + const result = await deleteUserAdmin({ id: user.id, userAdminDeleteDto: dto }); + eventManager.emit('UserAdminDelete', result); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_delete_user')); + } +}; + +export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => { + const $t = await getFormatter(); + + try { + const response = await restoreUserAdmin({ id: user.id }); + eventManager.emit('UserAdminRestore', response); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_restore_user')); + return false; + } +}; + +// TODO move password reset server-side +const 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; +}; + +export const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => { + const $t = await getFormatter(); + const prompt = $t('admin.confirm_user_password_reset', { values: { user: user.name } }); + const success = await modalManager.showDialog({ prompt }); + if (!success) { + return false; + } + + try { + const dto = { password: generatePassword(), shouldChangePassword: true }; + const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto }); + eventManager.emit('UserAdminUpdate', response); + toastManager.success(); + await modalManager.show(PasswordResetSuccessModal, { newPassword: dto.password }); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_reset_password')); + return false; + } +}; + +export const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => { + const $t = await getFormatter(); + const prompt = $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }); + const success = await modalManager.showDialog({ prompt }); + if (!success) { + return false; + } + + try { + const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } }); + eventManager.emit('UserAdminUpdate', response); + toastManager.success($t('pin_code_reset_successfully')); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_reset_pin_code')); + return false; + } +}; diff --git a/web/src/lib/stores/folders.svelte.ts b/web/src/lib/stores/folders.svelte.ts index f77b67bb7c..3480e95f28 100644 --- a/web/src/lib/stores/folders.svelte.ts +++ b/web/src/lib/stores/folders.svelte.ts @@ -19,7 +19,7 @@ class FoldersStore { private assets = $state({}); constructor() { - eventManager.on('auth.logout', () => this.clearCache()); + eventManager.on('AuthLogout', () => this.clearCache()); } async fetchTree(): Promise { diff --git a/web/src/lib/stores/memory.store.svelte.ts b/web/src/lib/stores/memory.store.svelte.ts index bc42053a21..05f45b3d7d 100644 --- a/web/src/lib/stores/memory.store.svelte.ts +++ b/web/src/lib/stores/memory.store.svelte.ts @@ -21,7 +21,7 @@ export type MemoryAsset = MemoryIndex & { class MemoryStoreSvelte { constructor() { - eventManager.on('auth.logout', () => this.clearCache()); + eventManager.on('AuthLogout', () => this.clearCache()); } memories = $state([]); diff --git a/web/src/lib/stores/notification-manager.svelte.ts b/web/src/lib/stores/notification-manager.svelte.ts index 3eba15deed..a0f0f6bb93 100644 --- a/web/src/lib/stores/notification-manager.svelte.ts +++ b/web/src/lib/stores/notification-manager.svelte.ts @@ -9,8 +9,8 @@ class NotificationStore { notifications = $state([]); constructor() { - eventManager.on('auth.login', () => handlePromiseError(this.refresh())); - eventManager.on('auth.logout', () => this.clear()); + eventManager.on('AuthLogin', () => handlePromiseError(this.refresh())); + eventManager.on('AuthLogout', () => this.clear()); } async refresh() { diff --git a/web/src/lib/stores/search.svelte.ts b/web/src/lib/stores/search.svelte.ts index 32f2741955..007c764fcf 100644 --- a/web/src/lib/stores/search.svelte.ts +++ b/web/src/lib/stores/search.svelte.ts @@ -5,7 +5,7 @@ class SearchStore { isSearchEnabled = $state(false); constructor() { - eventManager.on('auth.logout', () => this.clearCache()); + eventManager.on('AuthLogout', () => this.clearCache()); } clearCache() { diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts deleted file mode 100644 index 46a71ea19e..0000000000 --- a/web/src/lib/stores/server-config.store.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - getConfig, - getServerConfig, - getServerFeatures, - type ServerConfigDto, - type ServerFeaturesDto, - type SystemConfigDto, -} from '@immich/sdk'; -import { writable } from 'svelte/store'; - -export type FeatureFlags = ServerFeaturesDto & { loaded: boolean }; - -export const featureFlags = writable({ - loaded: false, - smartSearch: true, - duplicateDetection: false, - facialRecognition: true, - importFaces: false, - sidecar: true, - map: true, - reverseGeocoding: true, - search: true, - oauth: false, - oauthAutoLaunch: false, - passwordLogin: true, - configFile: false, - trash: true, - email: false, - ocr: true, -}); - -export type ServerConfig = ServerConfigDto & { loaded: boolean }; - -export const serverConfig = writable({ - loaded: false, - oauthButtonText: '', - loginPageMessage: '', - trashDays: 30, - userDeleteDelay: 7, - isInitialized: false, - isOnboarded: false, - externalDomain: '', - mapDarkStyleUrl: '', - mapLightStyleUrl: '', - publicUsers: true, -}); - -export type SystemConfig = SystemConfigDto & { loaded: boolean }; -export const systemConfig = writable(); - -export const retrieveServerConfig = async () => { - const [flags, config] = await Promise.all([getServerFeatures(), getServerConfig()]); - - featureFlags.update(() => ({ ...flags, loaded: true })); - serverConfig.update(() => ({ ...config, loaded: true })); -}; - -export const retrieveSystemConfig = async () => { - const config = await getConfig(); - systemConfig.update(() => ({ ...config, loaded: true })); -}; diff --git a/web/src/lib/stores/user.store.ts b/web/src/lib/stores/user.store.ts index 0790788278..bc23917d22 100644 --- a/web/src/lib/stores/user.store.ts +++ b/web/src/lib/stores/user.store.ts @@ -16,4 +16,4 @@ export const resetSavedUser = () => { purchaseStore.setPurchaseStatus(false); }; -eventManager.on('auth.logout', () => resetSavedUser()); +eventManager.on('AuthLogout', () => resetSavedUser()); diff --git a/web/src/lib/stores/user.svelte.ts b/web/src/lib/stores/user.svelte.ts index 94d73efb9c..f9319fcfa1 100644 --- a/web/src/lib/stores/user.svelte.ts +++ b/web/src/lib/stores/user.svelte.ts @@ -26,4 +26,4 @@ const reset = () => { Object.assign(userInteraction, defaultUserInteraction); }; -eventManager.on('auth.logout', () => reset()); +eventManager.on('AuthLogout', () => reset()); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000000..960158a0f7 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,4 @@ +import type { MenuItem } from '@immich/ui'; +import type { HTMLAttributes } from 'svelte/elements'; + +export type ActionItem = MenuItem & { props?: Omit, 'color'> }; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 3f2d945f39..100f807273 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,13 +1,12 @@ import { defaultLang, langs, locales } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { lang } from '$lib/stores/preferences.store'; -import { serverConfig } from '$lib/stores/server-config.store'; import { handleError } from '$lib/utils/handle-error'; import { AssetJobName, AssetMediaSize, - JobName, MemoryType, + QueueName, finishOAuth, getAssetOriginalPath, getAssetPlaybackPath, @@ -144,28 +143,29 @@ export const downloadRequest = (options: DownloadRequestOptions }); }; -export const getJobName = derived(t, ($t) => { - return (jobName: JobName) => { - const names: Record = { - [JobName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'), - [JobName.MetadataExtraction]: $t('admin.metadata_extraction_job'), - [JobName.Sidecar]: $t('admin.sidecar_job'), - [JobName.SmartSearch]: $t('admin.machine_learning_smart_search'), - [JobName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'), - [JobName.FaceDetection]: $t('admin.face_detection'), - [JobName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'), - [JobName.VideoConversion]: $t('admin.video_conversion_job'), - [JobName.StorageTemplateMigration]: $t('admin.storage_template_migration'), - [JobName.Migration]: $t('admin.migration_job'), - [JobName.BackgroundTask]: $t('admin.background_task_job'), - [JobName.Search]: $t('search'), - [JobName.Library]: $t('external_libraries'), - [JobName.Notifications]: $t('notifications'), - [JobName.BackupDatabase]: $t('admin.backup_database'), - [JobName.Ocr]: $t('admin.machine_learning_ocr'), +export const getQueueName = derived(t, ($t) => { + return (name: QueueName) => { + const names: Record = { + [QueueName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'), + [QueueName.MetadataExtraction]: $t('admin.metadata_extraction_job'), + [QueueName.Sidecar]: $t('admin.sidecar_job'), + [QueueName.SmartSearch]: $t('admin.machine_learning_smart_search'), + [QueueName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'), + [QueueName.FaceDetection]: $t('admin.face_detection'), + [QueueName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'), + [QueueName.VideoConversion]: $t('admin.video_conversion_job'), + [QueueName.StorageTemplateMigration]: $t('admin.storage_template_migration'), + [QueueName.Migration]: $t('admin.migration_job'), + [QueueName.BackgroundTask]: $t('admin.background_task_job'), + [QueueName.Search]: $t('search'), + [QueueName.Library]: $t('external_libraries'), + [QueueName.Notifications]: $t('notifications'), + [QueueName.BackupDatabase]: $t('admin.backup_database'), + [QueueName.Ocr]: $t('admin.machine_learning_ocr'), + [QueueName.Workflow]: $t('workflow'), }; - return names[jobName]; + return names[name]; }; }); @@ -269,11 +269,6 @@ export const copyToClipboard = async (secret: string) => { } }; -export const makeSharedLinkUrl = (sharedLink: SharedLinkResponseDto) => { - const path = sharedLink.slug ? `s/${sharedLink.slug}` : `share/${sharedLink.key}`; - return new URL(path, get(serverConfig).externalDomain || globalThis.location.origin).href; -}; - export const oauth = { isCallback: (location: Location) => { const search = location.search; @@ -406,3 +401,5 @@ export const getReleaseType = ( return 'none'; }; + +export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`; diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index d84ca52a96..af79527841 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -38,7 +38,7 @@ export const deleteAssets = async ( props: { title: $t('success'), description: force - ? $t('assets_permanently_deleted_count') + ? $t('assets_permanently_deleted_count', { values: { count: ids.length } }) : $t('assets_trashed_count', { values: { count: ids.length } }), color: 'success', button: diff --git a/web/src/lib/utils/album-utils.ts b/web/src/lib/utils/album-utils.ts index 0cb8b7fc04..d4541949ca 100644 --- a/web/src/lib/utils/album-utils.ts +++ b/web/src/lib/utils/album-utils.ts @@ -12,7 +12,6 @@ import { import { handleError } from '$lib/utils/handle-error'; import type { AlbumResponseDto } from '@immich/sdk'; import * as sdk from '@immich/sdk'; -import { modalManager } from '@immich/ui'; import { orderBy } from 'lodash-es'; import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; @@ -203,19 +202,6 @@ export const expandAllAlbumGroups = () => { collapseAllAlbumGroups([]); }; -export const confirmAlbumDelete = async (album: AlbumResponseDto) => { - const $t = get(t); - const confirmation = - album.albumName.length > 0 - ? $t('album_delete_confirmation', { values: { album: album.albumName } }) - : $t('unnamed_album_delete_confirmation'); - - const description = $t('album_delete_confirmation_description'); - const prompt = `${confirmation} ${description}`; - - return modalManager.showDialog({ prompt }); -}; - interface AlbumSortOption { [option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumResponseDto[]; } diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index fa857846bb..dc572b541a 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -30,7 +30,6 @@ import { untagAssets, updateAsset, updateAssets, - type AlbumResponseDto, type AssetResponseDto, type AssetTypeEnum, type DownloadInfoDto, @@ -156,12 +155,6 @@ export const removeTag = async ({ return assetIds; }; -export const downloadAlbum = async (album: AlbumResponseDto) => { - await downloadArchive(`${album.albumName}.zip`, { - albumId: album.id, - }); -}; - export const downloadBlob = (data: Blob, filename: string) => { const url = URL.createObjectURL(data); diff --git a/web/src/lib/utils/license-utils.ts b/web/src/lib/utils/license-utils.ts index b592dbff48..82f05a8f5f 100644 --- a/web/src/lib/utils/license-utils.ts +++ b/web/src/lib/utils/license-utils.ts @@ -1,8 +1,7 @@ import { PUBLIC_IMMICH_BUY_HOST, PUBLIC_IMMICH_PAY_HOST } from '$env/static/public'; import type { ImmichProduct } from '$lib/constants'; -import { serverConfig } from '$lib/stores/server-config.store'; +import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { setServerLicense, setUserLicense, type LicenseResponseDto } from '@immich/sdk'; -import { get } from 'svelte/store'; import { loadUser } from './auth'; export const activateProduct = async (licenseKey: string, activationKey: string): Promise => { @@ -24,6 +23,6 @@ export const getActivationKey = async (licenseKey: string): Promise => { export const getLicenseLink = (license: ImmichProduct) => { const url = new URL('/', PUBLIC_IMMICH_BUY_HOST); url.searchParams.append('productId', license); - url.searchParams.append('instanceUrl', get(serverConfig).externalDomain || globalThis.origin); + url.searchParams.append('instanceUrl', serverConfigManager.value.externalDomain || globalThis.origin); return url.href; }; diff --git a/web/src/lib/utils/server.ts b/web/src/lib/utils/server.ts index 1c52274d23..046ee496a8 100644 --- a/web/src/lib/utils/server.ts +++ b/web/src/lib/utils/server.ts @@ -1,4 +1,5 @@ -import { retrieveServerConfig } from '$lib/stores/server-config.store'; +import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; +import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { initLanguage } from '$lib/utils'; import { defaults } from '@immich/sdk'; import { memoize } from 'lodash-es'; @@ -11,7 +12,8 @@ async function _init(fetch: Fetch) { // https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options defaults.fetch = fetch; await initLanguage(); - await retrieveServerConfig(); + await featureFlagsManager.init(); + await serverConfigManager.init(); } export const init = memoize(_init, () => 'singlevalue'); diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 464543cdce..f3c48b99a4 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -8,6 +8,7 @@ import AlbumTitle from '$lib/components/album-page/album-title.svelte'; import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte'; import ActivityViewer from '$lib/components/asset-viewer/activity-viewer.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; @@ -29,21 +30,20 @@ import Timeline from '$lib/components/timeline/Timeline.svelte'; import { AlbumPageViewMode, AppRoute } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte'; import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte'; - import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; + import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { featureFlags } from '$lib/stores/server-config.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { preferences, user } from '$lib/stores/user.store'; - import { handlePromiseError, makeSharedLinkUrl } from '$lib/utils'; - import { confirmAlbumDelete } from '$lib/utils/album-utils'; - import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; + import { handlePromiseError } from '$lib/utils'; + import { cancelMultiselect } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { @@ -59,9 +59,9 @@ AssetVisibility, addAssetsToAlbum, addUsersToAlbum, - deleteAlbum, getAlbumInfo, updateAlbumInfo, + type AlbumResponseDto, type AlbumUserAddDto, } from '@immich/sdk'; import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui'; @@ -233,28 +233,6 @@ } }; - const handleDownloadAlbum = async () => { - await downloadAlbum(album); - }; - - const handleRemoveAlbum = async () => { - const isConfirmed = await confirmAlbumDelete(album); - - if (!isConfirmed) { - viewMode = AlbumPageViewMode.VIEW; - return; - } - - try { - await deleteAlbum({ id: album.id }); - await goto(backUrl); - } catch (error) { - handleError(error, $t('errors.unable_to_delete_album')); - } finally { - viewMode = AlbumPageViewMode.VIEW; - } - }; - const handleSetVisibility = (assetIds: string[]) => { timelineManager.removeAssets(assetIds); assetInteraction.clearMultiselect(); @@ -305,7 +283,7 @@ onNavigate(async ({ to }) => { if (!isAlbumsRoute(to?.route.id) && album.assetCount === 0 && !album.albumName) { - await deleteAlbum(album); + await handleDeleteAlbum(album, { notify: false, prompt: false }); } }); @@ -320,9 +298,6 @@ let timelineManager = $state() as TimelineManager; const options = $derived.by(() => { - if (viewMode === AlbumPageViewMode.VIEW) { - return { albumId, order: albumOrder }; - } if (viewMode === AlbumPageViewMode.SELECT_ASSETS) { return { visibility: AssetVisibility.Timeline, @@ -330,7 +305,7 @@ timelineAlbumId: albumId, }; } - return {}; + return { albumId, order: albumOrder }; }); const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0); @@ -388,14 +363,21 @@ } }; - const handleShareLink = async () => { - const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id }); - if (sharedLink) { - await refreshAlbum(); - await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) }); + const onSharedLinkCreate = async () => { + await refreshAlbum(); + }; + + const onAlbumDelete = async ({ id }: AlbumResponseDto) => { + if (id === album.id) { + await goto(backUrl); + viewMode = AlbumPageViewMode.VIEW; } }; + const handleShareLink = async () => { + await modalManager.show(SharedLinkCreateModal, { albumId: album.id }); + }; + const handleEditUsers = async () => { const changed = await modalManager.show(AlbumUsersModal, { album }); @@ -428,6 +410,8 @@ }; + +
@@ -635,7 +619,7 @@ /> {/if} - {#if $featureFlags.loaded && $featureFlags.map} + {#if featureFlagsManager.value.map} {/if} @@ -653,7 +637,7 @@ variant="ghost" color="secondary" aria-label={$t('download')} - onclick={handleDownloadAlbum} + onclick={() => handleDownloadAlbum(album)} icon={mdiDownload} /> {/if} @@ -674,7 +658,11 @@ {/if} - handleRemoveAlbum()} /> + handleDeleteAlbum(album)} + /> {/if} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index dc3d6a63dd..dd5cbe8b8f 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,16 +1,14 @@ -{#if $featureFlags.loaded && $featureFlags.map} +{#if featureFlagsManager.value.map}
{#await import('$lib/components/shared-components/map/map.svelte')} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts index add9882bcd..e797c4d8b6 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -1,3 +1,7 @@ +import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; +import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; +import { handlePromiseError } from '$lib/utils'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; @@ -8,6 +12,10 @@ export const load = (async ({ params, url }) => { const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); + if (!featureFlagsManager.value.map) { + handlePromiseError(goto(AppRoute.PHOTOS)); + } + return { asset, meta: { diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 52d76ed793..97964344ef 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -21,11 +21,11 @@ import TagAction from '$lib/components/timeline/actions/TagAction.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { lang, locale } from '$lib/stores/preferences.store'; - import { featureFlags } from '$lib/stores/server-config.store'; import { preferences } from '$lib/stores/user.store'; import { handlePromiseError } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; @@ -67,7 +67,7 @@ type SearchTerms = MetadataSearchDto & Pick; let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY)); - let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch); + let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); $effect(() => { diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte index 02f230d609..d8b35204dc 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -2,13 +2,12 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte'; import { AppRoute } from '$lib/constants'; import GroupTab from '$lib/elements/GroupTab.svelte'; - import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; - import { handleError } from '$lib/utils/handle-error'; - import { getAllSharedLinks, removeSharedLink, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk'; - import { modalManager, toastManager } from '@immich/ui'; + import SharedLinkUpdateModal from '$lib/modals/SharedLinkUpdateModal.svelte'; + import { getAllSharedLinks, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -30,38 +29,6 @@ await refresh(); }); - const handleDeleteLink = async (id: string) => { - const isConfirmed = await modalManager.showDialog({ - title: $t('delete_shared_link'), - prompt: $t('confirm_delete_shared_link'), - confirmText: $t('delete'), - }); - - if (!isConfirmed) { - return; - } - - try { - await removeSharedLink({ id }); - toastManager.success($t('deleted_shared_link')); - await refresh(); - } catch (error) { - handleError(error, $t('errors.unable_to_delete_shared_link')); - } - }; - - const handleEditDone = async (updatedLink?: SharedLinkResponseDto) => { - if (updatedLink) { - const index = sharedLinks.findIndex((link) => link.id === updatedLink.id); - if (index !== -1) { - sharedLinks[index] = updatedLink; - } - } else { - await refresh(); - } - await goto(AppRoute.SHARED_LINKS); - }; - type Filter = 'all' | 'album' | 'individual'; const filterMap: Record = { @@ -91,8 +58,21 @@ (type === SharedLinkType.Individual && selectedTab === 'individual'), ), ); + + const onSharedLinkUpdate = (sharedLink: SharedLinkResponseDto) => { + const index = sharedLinks.findIndex((link) => link.id === sharedLink.id); + if (index !== -1) { + sharedLinks[index] = sharedLink; + } + }; + + const onSharedLinkDelete = (sharedLink: SharedLinkResponseDto) => { + sharedLinks = sharedLinks.filter(({ id }) => id !== sharedLink.id); + }; + + {#snippet buttons()} {:else}
- {#each filteredSharedLinks as link (link.id)} - handleDeleteLink(link.id)} /> + {#each filteredSharedLinks as sharedLink (sharedLink.id)} + {/each}
{/if} {#if sharedLink} - + goto(AppRoute.SHARED_LINKS)} /> {/if}
diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index f12d8c80d3..99aad49285 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,5 +1,4 @@ -{#if $featureFlags.loaded && $featureFlags.trash} +{#if featureFlagsManager.value.trash} {#snippet buttons()} @@ -103,9 +97,11 @@ {/snippet} - +

- {$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })} + {$t('trashed_items_will_be_permanently_deleted_after', { + values: { days: serverConfigManager.value.trashDays }, + })}

{#snippet empty()} diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts index 79c41892c7..eddf9aa6af 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -1,3 +1,7 @@ +import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; +import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; +import { handlePromiseError } from '$lib/utils'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; @@ -8,6 +12,10 @@ export const load = (async ({ params, url }) => { const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); + if (!featureFlagsManager.value.trash) { + handlePromiseError(goto(AppRoute.PHOTOS)); + } + return { asset, meta: { diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index da97e1d13a..c6943c6491 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -5,11 +5,11 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte'; import { AppRoute } from '$lib/constants'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { locale } from '$lib/stores/preferences.store'; - import { featureFlags } from '$lib/stores/server-config.store'; import { stackAssets } from '$lib/utils/asset-utils'; import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { handleError } from '$lib/utils/handle-error'; @@ -92,7 +92,7 @@ return; } - const message = $featureFlags.trash + const message = featureFlagsManager.value.trash ? $t('assets_moved_to_trash_count', { values: { count: trashedCount } }) : $t('permanently_deleted_assets_count', { values: { count: trashedCount } }); toastManager.success(message); @@ -101,7 +101,7 @@ const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => { return withConfirmation( async () => { - await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !$featureFlags.trash } }); + await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !featureFlagsManager.value.trash } }); await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); @@ -109,8 +109,8 @@ deletedNotification(trashIds.length); await correctDuplicatesIndexAndGo(duplicatesIndex); }, - trashIds.length > 0 && !$featureFlags.trash ? $t('delete_duplicates_confirmation') : undefined, - trashIds.length > 0 && !$featureFlags.trash ? $t('permanently_delete') : undefined, + trashIds.length > 0 && !featureFlagsManager.value.trash ? $t('delete_duplicates_confirmation') : undefined, + trashIds.length > 0 && !featureFlagsManager.value.trash ? $t('permanently_delete') : undefined, ); }; @@ -129,7 +129,7 @@ ); let prompt, confirmText; - if ($featureFlags.trash) { + if (featureFlagsManager.value.trash) { prompt = $t('bulk_trash_duplicates_confirmation', { values: { count: idsToDelete.length } }); confirmText = $t('confirm'); } else { @@ -139,7 +139,7 @@ return withConfirmation( async () => { - await deleteAssets({ assetBulkDeleteDto: { ids: idsToDelete, force: !$featureFlags.trash } }); + await deleteAssets({ assetBulkDeleteDto: { ids: idsToDelete, force: !featureFlagsManager.value.trash } }); await updateAssets({ assetBulkUpdateDto: { ids: [...idsToDelete, ...idsToKeep.filter((id): id is string => !!id)], @@ -272,7 +272,7 @@ handleResolve(duplicates[duplicatesIndex].duplicateId, duplicateAssetIds, trashIds)} onStack={(assets) => handleStack(duplicates[duplicatesIndex].duplicateId, assets)} /> -
+
- - {#if !$featureFlags.configFile} - - {/if} + + + {/snippet} - - {#snippet children({ savedConfig, defaultConfig })} -
-
- {#if $featureFlags.configFile} - - {/if} -
- -
- - {#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)} - - adminSettingElement?.handleSave(config)} - onReset={(options) => adminSettingElement?.handleReset(options)} - disabled={$featureFlags.configFile} - bind:config - {defaultConfig} - {savedConfig} - /> - - {/each} - -
-
- {/snippet} -
+
+
+ {#if featureFlagsManager.value.configFile} + + {/if} +
+ +
+ + {#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)} + + + + {/each} + +
+
diff --git a/web/src/routes/admin/system-settings/+page.ts b/web/src/routes/admin/system-settings/+page.ts index 294096a4be..10dc0cf246 100644 --- a/web/src/routes/admin/system-settings/+page.ts +++ b/web/src/routes/admin/system-settings/+page.ts @@ -1,15 +1,17 @@ import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; -import { getConfig } from '@immich/sdk'; +import { getConfig, getConfigDefaults } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url, { admin: true }); - const configs = await getConfig(); + const config = await getConfig(); + const defaultConfig = await getConfigDefaults(); const $t = await getFormatter(); return { - configs, + config, + defaultConfig, meta: { title: $t('admin.system_settings'), }, diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index 129862a62c..c4c1012774 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -1,19 +1,16 @@ + + {#snippet buttons()} - + {/snippet}
@@ -93,20 +78,21 @@ {#if allUsers} - {#each allUsers as immichUser (immichUser.id)} + {#each allUsers as user (user.id)} + {@const UserAdminActions = getUserAdminActions($t, user)} - {immichUser.email} + {user.email} - {immichUser.name} + {user.name}
- {#if immichUser.quotaSizeInBytes !== null && immichUser.quotaSizeInBytes >= 0} - {getByteUnitString(immichUser.quotaSizeInBytes, $locale)} + {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} + {getByteUnitString(user.quotaSizeInBytes, $locale)} {:else} {/if} @@ -115,38 +101,8 @@ - {#if !immichUser.deletedAt} - - {#if immichUser.id !== $user.id} - handleDelete(immichUser)} - aria-label={$t('delete_user')} - /> - {/if} - {/if} - {#if immichUser.deletedAt && immichUser.status === UserStatus.Deleted} - handleRestore(immichUser)} - aria-label={$t('admin.user_restore_scheduled_removal')} - /> - {/if} + + {/each} diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index 1414bfbf89..49cfb4715a 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -1,22 +1,18 @@ + + {#snippet buttons()} - {#if canResetPassword} - - {/if} - - - - {#if user.deletedAt} - - {:else} - - {/if} + + + + + {/snippet}
@@ -350,8 +220,8 @@
- - Sessions + + {$t('authorized_devices')}
@@ -360,7 +230,7 @@ {#each userSessions as session (session.id)} {:else} - No mobile devices + {$t('no_devices')} {/each}
diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index 9ed89a0c63..88a557f845 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -3,7 +3,8 @@ import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; import { AppRoute } from '$lib/constants'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; + import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { oauth } from '$lib/utils'; import { getServerErrorMessage, handleError } from '$lib/utils/handle-error'; import { login, type LoginResponseDto } from '@immich/sdk'; @@ -25,16 +26,18 @@ let loading = $state(false); let oauthLoading = $state(true); + const serverConfig = $derived(serverConfigManager.value); + const onSuccess = async (user: LoginResponseDto) => { await goto(data.continueUrl, { invalidateAll: true }); - eventManager.emit('auth.login', user); + eventManager.emit('AuthLogin', user); }; const onFirstLogin = () => goto(AppRoute.AUTH_CHANGE_PASSWORD); const onOnboarding = () => goto(AppRoute.AUTH_ONBOARDING); onMount(async () => { - if (!$featureFlags.oauth) { + if (!featureFlagsManager.value.oauth) { oauthLoading = false; return; } @@ -60,7 +63,7 @@ try { if ( - ($featureFlags.oauthAutoLaunch && !oauth.isAutoLaunchDisabled(globalThis.location)) || + (featureFlagsManager.value.oauthAutoLaunch && !oauth.isAutoLaunchDisabled(globalThis.location)) || oauth.isAutoLaunchEnabled(globalThis.location) ) { await goto(`${AppRoute.AUTH_LOGIN}?autoLaunch=0`, { replaceState: true }); @@ -80,7 +83,7 @@ loading = true; const user = await login({ loginCredentialDto: { email, password } }); - if (user.isAdmin && !$serverConfig.isOnboarded) { + if (user.isAdmin && !serverConfig.isOnboarded) { await onOnboarding(); return; } @@ -123,64 +126,62 @@ }; -{#if $featureFlags.loaded} - - - {#if $serverConfig.loginPageMessage} - - - {@html $serverConfig.loginPageMessage} - - {/if} + + + {#if serverConfig.loginPageMessage} + + + {@html serverConfig.loginPageMessage} + + {/if} - {#if !oauthLoading && $featureFlags.passwordLogin} - - {#if errorMessage} - - {/if} - - - - - - - - - - - - {/if} - - {#if $featureFlags.oauth} - {#if $featureFlags.passwordLogin} -
-
- - {$t('or')} - -
+ {#if !oauthLoading && featureFlagsManager.value.passwordLogin} +
+ {#if errorMessage} + {/if} - {#if oauthError} - - {/if} - - {/if} - {#if !$featureFlags.passwordLogin && !$featureFlags.oauth} - + + + + + + + + + + + {/if} + + {#if featureFlagsManager.value.oauth} + {#if featureFlagsManager.value.passwordLogin} +
+
+ + {$t('or')} + +
{/if} -
-
-{/if} + {#if oauthError} + + {/if} + + {/if} + + {#if !featureFlagsManager.value.passwordLogin && !featureFlagsManager.value.oauth} + + {/if} +
+
diff --git a/web/src/routes/auth/login/+page.ts b/web/src/routes/auth/login/+page.ts index 54c5da716a..5577ab1a7e 100644 --- a/web/src/routes/auth/login/+page.ts +++ b/web/src/routes/auth/login/+page.ts @@ -1,16 +1,13 @@ import { AppRoute } from '$lib/constants'; -import { serverConfig } from '$lib/stores/server-config.store'; +import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { getFormatter } from '$lib/utils/i18n'; - import { redirect } from '@sveltejs/kit'; -import { get } from 'svelte/store'; import type { PageLoad } from './$types'; export const load = (async ({ parent, url }) => { await parent(); - const { isInitialized } = get(serverConfig); - if (!isInitialized) { + if (!serverConfigManager.value.isInitialized) { // Admin not registered redirect(302, AppRoute.AUTH_REGISTER); } diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index d2e9a9f240..44cd97637a 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -11,8 +11,9 @@ import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte'; import OnboardingUserPrivacy from '$lib/components/onboarding-page/onboarding-user-privacy.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; + import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; + import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { OnboardingRole } from '$lib/models/onboarding-role'; - import { retrieveServerConfig, retrieveSystemConfig, serverConfig } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk'; import { @@ -95,7 +96,9 @@ ]); let index = $state(0); - let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER); + let userRole = $derived( + $user.isAdmin && !serverConfigManager.value.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER, + ); let onboardingStepCount = $derived(onboardingSteps.filter((step) => shouldRunStep(step.role, userRole)).length); let onboardingProgress = $derived( @@ -105,7 +108,9 @@ const shouldRunStep = (stepRole: OnboardingRole, userRole: OnboardingRole) => { return ( stepRole === OnboardingRole.USER || - (stepRole === OnboardingRole.SERVER && userRole === OnboardingRole.SERVER && !$serverConfig.isOnboarded) + (stepRole === OnboardingRole.SERVER && + userRole === OnboardingRole.SERVER && + !serverConfigManager.value.isOnboarded) ); }; @@ -127,7 +132,7 @@ if (nextStepIndex == -1) { if ($user.isAdmin) { await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } }); - await retrieveServerConfig(); + await serverConfigManager.loadServerConfig(); } await setUserOnboarding({ @@ -152,11 +157,13 @@ ); }; - onMount(async () => { - await retrieveSystemConfig(); - }); - const OnboardingStep = $derived(onboardingSteps[index].component); + + onMount(async () => { + if (userRole === OnboardingRole.SERVER) { + await systemConfigManager.init(); + } + });
diff --git a/web/src/routes/auth/register/+page.svelte b/web/src/routes/auth/register/+page.svelte index 3eb046e80f..e78f782841 100644 --- a/web/src/routes/auth/register/+page.svelte +++ b/web/src/routes/auth/register/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; import { AppRoute } from '$lib/constants'; - import { retrieveServerConfig } from '$lib/stores/server-config.store'; + import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { handleError } from '$lib/utils/handle-error'; import { signUpAdmin } from '@immich/sdk'; import { Alert, Button, Field, Input, PasswordInput, Text } from '@immich/ui'; @@ -37,7 +37,7 @@ try { await signUpAdmin({ signUpDto: { email, password, name } }); - await retrieveServerConfig(); + await serverConfigManager.loadServerConfig(); await goto(AppRoute.AUTH_LOGIN); } catch (error) { handleError(error, $t('errors.unable_to_create_admin_account')); diff --git a/web/src/routes/auth/register/+page.ts b/web/src/routes/auth/register/+page.ts index 88b56caa47..344a37738c 100644 --- a/web/src/routes/auth/register/+page.ts +++ b/web/src/routes/auth/register/+page.ts @@ -1,14 +1,12 @@ import { AppRoute } from '$lib/constants'; -import { serverConfig } from '$lib/stores/server-config.store'; +import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { getFormatter } from '$lib/utils/i18n'; import { redirect } from '@sveltejs/kit'; -import { get } from 'svelte/store'; import type { PageLoad } from './$types'; export const load = (async ({ parent }) => { await parent(); - const { isInitialized } = get(serverConfig); - if (isInitialized) { + if (serverConfigManager.value.isInitialized) { // Admin has been registered, redirect to login redirect(302, AppRoute.AUTH_LOGIN); } diff --git a/web/src/test-data/factories/preferences-factory.ts b/web/src/test-data/factories/preferences-factory.ts index e7d556b00b..f8ab8615bf 100644 --- a/web/src/test-data/factories/preferences-factory.ts +++ b/web/src/test-data/factories/preferences-factory.ts @@ -23,6 +23,7 @@ export const preferencesFactory = Sync.makeFactory({ }, memories: { enabled: false, + duration: 5, }, people: { enabled: false,