From 72cefcabafa75a9d7aef8e04823ceb1a94c54d20 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Sat, 14 Feb 2026 17:40:27 +0100 Subject: [PATCH 001/143] chore: discourage LLM-generated PRs (#26211) * chore: discourage LLM-generated PRs * chore: add reading CONTRIBUTING.md to PR checklist * chore: workflow to close LLM-generated PRs --- .github/pull_request_template.md | 1 + .github/workflows/close-llm-pr.yml | 38 ++++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/close-llm-pr.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0bd3b30814..2d1fdafa30 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -26,6 +26,7 @@ The `/api/something` endpoint is now `/api/something-else` ## Checklist: +- [ ] I have carefully read CONTRIBUTING.md - [ ] I have performed a self-review of my own code - [ ] I have made corresponding changes to the documentation if applicable - [ ] I have no unrelated changes in the PR. diff --git a/.github/workflows/close-llm-pr.yml b/.github/workflows/close-llm-pr.yml new file mode 100644 index 0000000000..f17d98e684 --- /dev/null +++ b/.github/workflows/close-llm-pr.yml @@ -0,0 +1,38 @@ +name: Close LLM-generated PRs + +on: + pull_request: + types: [labeled] + +permissions: {} + +jobs: + comment_and_close: + runs-on: ubuntu-latest + if: ${{ github.event.label.name == 'llm-generated' }} + permissions: + pull-requests: write + steps: + - name: Comment and close + env: + GH_TOKEN: ${{ github.token }} + NODE_ID: ${{ github.event.pull_request.node_id }} + run: | + gh api graphql \ + -f prId="$NODE_ID" \ + -f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our CONTRIBUTING.md, we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \ + -f query=' + mutation CommentAndClosePR($prId: ID!, $body: String!) { + addComment(input: { + subjectId: $prId, + body: $body + }) { + __typename + } + + closePullRequest(input: { + pullRequestId: $prId + }) { + __typename + } + }' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 109708cc6e..1695403cb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ If you are looking for something to work on, there are discussions and issues wi ## Use of generative AI -We generally discourage PRs entirely generated by an LLM. For any part generated by an LLM, please put extra effort into your self-review. By using generative AI without proper self-review, the time you save ends up being more work we need to put in for proper reviews and code cleanup. Please keep that in mind when submitting code by an LLM. Clearly state the use of LLMs/(generative) AI in your pull request as requested by the template. +We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request. ## Feature freezes From 2c9d69865c834d85ac30a19705aa002221760148 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Sat, 14 Feb 2026 12:51:54 -0500 Subject: [PATCH 002/143] fix: e2e exit 135 (#26214) --- e2e/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index a98a7013a4..5a79396aa5 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -53,6 +53,7 @@ services: POSTGRES_DB: immich ports: - 5435:5432 + shm_size: 128mb healthcheck: test: ['CMD-SHELL', 'pg_isready -U postgres -d immich'] interval: 1s From d264e78d3f1a1b06e37ac696fa03b0f7591d3d17 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Sat, 14 Feb 2026 15:03:08 -0500 Subject: [PATCH 003/143] chore: pnpm workspace protocol for sibling packagages (#26218) --- cli/package.json | 2 +- e2e/package.json | 6 +++--- pnpm-lock.yaml | 10 +++++----- web/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cli/package.json b/cli/package.json index 28bee420aa..d80efdd74a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -14,7 +14,7 @@ ], "devDependencies": { "@eslint/js": "^9.8.0", - "@immich/sdk": "file:../open-api/typescript-sdk", + "@immich/sdk": "workspace:*", "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", diff --git a/e2e/package.json b/e2e/package.json index 01dd036a2f..abe46a39ca 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -21,9 +21,9 @@ "devDependencies": { "@eslint/js": "^9.8.0", "@faker-js/faker": "^10.1.0", - "@immich/cli": "file:../cli", - "@immich/e2e-auth-server": "file:../e2e-auth-server", - "@immich/sdk": "file:../open-api/typescript-sdk", + "@immich/cli": "workspace:*", + "@immich/e2e-auth-server": "workspace:*", + "@immich/sdk": "workspace:*", "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a435f3db6d..9af490b82a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,7 +45,7 @@ importers: specifier: ^9.8.0 version: 9.39.2 '@immich/sdk': - specifier: file:../open-api/typescript-sdk + specifier: workspace:* version: link:../open-api/typescript-sdk '@types/byte-size': specifier: ^8.1.0 @@ -202,13 +202,13 @@ importers: specifier: ^10.1.0 version: 10.3.0 '@immich/cli': - specifier: file:../cli + specifier: workspace:* version: link:../cli '@immich/e2e-auth-server': - specifier: file:../e2e-auth-server + specifier: workspace:* version: link:../e2e-auth-server '@immich/sdk': - specifier: file:../open-api/typescript-sdk + specifier: workspace:* version: link:../open-api/typescript-sdk '@playwright/test': specifier: ^1.44.1 @@ -738,7 +738,7 @@ importers: specifier: ^0.4.3 version: 0.4.3 '@immich/sdk': - specifier: file:../open-api/typescript-sdk + specifier: workspace:* version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.62.1 diff --git a/web/package.json b/web/package.json index e172584c5d..5b66c75029 100644 --- a/web/package.json +++ b/web/package.json @@ -26,7 +26,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", - "@immich/sdk": "file:../open-api/typescript-sdk", + "@immich/sdk": "workspace:*", "@immich/ui": "^0.62.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", From 9ab887d5d229be9ac506a002a01558a5580e7e63 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:24:47 +0100 Subject: [PATCH 004/143] perf(web): speed up multi asset operations (#26217) --- .../timeline-manager/day-group.svelte.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/web/src/lib/managers/timeline-manager/day-group.svelte.ts b/web/src/lib/managers/timeline-manager/day-group.svelte.ts index e21e54a6e5..ba12f4bb6c 100644 --- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts @@ -102,25 +102,21 @@ export class DayGroup { } runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) { - if (ids.size === 0) { - return { - moveAssets: [] as MoveAsset[], - processedIds: new SvelteSet(), - unprocessedIds: ids, - changedGeometry: false, - }; - } const unprocessedIds = new SvelteSet(ids); const processedIds = new SvelteSet(); const moveAssets: MoveAsset[] = []; let changedGeometry = false; - for (const assetId of unprocessedIds) { - const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId); - if (index === -1) { + + if (ids.size === 0) { + return { moveAssets, processedIds, unprocessedIds, changedGeometry }; + } + + for (let index = this.viewerAssets.length - 1; index >= 0; index--) { + const { id: assetId, asset } = this.viewerAssets[index]; + if (!ids.has(assetId)) { continue; } - const asset = this.viewerAssets[index].asset!; const oldTime = { ...asset.localDateTime }; const callbackResult = callback(asset); let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false; From 49ba833e4c12c84b405ff6f8c6d59c65652bc64d Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 14 Feb 2026 14:25:14 -0600 Subject: [PATCH 005/143] fix(web): Revert "add checkerboard background for transparent images (#26091)" (#26220) Revert "fix(web): add checkerboard background for transparent images (#26091)" This reverts commit bc7a1c838ca3ab5db3e86b2d8a98733d964e6e7c. --- web/src/lib/components/asset-viewer/photo-viewer.svelte | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 7c3ab2eb21..2101107f6e 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -226,7 +226,7 @@ alt={$getAltText(toTimelineAsset(asset))} class="h-full w-full {$slideshowState === SlideshowState.None ? 'object-contain' - : slideshowLookCssMapping[$slideshowLook]} checkerboard" + : slideshowLookCssMapping[$slideshowLook]}" draggable="false" /> @@ -259,8 +259,4 @@ visibility: hidden; animation: 0s linear 0.4s forwards delayedVisibility; } - .checkerboard { - background-image: conic-gradient(#808080 25%, #b0b0b0 25% 50%, #808080 50% 75%, #b0b0b0 75%); - background-size: 20px 20px; - } From ff7dca35f5c6af2a514b041a33fa4f2247aecfa0 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:31:04 +0100 Subject: [PATCH 006/143] perf(web): speed up asset selection (#26216) --- .../lib/stores/asset-interaction.svelte.ts | 20 ++++++++----------- web/src/lib/utils/asset-utils.ts | 7 ++++--- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/web/src/lib/stores/asset-interaction.svelte.ts b/web/src/lib/stores/asset-interaction.svelte.ts index 9cfc1b2c8e..817354e619 100644 --- a/web/src/lib/stores/asset-interaction.svelte.ts +++ b/web/src/lib/stores/asset-interaction.svelte.ts @@ -1,14 +1,15 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { user } from '$lib/stores/user.store'; import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk'; -import { SvelteSet } from 'svelte/reactivity'; +import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { fromStore } from 'svelte/store'; export class AssetInteraction { - selectedAssets = $state([]); + private selectedAssetsMap = new SvelteMap(); + selectedAssets = $derived(Array.from(this.selectedAssetsMap.values())); selectAll = $state(false); hasSelectedAsset(assetId: string) { - return this.selectedAssets.some((asset) => asset.id === assetId); + return this.selectedAssetsMap.has(assetId); } selectedGroup = new SvelteSet(); assetSelectionCandidates = $state([]); @@ -16,7 +17,7 @@ export class AssetInteraction { return this.assetSelectionCandidates.some((asset) => asset.id === assetId); } assetSelectionStart = $state(null); - selectionActive = $derived(this.selectedAssets.length > 0); + selectionActive = $derived(this.selectedAssetsMap.size > 0); private user = fromStore(user); private userId = $derived(this.user.current?.id); @@ -27,9 +28,7 @@ export class AssetInteraction { isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId)); selectAsset(asset: TimelineAsset) { - if (!this.hasSelectedAsset(asset.id)) { - this.selectedAssets.push(asset); - } + this.selectedAssetsMap.set(asset.id, asset); } selectAssets(assets: TimelineAsset[]) { @@ -39,10 +38,7 @@ export class AssetInteraction { } removeAssetFromMultiselectGroup(assetId: string) { - const index = this.selectedAssets.findIndex((a) => a.id == assetId); - if (index !== -1) { - this.selectedAssets.splice(index, 1); - } + this.selectedAssetsMap.delete(assetId); } addGroupToMultiselectGroup(group: string) { @@ -69,7 +65,7 @@ export class AssetInteraction { this.selectAll = false; // Multi-selection - this.selectedAssets = []; + this.selectedAssetsMap.clear(); this.selectedGroup.clear(); // Range selection diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 84e386d620..47df967844 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -4,7 +4,6 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { downloadManager } from '$lib/managers/download-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { Route } from '$lib/route'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; @@ -443,13 +442,15 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt try { for (const monthGroup of timelineManager.months) { - await timelineManager.loadMonthGroup(monthGroup.yearMonth); + if (!monthGroup.isLoaded) { + await timelineManager.loadMonthGroup(monthGroup.yearMonth); + } if (!assetInteraction.selectAll) { assetInteraction.clearMultiselect(); break; // Cancelled } - assetInteraction.selectAssets(assetsSnapshot([...monthGroup.assetsIterator()])); + assetInteraction.selectAssets([...monthGroup.assetsIterator()]); for (const dateGroup of monthGroup.dayGroups) { assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle); From df4c25e567ef37a8160a368af396a9aee01f8a2e Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Sun, 15 Feb 2026 11:47:01 +0100 Subject: [PATCH 007/143] fix: use pull_request_target in close-llm-pr.yml (#26232) So that it actually has write permissions; this should be safe as it doesn't use any external input. --- .github/workflows/close-llm-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/close-llm-pr.yml b/.github/workflows/close-llm-pr.yml index f17d98e684..a6dbfba9af 100644 --- a/.github/workflows/close-llm-pr.yml +++ b/.github/workflows/close-llm-pr.yml @@ -1,7 +1,7 @@ name: Close LLM-generated PRs on: - pull_request: + pull_request_target: types: [labeled] permissions: {} From 75e3b0467af4d3f68ba07d1e8aea70c5a5a0aba4 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:51:47 +0100 Subject: [PATCH 008/143] chore: hyperlink contributing file in llm message (#26234) --- .github/workflows/close-llm-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/close-llm-pr.yml b/.github/workflows/close-llm-pr.yml index a6dbfba9af..511d5c7f55 100644 --- a/.github/workflows/close-llm-pr.yml +++ b/.github/workflows/close-llm-pr.yml @@ -20,7 +20,7 @@ jobs: run: | gh api graphql \ -f prId="$NODE_ID" \ - -f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our CONTRIBUTING.md, we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \ + -f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \ -f query=' mutation CommentAndClosePR($prId: ID!, $body: String!) { addComment(input: { From 5f87047490e9af180cc17f23ee6da3bd9da98289 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:53:45 +0000 Subject: [PATCH 009/143] feat(mobile): dynamic multi-line album name (#26040) * feat(mobile): dynamic multi-line album name Album names are currently limited to a single line, and scroll on overflow. It would be better if album names were multi-line, and even better if the font size was dynamic depending on how many lines there are. The album name should then overflow with an ellipsis. This is actually quite similar to how Google Photos handles album names. * lint --------- Co-authored-by: timonrieger --- .../common/remote_album_sliver_app_bar.dart | 64 ++++++++++++++----- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 30eaf4c555..50746f5cbd 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'dart:ui'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -254,22 +254,9 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S ), GestureDetector( onTap: widget.onEditTitle, - child: SizedBox( - width: double.infinity, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Text( - currentAlbum.name, - maxLines: 1, - style: const TextStyle( - color: Colors.white, - fontSize: 36, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black54)], - ), - ), - ), + child: LayoutBuilder( + builder: (context, constraints) => + _DynamicText(text: currentAlbum.name, maxWidth: constraints.maxWidth), ), ), if (currentAlbum.description.isNotEmpty) @@ -549,3 +536,46 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic ); } } + +class _DynamicText extends StatelessWidget { + final String text; + final double maxWidth; + + const _DynamicText({required this.text, required this.maxWidth}); + + static const _baseTextStyle = TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black54)], + overflow: TextOverflow.ellipsis, + ); + + int _lineCount(double fontSize) { + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: _baseTextStyle.copyWith(fontSize: fontSize), + ), + maxLines: 3, + textDirection: TextDirection.ltr, + )..layout(maxWidth: maxWidth); + return textPainter.computeLineMetrics().length; + } + + double _fontSize() { + final fontSizes = [44.0, 36.0]; + for (final fontSize in fontSizes) { + final lineCount = _lineCount(fontSize); + if (lineCount == 1) { + return fontSize; + } + } + return 28; + } + + @override + Widget build(BuildContext context) { + return Text(text, style: _baseTextStyle.copyWith(fontSize: _fontSize()), maxLines: 3); + } +} From f6e10afe2b160ab6581658e33f3079b434193422 Mon Sep 17 00:00:00 2001 From: Dusan Hlavaty Date: Sun, 15 Feb 2026 21:34:02 +0100 Subject: [PATCH 010/143] chore(docs): fix discord channel in docs (#26238) --- docs/docs/developer/devcontainers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/developer/devcontainers.md b/docs/docs/developer/devcontainers.md index f50ec62d8a..c4f673de6e 100644 --- a/docs/docs/developer/devcontainers.md +++ b/docs/docs/developer/devcontainers.md @@ -408,7 +408,7 @@ If you encounter issues: 1. Check container logs: View → Output → Select "Dev Containers" 2. Rebuild without cache: "Dev Containers: Rebuild Container Without Cache" 3. Review [common Docker issues](https://docs.docker.com/desktop/troubleshoot/) -4. Ask in [Discord](https://discord.immich.app) `#help-desk-support` channel +4. Ask in [Discord](https://discord.immich.app) `#contributing` channel ## Mobile Development From c9dd8e0a791ffd5360f538613f453e8a37aa9a57 Mon Sep 17 00:00:00 2001 From: Nicolas <63413694+Nacolis@users.noreply.github.com> Date: Mon, 16 Feb 2026 03:41:56 +0100 Subject: [PATCH 011/143] feat(mobile): hide search by context/OCR if disabled on server (#25472) (#26063) * feat(mobile): hide search by context/OCR if disabled on server (#25472) * revert(mobile): remove changes to old search page --------- Co-authored-by: Nicolas --- .../server_info/server_features.model.dart | 27 +++++++++--- .../pages/search/drift_search.page.dart | 43 ++++++++++++------- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/mobile/lib/models/server_info/server_features.model.dart b/mobile/lib/models/server_info/server_features.model.dart index 049628a8d2..78a80c9013 100644 --- a/mobile/lib/models/server_info/server_features.model.dart +++ b/mobile/lib/models/server_info/server_features.model.dart @@ -6,6 +6,7 @@ class ServerFeatures { final bool oauthEnabled; final bool passwordLogin; final bool ocr; + final bool smartSearch; const ServerFeatures({ required this.trash, @@ -13,21 +14,30 @@ class ServerFeatures { required this.oauthEnabled, required this.passwordLogin, this.ocr = false, + this.smartSearch = false, }); - ServerFeatures copyWith({bool? trash, bool? map, bool? oauthEnabled, bool? passwordLogin, bool? ocr}) { + ServerFeatures copyWith({ + bool? trash, + bool? map, + bool? oauthEnabled, + bool? passwordLogin, + bool? ocr, + bool? smartSearch, + }) { return ServerFeatures( trash: trash ?? this.trash, map: map ?? this.map, oauthEnabled: oauthEnabled ?? this.oauthEnabled, passwordLogin: passwordLogin ?? this.passwordLogin, ocr: ocr ?? this.ocr, + smartSearch: smartSearch ?? this.smartSearch, ); } @override String toString() { - return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr)'; + return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)'; } ServerFeatures.fromDto(ServerFeaturesDto dto) @@ -35,7 +45,8 @@ class ServerFeatures { map = dto.map, oauthEnabled = dto.oauth, passwordLogin = dto.passwordLogin, - ocr = dto.ocr; + ocr = dto.ocr, + smartSearch = dto.smartSearch; @override bool operator ==(covariant ServerFeatures other) { @@ -45,11 +56,17 @@ class ServerFeatures { other.map == map && other.oauthEnabled == oauthEnabled && other.passwordLogin == passwordLogin && - other.ocr == ocr; + other.ocr == ocr && + other.smartSearch == smartSearch; } @override int get hashCode { - return trash.hashCode ^ map.hashCode ^ oauthEnabled.hashCode ^ passwordLogin.hashCode ^ ocr.hashCode; + return trash.hashCode ^ + map.hashCode ^ + oauthEnabled.hashCode ^ + passwordLogin.hashCode ^ + ocr.hashCode ^ + smartSearch.hashCode; } } diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 16655e98f6..62ec11f7ed 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/feature_check.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; @@ -39,8 +40,15 @@ class DriftSearchPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final textSearchType = useState(TextSearchType.context); - final searchHintText = useState('sunrise_on_the_beach'.t(context: context)); + final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures)); + final textSearchType = useState( + serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename, + ); + final searchHintText = useState( + serverFeatures.smartSearch + ? 'sunrise_on_the_beach'.t(context: context) + : 'file_name_or_extension'.t(context: context), + ); final textSearchController = useTextEditingController(); final preFilter = ref.watch(searchPreFilterProvider); final filter = useState( @@ -518,23 +526,26 @@ class DriftSearchPage extends HookConsumerWidget { ); }, menuChildren: [ - MenuItemButton( - child: ListTile( - leading: const Icon(Icons.image_search_rounded), - title: Text( - 'search_by_context'.t(context: context), - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null, + FeatureCheck( + feature: (features) => features.smartSearch, + child: MenuItemButton( + child: ListTile( + leading: const Icon(Icons.image_search_rounded), + title: Text( + 'search_by_context'.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null, + ), ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.context, ), - selectedColor: context.colorScheme.primary, - selected: textSearchType.value == TextSearchType.context, + onPressed: () { + textSearchType.value = TextSearchType.context; + searchHintText.value = 'sunrise_on_the_beach'.t(context: context); + }, ), - onPressed: () { - textSearchType.value = TextSearchType.context; - searchHintText.value = 'sunrise_on_the_beach'.t(context: context); - }, ), MenuItemButton( child: ListTile( From d2682f160e45114daf4161157b0b90527566d6e1 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:24:57 +0000 Subject: [PATCH 012/143] fix(mobile): inherit toolbar opacity (#25694) Some widgets, like Icon widgets, automatically inherit opacity from the icon theme in the context. Many other widgets however, do not. The Immich logo, profile picture, and backup badge are examples of widgets of this. All unsupported toolbar widgets have been updated to support inheriting the opacity from the icon theme. IconButtons internally animate properties like opacity, which is kind of nice, but means we have to do more work to replicate that behaviour for other widgets. In most cases, we can simply use an IconButton widget and forward the correct opacity. The Immich logo however is not a button, and therefore we need to use a custom TweenAnimationBuilder. All widgets are using efficient, native opacity rather than the heavy Opacity widget. --- .../lib/pages/album/album_options.page.dart | 2 +- .../pages/album/album_shared_user_icons.dart | 2 +- .../presentation/pages/drift_album.page.dart | 2 +- .../pages/drift_album_options.page.dart | 4 +- .../album/drift_activity_text_field.dart | 2 +- .../activities/activity_text_field.dart | 2 +- .../lib/widgets/activities/activity_tile.dart | 2 +- .../widgets/activities/comment_bubble.dart | 2 +- .../album/remote_album_shared_user_icons.dart | 2 +- .../app_bar_dialog/app_bar_profile_info.dart | 2 +- mobile/lib/widgets/common/immich_app_bar.dart | 2 +- .../widgets/common/immich_sliver_app_bar.dart | 185 +++++++++--------- .../widgets/common/user_circle_avatar.dart | 39 ++-- 13 files changed, 125 insertions(+), 123 deletions(-) diff --git a/mobile/lib/pages/album/album_options.page.dart b/mobile/lib/pages/album/album_options.page.dart index b0f682ffed..ca65a92a79 100644 --- a/mobile/lib/pages/album/album_options.page.dart +++ b/mobile/lib/pages/album/album_options.page.dart @@ -134,7 +134,7 @@ class AlbumOptionsPage extends HookConsumerWidget { itemBuilder: (context, index) { final user = sharedUsers.value[index]; return ListTile( - leading: UserCircleAvatar(user: user, radius: 22), + leading: UserCircleAvatar(user: user), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(), diff --git a/mobile/lib/pages/album/album_shared_user_icons.dart b/mobile/lib/pages/album/album_shared_user_icons.dart index fe1823ec61..7cf6f387ae 100644 --- a/mobile/lib/pages/album/album_shared_user_icons.dart +++ b/mobile/lib/pages/album/album_shared_user_icons.dart @@ -41,7 +41,7 @@ class AlbumSharedUserIcons extends HookConsumerWidget { itemBuilder: ((context, index) { return Padding( padding: const EdgeInsets.only(right: 8.0), - child: UserCircleAvatar(user: sharedUsers.value[index], radius: 18, size: 36), + child: UserCircleAvatar(user: sharedUsers.value[index], size: 36), ); }), itemCount: sharedUsers.value.length, diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index cde8c127db..c9fed636b4 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -44,8 +44,8 @@ class _DriftAlbumsPageState extends ConsumerState { pinned: true, actions: [ IconButton( - icon: const Icon(Icons.add_rounded, size: 28), onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()), + icon: const Icon(Icons.add_rounded), ), ], showUploadButton: false, diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 9db6e98613..061edbaf26 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -149,7 +149,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { } return ListTile( - leading: UserCircleAvatar(user: user, radius: 22), + leading: UserCircleAvatar(user: user), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context), @@ -169,7 +169,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { itemBuilder: (context, index) { final user = sharedUsers[index]; return ListTile( - leading: UserCircleAvatar(user: user, radius: 22), + leading: UserCircleAvatar(user: user), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(), diff --git a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart index fe5c763ec5..691b46f80d 100644 --- a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart +++ b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart @@ -88,7 +88,7 @@ class _DriftActivityTextFieldState extends ConsumerState prefixIcon: user != null ? Padding( padding: const EdgeInsets.symmetric(horizontal: 15), - child: UserCircleAvatar(user: user, size: 30, radius: 15), + child: UserCircleAvatar(user: user, size: 30), ) : null, suffixIcon: IconButton( diff --git a/mobile/lib/widgets/activities/activity_text_field.dart b/mobile/lib/widgets/activities/activity_text_field.dart index a61a284844..d21cdfbc94 100644 --- a/mobile/lib/widgets/activities/activity_text_field.dart +++ b/mobile/lib/widgets/activities/activity_text_field.dart @@ -63,7 +63,7 @@ class ActivityTextField extends HookConsumerWidget { prefixIcon: user != null ? Padding( padding: const EdgeInsets.symmetric(horizontal: 15), - child: UserCircleAvatar(user: user, size: 30, radius: 15), + child: UserCircleAvatar(user: user, size: 30), ) : null, suffixIcon: Padding( diff --git a/mobile/lib/widgets/activities/activity_tile.dart b/mobile/lib/widgets/activities/activity_tile.dart index e0eccbff21..ac3b6c95a4 100644 --- a/mobile/lib/widgets/activities/activity_tile.dart +++ b/mobile/lib/widgets/activities/activity_tile.dart @@ -40,7 +40,7 @@ class ActivityTile extends HookConsumerWidget { child: Icon(Icons.thumb_up, color: context.primaryColor), ) : isBottomSheet - ? UserCircleAvatar(user: activity.user, size: 30, radius: 15) + ? UserCircleAvatar(user: activity.user, size: 30) : UserCircleAvatar(user: activity.user), title: _ActivityTitle( userName: activity.user.name, diff --git a/mobile/lib/widgets/activities/comment_bubble.dart b/mobile/lib/widgets/activities/comment_bubble.dart index 5f060833a7..401e4b8e99 100644 --- a/mobile/lib/widgets/activities/comment_bubble.dart +++ b/mobile/lib/widgets/activities/comment_bubble.dart @@ -41,7 +41,7 @@ class CommentBubble extends ConsumerWidget { // avatar (hidden for own messages) Widget avatar = const SizedBox.shrink(); if (!isOwn) { - avatar = UserCircleAvatar(user: activity.user, size: 28, radius: 14); + avatar = UserCircleAvatar(user: activity.user, size: 28); } // Thumbnail with tappable behavior and optional heart overlay diff --git a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart index 8913e94136..2025fa7583 100644 --- a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart +++ b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart @@ -33,7 +33,7 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget { itemBuilder: ((context, index) { return Padding( padding: const EdgeInsets.only(right: 4.0), - child: UserCircleAvatar(user: sharedUsers[index], radius: 18, size: 36, hasBorder: true), + child: UserCircleAvatar(user: sharedUsers[index], size: 36, hasBorder: true), ); }), itemCount: sharedUsers.length, diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index b0c005424f..12273849f2 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -34,7 +34,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { ); } - final userImage = UserCircleAvatar(radius: 22, size: 44, user: user); + final userImage = UserCircleAvatar(size: 44, user: user); if (uploadProfileImageStatus == UploadProfileStatus.loading) { return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20)); diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index b3dc04236c..ebd8ed8b36 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -51,7 +51,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { ? const Icon(Icons.face_outlined, size: widgetSize) : Semantics( label: "logged_in_as".tr(namedArgs: {"user": user.name}), - child: UserCircleAvatar(radius: 17, size: 31, user: user), + child: UserCircleAvatar(size: 32, user: user), ), ), ); diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 95622c1e5a..939e9e27aa 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -46,45 +48,42 @@ class ImmichSliverAppBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); - return SliverAnimatedOpacity( - duration: Durations.medium1, - opacity: isMultiSelectEnabled ? 0 : 1, - sliver: SliverAppBar( - backgroundColor: context.colorScheme.surface, - surfaceTintColor: context.colorScheme.surfaceTint, - elevation: 0, - scrolledUnderElevation: 1.0, - floating: floating, - pinned: pinned, - snap: snap, - expandedHeight: expandedHeight, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), - automaticallyImplyLeading: false, - centerTitle: false, - title: title ?? const _ImmichLogoWithText(), - actions: [ - if (isCasting && !isReadonlyModeEnabled) - Padding( - padding: const EdgeInsets.only(right: 12), - child: IconButton( - onPressed: () { - showDialog(context: context, builder: (context) => const CastDialog()); - }, + return SliverIgnorePointer( + ignoring: isMultiSelectEnabled, + sliver: SliverAnimatedOpacity( + duration: Durations.medium1, + opacity: isMultiSelectEnabled ? 0 : 1, + sliver: SliverAppBar( + backgroundColor: context.colorScheme.surface, + surfaceTintColor: context.colorScheme.surfaceTint, + elevation: 0, + scrolledUnderElevation: 1.0, + floating: floating, + pinned: pinned, + snap: snap, + expandedHeight: expandedHeight, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), + automaticallyImplyLeading: false, + centerTitle: false, + title: title ?? const _ImmichLogoWithText(), + actions: [ + const _SyncStatusIndicator(), + if (isCasting && !isReadonlyModeEnabled) + IconButton( + onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()), icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded), ), - ), - const _SyncStatusIndicator(), - if (actions != null) - ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), - if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) - IconButton( - icon: const Icon(Icons.palette_rounded), - onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), - ), - if (showUploadButton && !isReadonlyModeEnabled) - const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), - const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()), - ], + if (actions != null) ...actions!, + if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) + IconButton( + onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), + icon: const Icon(Icons.palette_rounded), + ), + if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(), + const _ProfileIndicator(), + const SizedBox(width: 8), + ], + ), ), ); } @@ -94,27 +93,14 @@ class _ImmichLogoWithText extends StatelessWidget { const _ImmichLogoWithText(); @override - Widget build(BuildContext context) { - return Builder( - builder: (BuildContext context) { - return Row( - children: [ - Builder( - builder: (context) { - return Padding( - padding: const EdgeInsets.only(top: 3.0), - child: SvgPicture.asset( - context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg', - height: 40, - ), - ); - }, - ), - ], - ); - }, - ); - } + Widget build(BuildContext context) => AnimatedOpacity( + opacity: IconTheme.of(context).opacity ?? 1, + duration: kThemeChangeDuration, + child: SvgPicture.asset( + context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg', + height: 40, + ), + ); } class _ProfileIndicator extends ConsumerWidget { @@ -126,7 +112,7 @@ class _ProfileIndicator extends ConsumerWidget { final bool versionWarningPresent = ref.watch(versionWarningPresentProvider(user)); final serverInfoState = ref.watch(serverInfoProvider); - const widgetSize = 30.0; + const widgetSize = 32.0; // TODO: remove this when update Flutter version newer than 3.35.7 final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile; @@ -146,27 +132,23 @@ class _ProfileIndicator extends ConsumerWidget { ); } - return InkWell( - onTap: () => showDialog( + return IconButton( + onPressed: () => showDialog( context: context, useRootNavigator: false, barrierDismissible: !isIpad, builder: (ctx) => const ImmichAppBarDialog(), ), onLongPress: () => toggleReadonlyMode(), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Badge( - label: Container( - decoration: BoxDecoration( - color: context.isDarkTheme ? Colors.black : Colors.white, - borderRadius: BorderRadius.circular(widgetSize / 2), - ), - child: Icon( + icon: Badge( + label: _BadgeLabel( + Icon( Icons.info, color: serverInfoState.versionStatus == VersionStatus.error ? context.colorScheme.error : context.primaryColor, size: widgetSize / 2, + semanticLabel: 'new_version_available'.tr(), ), ), backgroundColor: Colors.transparent, @@ -177,7 +159,16 @@ class _ProfileIndicator extends ConsumerWidget { ? const Icon(Icons.face_outlined, size: widgetSize) : Semantics( label: "logged_in_as".tr(namedArgs: {"user": user.name}), - child: AbsorbPointer(child: UserCircleAvatar(radius: 17, size: 31, user: user)), + child: AbsorbPointer( + child: Builder( + builder: (context) => UserCircleAvatar( + size: 32, + user: user, + opacity: IconTheme.of(context).opacity ?? 1, + hasBorder: true, + ), + ), + ), ), ), ); @@ -193,10 +184,9 @@ class _BackupIndicator extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final indicatorIcon = _getBackupBadgeIcon(context, ref); - return InkWell( - onTap: () => context.pushRoute(const DriftBackupRoute()), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Badge( + return IconButton( + onPressed: () => context.pushRoute(const DriftBackupRoute()), + icon: Badge( label: indicatorIcon, backgroundColor: Colors.transparent, alignment: Alignment.bottomRight, @@ -278,12 +268,14 @@ class _BadgeLabel extends StatelessWidget { @override Widget build(BuildContext context) { + final opacity = IconTheme.of(context).opacity ?? 1; + return Container( width: _kBadgeWidgetSize / 2, height: _kBadgeWidgetSize / 2, decoration: BoxDecoration( - color: backgroundColor ?? context.colorScheme.surfaceContainer, - border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)), + color: (backgroundColor ?? context.colorScheme.surfaceContainer).withValues(alpha: opacity), + border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3 * opacity)), borderRadius: BorderRadius.circular(_kBadgeWidgetSize / 2), ), child: indicator, @@ -346,23 +338,30 @@ class _SyncStatusIndicatorState extends ConsumerState<_SyncStatusIndicator> with return const SizedBox.shrink(); } - return AnimatedBuilder( - animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]), - builder: (context, child) { - return Padding( - padding: EdgeInsets.only(right: isSyncing ? 16 : 0), - child: Transform.scale( - scale: isSyncing ? 1.0 : _dismissalAnimation.value, - child: Opacity( - opacity: isSyncing ? 1.0 : _dismissalAnimation.value, - child: Transform.rotate( - angle: _rotationAnimation.value * 2 * 3.14159 * -1, // Rotate counter-clockwise - child: Icon(Icons.sync, size: 24, color: context.primaryColor), - ), - ), - ), - ); - }, + return Padding( + padding: const EdgeInsets.all(8), + child: TweenAnimationBuilder( + tween: Tween(end: IconTheme.of(context).opacity ?? 1), + duration: kThemeChangeDuration, + builder: (context, opacity, child) { + return AnimatedBuilder( + animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]), + builder: (context, child) { + final dismissalValue = isSyncing ? 1.0 : _dismissalAnimation.value; + return IconTheme( + data: IconTheme.of(context).copyWith(opacity: opacity * dismissalValue), + child: Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..scaleByDouble(dismissalValue, dismissalValue, dismissalValue, 1.0) + ..rotateZ(-_rotationAnimation.value * 2 * math.pi), + child: const Icon(Icons.sync), + ), + ); + }, + ); + }, + ), ); } } diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index 352d686e7c..fe39c5da3f 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -8,49 +8,52 @@ import 'package:immich_mobile/presentation/widgets/images/remote_image_provider. // ignore: must_be_immutable class UserCircleAvatar extends ConsumerWidget { final UserDto user; - double radius; double size; bool hasBorder; + double opacity; - UserCircleAvatar({super.key, this.radius = 22, this.size = 44, this.hasBorder = false, required this.user}); + UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user}); @override Widget build(BuildContext context, WidgetRef ref) { - final userAvatarColor = user.avatarColor.toColor(); + final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity); final profileImageUrl = '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}'; + final textColor = (user.avatarColor.toColor().computeLuminance() > 0.5 ? Colors.black : Colors.white).withValues( + alpha: opacity, + ); + final textIcon = DefaultTextStyle( - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12, - color: userAvatarColor.computeLuminance() > 0.5 ? Colors.black : Colors.white, - ), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: textColor), child: Text(user.name[0].toUpperCase()), ); return Tooltip( message: user.name, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: hasBorder ? Border.all(color: Colors.grey[500]!, width: 1) : null, - ), - child: CircleAvatar( - backgroundColor: userAvatarColor, - radius: radius, + child: UnconstrainedBox( + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: userAvatarColor, + shape: BoxShape.circle, + border: hasBorder ? Border.all(color: Colors.grey[500]!.withValues(alpha: opacity), width: 1) : null, + ), child: user.hasProfileImage ? ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(50)), + borderRadius: BorderRadius.all(Radius.circular(size / 2)), child: Image( fit: BoxFit.cover, width: size, height: size, image: RemoteImageProvider(url: profileImageUrl), errorBuilder: (context, error, stackTrace) => textIcon, + color: Colors.white.withValues(alpha: opacity), + colorBlendMode: BlendMode.modulate, ), ) - : textIcon, + : Center(child: textIcon), ), ), ); From 19ef1961507f0cfb40336be1b9a47b030dd10856 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Sun, 15 Feb 2026 23:25:18 -0500 Subject: [PATCH 013/143] chore: quiet down dotenv (#26245) --- e2e/playwright.config.ts | 2 +- web/svelte.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 032e6affbf..6dd8c10d25 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -3,7 +3,7 @@ import dotenv from 'dotenv'; import { cpus } from 'node:os'; import { resolve } from 'node:path'; -dotenv.config({ path: resolve(import.meta.dirname, '.env') }); +dotenv.config({ quiet: true, path: resolve(import.meta.dirname, '.env') }); export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1'; export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1'; diff --git a/web/svelte.config.js b/web/svelte.config.js index 5daf958986..e2a5fb5c46 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -2,7 +2,7 @@ import adapter from '@sveltejs/adapter-static'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import dotenv from 'dotenv'; -dotenv.config(); +dotenv.config({ quiet: true }); process.env.PUBLIC_IMMICH_BUY_HOST = process.env.PUBLIC_IMMICH_BUY_HOST || 'https://buy.immich.app'; process.env.PUBLIC_IMMICH_PAY_HOST = process.env.PUBLIC_IMMICH_PAY_HOST || 'https://pay.futo.org'; From 156e3479fa2694317c60101606c0fbeaf215f3f3 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 16 Feb 2026 08:20:01 -0600 Subject: [PATCH 014/143] chore: styling tweak profile panel (#26248) --- .../common/app_bar_dialog/app_bar_dialog.dart | 18 +++++++++--------- .../app_bar_dialog/app_bar_profile_info.dart | 2 +- .../app_bar_dialog/app_bar_server_info.dart | 4 ++-- .../widgets/common/immich_sliver_app_bar.dart | 2 +- .../lib/widgets/common/user_circle_avatar.dart | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 527aae0e6e..c330fb4649 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -52,7 +52,10 @@ class ImmichAppBarDialog extends HookConsumerWidget { child: Stack( alignment: Alignment.centerLeft, children: [ - IconButton(onPressed: () => context.pop(), icon: const Icon(Icons.close, size: 20)), + IconButton( + onPressed: () => context.pop(), + icon: Icon(Icons.close, size: 20, color: context.colorScheme.onSurfaceVariant), + ), Align( alignment: Alignment.center, child: Padding( @@ -154,15 +157,12 @@ class ImmichAppBarDialog extends HookConsumerWidget { } return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 12, children: [ - Text( - "backup_controller_page_server_storage", - style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500), - ).tr(), + Text("backup_controller_page_server_storage".tr(), style: context.textTheme.labelLarge), LinearProgressIndicator( minHeight: 10.0, value: percentage, @@ -264,13 +264,13 @@ class ImmichAppBarDialog extends HookConsumerWidget { color: context.colorScheme.surface, borderRadius: const BorderRadius.all(Radius.circular(10)), ), - margin: const EdgeInsets.only(left: 8, right: 8, bottom: 8), + margin: const EdgeInsets.only(left: 12, right: 12, bottom: 8), child: Column( children: [ const AppBarProfileInfoBox(), - const Divider(height: 3), + Divider(thickness: 4, color: context.colorScheme.surfaceContainer), buildStorageInformation(), - const Divider(height: 3), + Divider(thickness: 4, color: context.colorScheme.surfaceContainer), const AppBarServerInfo(), ], ), diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index 12273849f2..a9fdb9a43f 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -34,7 +34,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { ); } - final userImage = UserCircleAvatar(size: 44, user: user); + final userImage = UserCircleAvatar(size: 44, user: user, hasBorder: true); if (uploadProfileImageStatus == UploadProfileStatus.loading) { return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20)); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart index 3203b18df7..2809505c58 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart @@ -38,7 +38,7 @@ class AppBarServerInfo extends HookConsumerWidget { const divider = Divider(thickness: 1); return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -109,7 +109,7 @@ class _ServerInfoItem extends StatelessWidget { style: TextStyle( fontSize: contentFontSize, color: context.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis, ), textAlign: TextAlign.end, diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 939e9e27aa..141f7e5e8b 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -162,7 +162,7 @@ class _ProfileIndicator extends ConsumerWidget { child: AbsorbPointer( child: Builder( builder: (context) => UserCircleAvatar( - size: 32, + size: 34, user: user, opacity: IconTheme.of(context).opacity ?? 1, hasBorder: true, diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index fe39c5da3f..c6e4f4719e 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -38,7 +38,7 @@ class UserCircleAvatar extends ConsumerWidget { decoration: BoxDecoration( color: userAvatarColor, shape: BoxShape.circle, - border: hasBorder ? Border.all(color: Colors.grey[500]!.withValues(alpha: opacity), width: 1) : null, + border: hasBorder ? Border.all(color: userAvatarColor.withValues(alpha: opacity), width: 1.5) : null, ), child: user.hasProfileImage ? ClipRRect( From 921101399634bcf35f61865089473bd98e241503 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:50:28 +0530 Subject: [PATCH 015/143] fix: bring back timeline args auto-scoping (#26219) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../widgets/timeline/timeline.widget.dart | 129 +++++++++++++----- 1 file changed, 97 insertions(+), 32 deletions(-) diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index da0497539b..9f7c695c8b 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -29,7 +29,38 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; -class Timeline extends ConsumerWidget { +class _TimelineRestorationState extends ChangeNotifier { + int? _restoreAssetIndex; + bool _shouldRestoreAssetPosition = false; + + int? get restoreAssetIndex => _restoreAssetIndex; + bool get shouldRestoreAssetPosition => _shouldRestoreAssetPosition; + + void setRestoreAssetIndex(int? index) { + _restoreAssetIndex = index; + notifyListeners(); + } + + void setShouldRestoreAssetPosition(bool should) { + _shouldRestoreAssetPosition = should; + notifyListeners(); + } + + void clearRestoreAssetIndex() { + _restoreAssetIndex = null; + notifyListeners(); + } +} + +class _TimelineRestorationProvider extends InheritedNotifier<_TimelineRestorationState> { + const _TimelineRestorationProvider({required super.notifier, required super.child}); + + static _TimelineRestorationState of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_TimelineRestorationProvider>()!.notifier!; + } +} + +class Timeline extends StatefulWidget { const Timeline({ super.key, this.topSliverWidget, @@ -58,36 +89,66 @@ class Timeline extends ConsumerWidget { final bool readOnly; @override - Widget build(BuildContext context, WidgetRef ref) { + State createState() => _TimelineState(); +} + +class _TimelineState extends State { + double? _lastWidth; + late final _TimelineRestorationState _restorationState; + + @override + void initState() { + super.initState(); + _restorationState = _TimelineRestorationState(); + } + + @override + void dispose() { + _restorationState.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { return Scaffold( resizeToAvoidBottomInset: false, floatingActionButton: const DownloadStatusFloatingButton(), body: LayoutBuilder( - builder: (_, constraints) => ProviderScope( - overrides: [ - timelineArgsProvider.overrideWithValue( - TimelineArgs( - maxWidth: constraints.maxWidth, - maxHeight: constraints.maxHeight, - columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), - showStorageIndicator: showStorageIndicator, - withStack: withStack, - groupBy: groupBy, + builder: (_, constraints) { + if (_lastWidth != null && _lastWidth != constraints.maxWidth) { + _restorationState.setShouldRestoreAssetPosition(true); + } + _lastWidth = constraints.maxWidth; + return _TimelineRestorationProvider( + notifier: _restorationState, + child: ProviderScope( + key: ValueKey(_lastWidth), + overrides: [ + timelineArgsProvider.overrideWith( + (ref) => TimelineArgs( + maxWidth: constraints.maxWidth, + maxHeight: constraints.maxHeight, + columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), + showStorageIndicator: widget.showStorageIndicator, + withStack: widget.withStack, + groupBy: widget.groupBy, + ), + ), + if (widget.readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), + ], + child: _SliverTimeline( + key: const ValueKey('_sliver_timeline'), + topSliverWidget: widget.topSliverWidget, + topSliverWidgetHeight: widget.topSliverWidgetHeight, + appBar: widget.appBar, + bottomSheet: widget.bottomSheet, + withScrubber: widget.withScrubber, + snapToMonth: widget.snapToMonth, + initialScrollOffset: widget.initialScrollOffset, ), ), - if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), - ], - child: _SliverTimeline( - key: const ValueKey('_sliver_timeline'), - topSliverWidget: topSliverWidget, - topSliverWidgetHeight: topSliverWidgetHeight, - appBar: appBar, - bottomSheet: bottomSheet, - withScrubber: withScrubber, - snapToMonth: snapToMonth, - initialScrollOffset: initialScrollOffset, - ), - ), + ); + }, ), ); } @@ -141,7 +202,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { int _perRow = 4; double _scaleFactor = 3.0; double _baseScaleFactor = 3.0; - int? _restoreAssetIndex; @override void initState() { @@ -182,13 +242,16 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { } void _restoreAssetPosition(_) { - if (_restoreAssetIndex == null) return; + final restorationState = _TimelineRestorationProvider.of(context); + if (!restorationState.shouldRestoreAssetPosition || restorationState.restoreAssetIndex == null) return; final asyncSegments = ref.read(timelineSegmentProvider); asyncSegments.whenData((segments) { - final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!); + final targetSegment = segments.lastWhereOrNull( + (segment) => segment.firstAssetIndex <= restorationState.restoreAssetIndex!, + ); if (targetSegment != null) { - final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex; + final assetIndexInSegment = restorationState.restoreAssetIndex! - targetSegment.firstAssetIndex; final newColumnCount = ref.read(timelineArgsProvider).columnCount; final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor(); final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment; @@ -200,7 +263,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { }); } }); - _restoreAssetIndex = null; + restorationState.clearRestoreAssetIndex(); } int? _getCurrentAssetIndex(List segments) { @@ -411,7 +474,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { onNotification: (notification) { final currentIndex = _getCurrentAssetIndex(segments); if (currentIndex != null && mounted) { - _restoreAssetIndex = currentIndex; + _TimelineRestorationProvider.of(context).setRestoreAssetIndex(currentIndex); } return false; }, @@ -430,12 +493,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final targetAssetIndex = _getCurrentAssetIndex(segments); if (newPerRow != _perRow) { + final restorationState = _TimelineRestorationProvider.of(context); setState(() { _scaleFactor = newScaleFactor; _perRow = newPerRow; - _restoreAssetIndex = targetAssetIndex; }); + restorationState.setRestoreAssetIndex(targetAssetIndex); + restorationState.setShouldRestoreAssetPosition(true); ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); } }; From 4dccc2082bb8b933249afee68eb2978472df50b7 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:30:41 +0100 Subject: [PATCH 016/143] fix(web): focus tag input when modal opens (#26256) --- pnpm-lock.yaml | 10 +++++----- web/package.json | 2 +- web/src/lib/modals/AssetTagModal.svelte | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9af490b82a..c139181d8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,8 +741,8 @@ importers: specifier: workspace:* version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.62.1 - version: 0.62.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) + specifier: ^0.63.0 + version: 0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -3018,8 +3018,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.62.1': - resolution: {integrity: sha512-+rZAjw24pAIJ1hmCtYF16BECh+7M09UudTPc28z6U2J3CZzSOs0+Nsz5fTs8SE5wyC45QKdPWJCS//xFMrrRUg==} + '@immich/ui@0.63.0': + resolution: {integrity: sha512-WTdEZi1XEvhcdQymFCIb8Us2DJv+Vp4wTytYwIgQUeXMFSQ8aUT7m76Wsa6uphmuFqyyJioFU+g4rIfJ+w2R5w==} peerDependencies: svelte: ^5.0.0 @@ -14961,7 +14961,7 @@ snapshots: node-emoji: 2.2.0 svelte: 5.50.0 - '@immich/ui@0.62.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)': + '@immich/ui@0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)': dependencies: '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.0) '@internationalized/date': 3.10.0 diff --git a/web/package.json b/web/package.json index 5b66c75029..bfe9eb112f 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", - "@immich/ui": "^0.62.1", + "@immich/ui": "^0.63.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", diff --git a/web/src/lib/modals/AssetTagModal.svelte b/web/src/lib/modals/AssetTagModal.svelte index 74daf75659..dbd5bdb118 100644 --- a/web/src/lib/modals/AssetTagModal.svelte +++ b/web/src/lib/modals/AssetTagModal.svelte @@ -62,6 +62,7 @@ {onClose} {onSubmit} submitText={$t('tag_assets')} + onOpenAutoFocus={(event) => event.preventDefault()} {disabled} >
From cc9c261fd06afc66a4e56d8838cdebd6bac44232 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:52:34 +0100 Subject: [PATCH 017/143] fix(web): clear face boxes when switching assets (#26249) --- web/src/lib/components/asset-viewer/photo-viewer.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 2101107f6e..61181acbc8 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -57,7 +57,10 @@ $effect.pre(() => { void asset.id; - untrack(() => assetViewerManager.resetZoomState()); + untrack(() => { + assetViewerManager.resetZoomState(); + $boundingBoxesArray = []; + }); }); onDestroy(() => { From 0da74569f2d6ca0c3d9554fe7f6514ee5501a05f Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:25:13 +0100 Subject: [PATCH 018/143] fix(web): clear unsaved asset description when changing asset (#26255) * fix(web): clear unsaved asset description when changing asset * remove unneeded $derived --- .../detail-panel-description.spec.ts | 65 +++++++++++++++++++ .../detail-panel-description.svelte | 4 +- 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/detail-panel-description.spec.ts diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.spec.ts b/web/src/lib/components/asset-viewer/detail-panel-description.spec.ts new file mode 100644 index 0000000000..3175bd8194 --- /dev/null +++ b/web/src/lib/components/asset-viewer/detail-panel-description.spec.ts @@ -0,0 +1,65 @@ +import { assetFactory } from '@test-data/factories/asset-factory'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import DetailPanelDescription from './detail-panel-description.svelte'; + +describe('DetailPanelDescription', () => { + it('clears unsaved draft on asset change', async () => { + const user = userEvent.setup(); + + const assetA = assetFactory.build({ + id: 'asset-a', + exifInfo: { description: '' }, + }); + const assetB = assetFactory.build({ + id: 'asset-b', + exifInfo: { description: '' }, + }); + + const { rerender } = render(DetailPanelDescription, { + props: { + asset: assetA, + isOwner: true, + }, + }); + + const textarea = screen.getByTestId('autogrow-textarea') as HTMLTextAreaElement; + await user.type(textarea, 'unsaved draft'); + expect(textarea).toHaveValue('unsaved draft'); + + await rerender({ + asset: assetB, + isOwner: true, + }); + + expect(screen.getByTestId('autogrow-textarea')).toHaveValue(''); + }); + + it('updates description on asset switch', async () => { + const assetA = assetFactory.build({ + id: 'asset-a', + exifInfo: { description: 'first description' }, + }); + const assetB = assetFactory.build({ + id: 'asset-b', + exifInfo: { description: 'second description' }, + }); + + const { rerender } = render(DetailPanelDescription, { + props: { + asset: assetA, + isOwner: true, + }, + }); + + expect(screen.getByTestId('autogrow-textarea')).toHaveValue('first description'); + + await rerender({ + asset: assetB, + isOwner: true, + }); + + expect(screen.getByTestId('autogrow-textarea')).toHaveValue('second description'); + }); +}); diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte index bc3929f3dd..9aeb7855b6 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte @@ -13,10 +13,10 @@ let { asset, isOwner }: Props = $props(); - let currentDescription = $derived(asset.exifInfo?.description ?? ''); - let description = $derived(currentDescription); + let description = $derived(asset.exifInfo?.description ?? ''); const handleFocusOut = async () => { + const currentDescription = asset.exifInfo?.description ?? ''; if (description === currentDescription) { return; } From 75bdd6a6446a50ab76154209f79792856e4c5a08 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 16 Feb 2026 18:34:42 -0500 Subject: [PATCH 019/143] fix: development containers init race conditions (#25876) --- docker/docker-compose.dev.yml | 114 +++++++++++++++++++----------- e2e/docker-compose.dev.yml | 127 ++++++++++++++++------------------ e2e/docker-compose.yml | 27 +++++--- server/Dockerfile.dev | 16 ++--- 4 files changed, 156 insertions(+), 128 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 81fc492001..8c46d3c51f 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -14,33 +14,65 @@ name: immich-dev services: + immich-app-base: + profiles: ['_base'] + tmpfs: + - /tmp + volumes: + - ..:/usr/src/app + - pnpm_cache:/buildcache/pnpm_cache + - server_node_modules:/usr/src/app/server/node_modules + - web_node_modules:/usr/src/app/web/node_modules + - github_node_modules:/usr/src/app/.github/node_modules + - cli_node_modules:/usr/src/app/cli/node_modules + - docs_node_modules:/usr/src/app/docs/node_modules + - e2e_node_modules:/usr/src/app/e2e/node_modules + - sdk_node_modules:/usr/src/app/open-api/typescript-sdk/node_modules + - app_node_modules:/usr/src/app/node_modules + - sveltekit:/usr/src/app/web/.svelte-kit + - coverage:/usr/src/app/web/coverage + + immich-init: + extends: + service: immich-app-base + profiles: !reset [] + container_name: immich_init + image: immich-server-dev:latest + build: + context: ../ + dockerfile: server/Dockerfile.dev + target: dev + command: + - | + pnpm install + touch /tmp/init-complete + exec tail -f /dev/null + volumes: + - pnpm_store_server:/buildcache/pnpm-store + restart: 'no' + healthcheck: + test: ['CMD', 'test', '-f', '/tmp/init-complete'] + interval: 2s + timeout: 3s + retries: 300 + start_period: 300s + immich-server: + extends: + service: immich-app-base + profiles: !reset [] container_name: immich_server command: ['immich-dev'] image: immich-server-dev:latest - # extends: - # file: hwaccel.transcoding.yml - # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding build: context: ../ dockerfile: server/Dockerfile.dev target: dev restart: unless-stopped volumes: - - ..:/usr/src/app - ${UPLOAD_LOCATION}/photos:/data - /etc/localtime:/etc/localtime:ro - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + - pnpm_store_server:/buildcache/pnpm-store - ../plugins:/build/corePlugin env_file: - .env @@ -63,6 +95,8 @@ services: - 9231:9231 - 2283:2283 depends_on: + immich-init: + condition: service_healthy redis: condition: service_started database: @@ -71,6 +105,9 @@ services: disable: false immich-web: + extends: + service: immich-app-base + profiles: !reset [] container_name: immich_web image: immich-web-dev:latest build: @@ -84,20 +121,11 @@ services: - 3000:3000 - 24678:24678 volumes: - - ..:/usr/src/app - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + - pnpm_store_web:/buildcache/pnpm-store restart: unless-stopped depends_on: + immich-init: + condition: service_healthy immich-server: condition: service_started @@ -116,7 +144,7 @@ services: - 3003:3003 volumes: - ../machine-learning/immich_ml:/usr/src/immich_ml - - model-cache:/cache + - model_cache:/cache env_file: - .env depends_on: @@ -156,7 +184,7 @@ services: # image: prom/prometheus # volumes: # - ./prometheus.yml:/etc/prometheus/prometheus.yml - # - prometheus-data:/prometheus + # - prometheus_data:/prometheus # first login uses admin/admin # add data source for http://immich-prometheus:9090 to get started @@ -167,20 +195,22 @@ services: # - 3000:3000 # image: grafana/grafana:10.3.3-ubuntu # volumes: - # - grafana-data:/var/lib/grafana + # - grafana_data:/var/lib/grafana volumes: - model-cache: - prometheus-data: - grafana-data: - pnpm-store: - server-node_modules: - web-node_modules: - github-node_modules: - cli-node_modules: - docs-node_modules: - e2e-node_modules: - sdk-node_modules: - app-node_modules: + model_cache: + prometheus_data: + grafana_data: + pnpm_cache: + pnpm_store_server: + pnpm_store_web: + server_node_modules: + web_node_modules: + github_node_modules: + cli_node_modules: + docs_node_modules: + e2e_node_modules: + sdk_node_modules: + app_node_modules: sveltekit: coverage: diff --git a/e2e/docker-compose.dev.yml b/e2e/docker-compose.dev.yml index 14e159ed50..b301ef8441 100644 --- a/e2e/docker-compose.dev.yml +++ b/e2e/docker-compose.dev.yml @@ -1,86 +1,77 @@ name: immich-e2e services: + immich-app-base: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-app-base + + immich-init: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-init + container_name: immich-e2e-init + immich-server: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-server container_name: immich-e2e-server - command: ['immich-dev'] - image: immich-server-dev:latest - build: - context: ../ - dockerfile: server/Dockerfile.dev - target: dev + ports: !reset [] + env_file: !reset [] environment: - - DB_HOSTNAME=database - - DB_USERNAME=postgres - - DB_PASSWORD=postgres - - DB_DATABASE_NAME=immich - - IMMICH_MACHINE_LEARNING_ENABLED=false - - IMMICH_TELEMETRY_INCLUDE=all - - IMMICH_ENV=testing - - IMMICH_PORT=2285 - - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true + DB_HOSTNAME: database + DB_USERNAME: postgres + DB_PASSWORD: postgres + DB_DATABASE_NAME: immich + IMMICH_MACHINE_LEARNING_ENABLED: 'false' + IMMICH_TELEMETRY_INCLUDE: all + IMMICH_ENV: testing + IMMICH_PORT: '2285' + IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true' volumes: - ./test-assets:/test-assets - - ..:/usr/src/app - - ${UPLOAD_LOCATION}/photos:/data - - /etc/localtime:/etc/localtime:ro - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage - - ../plugins:/build/corePlugin depends_on: + immich-init: + condition: service_healthy redis: condition: service_started database: condition: service_healthy immich-web: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-web container_name: immich-e2e-web - image: immich-web-dev:latest - build: - context: ../ - dockerfile: server/Dockerfile.dev - target: dev - command: ['immich-web'] - ports: + ports: !override - 2285:3000 environment: - - IMMICH_SERVER_URL=http://immich-server:2285/ - volumes: - - ..:/usr/src/app - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + IMMICH_SERVER_URL: http://immich-server:2285/ + depends_on: + immich-init: + condition: service_healthy restart: unless-stopped redis: - image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef + extends: + file: ../docker/docker-compose.dev.yml + service: redis + container_name: immich-e2e-redis database: - image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 + extends: + file: ../docker/docker-compose.dev.yml + service: database + container_name: immich-e2e-postgres command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf + env_file: !reset [] + ports: !override + - 5435:5432 environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: immich - ports: - - 5435:5432 healthcheck: test: ['CMD-SHELL', 'pg_isready -U postgres -d immich'] interval: 1s @@ -89,17 +80,19 @@ services: start_period: 10s volumes: - model-cache: - prometheus-data: - grafana-data: - pnpm-store: - server-node_modules: - web-node_modules: - github-node_modules: - cli-node_modules: - docs-node_modules: - e2e-node_modules: - sdk-node_modules: - app-node_modules: + model_cache: + prometheus_data: + grafana_data: + pnpm_cache: + pnpm_store_server: + pnpm_store_web: + server_node_modules: + web_node_modules: + github_node_modules: + cli_node_modules: + docs_node_modules: + e2e_node_modules: + sdk_node_modules: + app_node_modules: sveltekit: coverage: diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 5a79396aa5..2ef57475b7 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -2,6 +2,7 @@ name: immich-e2e services: e2e-auth-server: + container_name: immich-e2e-auth-server build: context: ../e2e-auth-server ports: @@ -22,15 +23,15 @@ services: - BUILD_SOURCE_REF=e2e - BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee environment: - - DB_HOSTNAME=database - - DB_USERNAME=postgres - - DB_PASSWORD=postgres - - DB_DATABASE_NAME=immich - - IMMICH_MACHINE_LEARNING_ENABLED=false - - IMMICH_TELEMETRY_INCLUDE=all - - IMMICH_ENV=testing - - IMMICH_PORT=2285 - - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true + DB_HOSTNAME: database + DB_USERNAME: postgres + DB_PASSWORD: postgres + DB_DATABASE_NAME: immich + IMMICH_MACHINE_LEARNING_ENABLED: 'false' + IMMICH_TELEMETRY_INCLUDE: all + IMMICH_ENV: testing + IMMICH_PORT: '2285' + IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true' volumes: - ./test-assets:/test-assets depends_on: @@ -42,10 +43,14 @@ services: - 2285:2285 redis: - image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef + container_name: immich-e2e-redis + image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63 + healthcheck: + test: redis-cli ping || exit 1 database: - image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 + container_name: immich-e2e-postgres + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23 command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf environment: POSTGRES_PASSWORD: postgres diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index be752dd862..74757956fc 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -3,19 +3,19 @@ FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e0 ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ - COREPACK_HOME=/tmp + COREPACK_HOME=/tmp \ + PNPM_HOME=/buildcache/pnpm-store RUN npm install --global corepack@latest && \ corepack enable pnpm && \ + echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ - echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc + echo "cache-dir=/buildcache/pnpm-cache" >> /usr/local/etc/npmrc && \ + echo "# Retry configuration - default is 2" >> /usr/local/etc/npmrc && \ + echo "fetch-retries=5" >> /usr/local/etc/npmrc && \ + mkdir -p /buildcache/pnpm-store /buildcache/pnpm-cache /buildcache/node-gyp && \ + chmod -R o+rw /buildcache -COPY ./package* ./pnpm* .pnpmfile.cjs /tmp/create-dep-cache/ -COPY ./web/package* ./web/pnpm* /tmp/create-dep-cache/web/ -COPY ./server/package* ./server/pnpm* /tmp/create-dep-cache/server/ -COPY ./open-api/typescript-sdk/package* ./open-api/typescript-sdk/pnpm* /tmp/create-dep-cache/open-api/typescript-sdk/ -WORKDIR /tmp/create-dep-cache -RUN pnpm fetch && rm -rf /tmp/create-dep-cache && chmod -R o+rw /buildcache WORKDIR /usr/src/app ENV PATH="${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin" \ From de7b42eb2300c90b0f1ba6b3279f0bb00c6e71e9 Mon Sep 17 00:00:00 2001 From: Joren Guillaume Date: Tue, 17 Feb 2026 11:39:43 +0100 Subject: [PATCH 020/143] chore(docs): Update help channel for developers (#26284) Update help channel for developers From ceef65154d3218238fd47dc81314d17226e5c65e Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:43:08 +0100 Subject: [PATCH 021/143] fix(web): clear cache when asset changes (#26257) * fix(web): clear cache when asset changes * formatting --- .../lib/managers/AssetCacheManager.svelte.ts | 47 ++++++++++++------- web/src/lib/stores/ocr.svelte.spec.ts | 1 + web/src/lib/stores/websocket.ts | 1 + 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/web/src/lib/managers/AssetCacheManager.svelte.ts b/web/src/lib/managers/AssetCacheManager.svelte.ts index f3c85acfa5..b90cf565c5 100644 --- a/web/src/lib/managers/AssetCacheManager.svelte.ts +++ b/web/src/lib/managers/AssetCacheManager.svelte.ts @@ -1,25 +1,23 @@ +import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; -import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk'; +import { getAssetInfo, getAssetOcr } from '@immich/sdk'; const defaultSerializer = (params: K) => JSON.stringify(params); -class AsyncCache { +class AsyncCache { #cache = new Map(); - async getOrFetch( - params: K, - fetcher: (params: K) => Promise, - keySerializer: (params: K) => string = defaultSerializer, - updateCache: boolean, - ): Promise { - const cacheKey = keySerializer(params); + constructor(private fetcher: (params: K) => Promise) {} + + async getOrFetch(params: K, updateCache: boolean): Promise { + const cacheKey = defaultSerializer(params); const cached = this.#cache.get(cacheKey); if (cached) { return cached; } - const value = await fetcher(params); + const value = await this.fetcher(params); if (value && updateCache) { this.#cache.set(cacheKey, value); } @@ -27,30 +25,43 @@ class AsyncCache { return value; } + clearKey(params: K) { + const cacheKey = defaultSerializer(params); + this.#cache.delete(cacheKey); + } + clear() { this.#cache.clear(); } } class AssetCacheManager { - #assetCache = new AsyncCache(); - #ocrCache = new AsyncCache(); + #assetCache = new AsyncCache(getAssetInfo); + #ocrCache = new AsyncCache(getAssetOcr); constructor() { eventManager.on({ - AssetEditsApplied: () => { - this.#assetCache.clear(); - this.#ocrCache.clear(); + AssetEditsApplied: (assetId) => { + this.invalidateAsset(assetId); + }, + AssetUpdate: (asset) => { + this.invalidateAsset(asset.id); }, }); } - async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) { - return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache); + async getAsset({ id, key, slug }: { id: string; key?: string; slug?: string }, updateCache = true) { + return this.#assetCache.getOrFetch({ id, key, slug }, updateCache); } async getAssetOcr(id: string) { - return this.#ocrCache.getOrFetch({ id }, getAssetOcr, (params) => params.id, true); + return this.#ocrCache.getOrFetch({ id }, true); + } + + invalidateAsset(id: string) { + const { key, slug } = authManager.params; + this.#assetCache.clearKey({ id, key, slug }); + this.#ocrCache.clearKey({ id }); } clearAssetCache() { diff --git a/web/src/lib/stores/ocr.svelte.spec.ts b/web/src/lib/stores/ocr.svelte.spec.ts index 5220cbb77d..1e2aeecb73 100644 --- a/web/src/lib/stores/ocr.svelte.spec.ts +++ b/web/src/lib/stores/ocr.svelte.spec.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock the SDK vi.mock('@immich/sdk', () => ({ + getAssetInfo: vi.fn(), getAssetOcr: vi.fn(), })); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 335ec188ea..32aa52fccb 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -77,6 +77,7 @@ websocket .on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event)) .on('on_session_delete', () => authManager.logout()) .on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id })) + .on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset)) .on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id })) .on('on_notification', () => notificationManager.refresh()) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); From 90ef6c4e282cd8e065e174a5f4f4fc7ff257a01c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:44:21 +0100 Subject: [PATCH 022/143] chore(deps): update docker.io/valkey/valkey:9 docker digest to 930b414 (#26272) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- e2e/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 2ef57475b7..8ae5762a1b 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -44,7 +44,7 @@ services: redis: container_name: immich-e2e-redis - image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63 + image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e healthcheck: test: redis-cli ping || exit 1 From b3c37905f7913d5fe01bacaf8ceb963a9f9a5eb6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:44:38 +0100 Subject: [PATCH 023/143] chore(deps): update dependency @types/node to ^24.10.13 (#26273) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package.json | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/package.json | 2 +- pnpm-lock.yaml | 9 +++++---- server/package.json | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/cli/package.json b/cli/package.json index d80efdd74a..50c14949aa 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/e2e/package.json b/e2e/package.json index abe46a39ca..fc6d9e9c8e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -27,7 +27,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 6310316857..d3f64f6a2b 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "typescript": "^5.3.3" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c139181d8b..b4927578dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@vitest/coverage-v8': specifier: ^3.0.0 @@ -220,7 +220,7 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@types/pg': specifier: ^8.15.1 @@ -320,7 +320,7 @@ importers: version: 1.1.0 devDependencies: '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 typescript: specifier: ^5.3.3 @@ -639,7 +639,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@types/nodemailer': specifier: ^7.0.0 @@ -11330,6 +11330,7 @@ packages: tar@7.5.7: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me terser-webpack-plugin@5.3.16: resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} diff --git a/server/package.json b/server/package.json index 80427642e5..680d0be8ea 100644 --- a/server/package.json +++ b/server/package.json @@ -135,7 +135,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", From 18bbb5b4db9a0098e983d3849ea3c54045e2793e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:45:57 +0100 Subject: [PATCH 024/143] chore(deps): update node.js to v24.13.1 (#26275) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/.nvmrc | 2 +- cli/.nvmrc | 2 +- cli/package.json | 2 +- docs/.nvmrc | 2 +- docs/package.json | 2 +- e2e/.nvmrc | 2 +- e2e/package.json | 2 +- mise.toml | 2 +- open-api/typescript-sdk/.nvmrc | 2 +- open-api/typescript-sdk/package.json | 2 +- server/.nvmrc | 2 +- server/package.json | 2 +- web/.nvmrc | 2 +- web/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/.nvmrc b/.github/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/.github/.nvmrc +++ b/.github/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/cli/.nvmrc b/cli/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/cli/package.json b/cli/package.json index 50c14949aa..8e2aec0282 100644 --- a/cli/package.json +++ b/cli/package.json @@ -69,6 +69,6 @@ "micromatch": "^4.0.8" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/docs/.nvmrc b/docs/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/docs/package.json b/docs/package.json index 87b0b3fccd..c22826b3cb 100644 --- a/docs/package.json +++ b/docs/package.json @@ -58,6 +58,6 @@ "node": ">=20" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/e2e/package.json b/e2e/package.json index fc6d9e9c8e..df9687e0a2 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -52,6 +52,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/mise.toml b/mise.toml index 3ca0d353ea..14645eeea3 100644 --- a/mise.toml +++ b/mise.toml @@ -14,7 +14,7 @@ config_roots = [ ] [tools] -node = "24.13.0" +node = "24.13.1" flutter = "3.35.7" pnpm = "10.28.2" terragrunt = "0.98.0" diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index d3f64f6a2b..8f057df6cc 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/server/.nvmrc b/server/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/server/package.json b/server/package.json index 680d0be8ea..814934b1be 100644 --- a/server/package.json +++ b/server/package.json @@ -167,7 +167,7 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" }, "overrides": { "sharp": "^0.34.5" diff --git a/web/.nvmrc b/web/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/web/package.json b/web/package.json index bfe9eb112f..db5a05617e 100644 --- a/web/package.json +++ b/web/package.json @@ -108,6 +108,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } From 398b750ef76d077ccbac97f7eb2fed1e711ac5aa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:49:14 +0100 Subject: [PATCH 025/143] chore(deps): update dependency github:extism/js-pdk to v1.6.0 (#26279) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- plugins/mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/mise.toml b/plugins/mise.toml index c1001e574b..66a107674d 100644 --- a/plugins/mise.toml +++ b/plugins/mise.toml @@ -1,7 +1,7 @@ [tools] "github:extism/cli" = "1.6.3" "github:webassembly/binaryen" = "version_124" -"github:extism/js-pdk" = "1.5.1" +"github:extism/js-pdk" = "1.6.0" [tasks.install] run = "pnpm install --frozen-lockfile" From a16a00ebd4c9269ee3905019f9051cc0939f53f0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:50:02 +0000 Subject: [PATCH 026/143] fix(deps): update typescript-projects (#26276) * fix(deps): update typescript-projects * chore: downgrade kysely --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- mise.toml | 2 +- package.json | 2 +- pnpm-lock.yaml | 578 +++++++++++++++++++++++------------------------ web/package.json | 2 +- 4 files changed, 292 insertions(+), 292 deletions(-) diff --git a/mise.toml b/mise.toml index 14645eeea3..cf517598c6 100644 --- a/mise.toml +++ b/mise.toml @@ -16,7 +16,7 @@ config_roots = [ [tools] node = "24.13.1" flutter = "3.35.7" -pnpm = "10.28.2" +pnpm = "10.29.3" terragrunt = "0.98.0" opentofu = "1.11.4" java = "21.0.2" diff --git a/package.json b/package.json index 0e4017f928..c50c4e1eb8 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "2.5.6", "description": "Monorepo for Immich", "private": true, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc", "engines": { "pnpm": ">=10.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4927578dc..71dece4861 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 24.10.13 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -106,19 +106,19 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -127,16 +127,16 @@ importers: dependencies: '@docusaurus/core': specifier: ~3.9.0 - version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/preset-classic': specifier: ~3.9.0 - version: 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + version: 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) '@docusaurus/theme-common': specifier: ~3.9.0 - version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-mermaid': specifier: ~3.9.0 - version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@mdi/js': specifier: ^7.3.67 version: 7.4.47 @@ -145,13 +145,13 @@ importers: version: 1.6.1 '@mdx-js/react': specifier: ^3.0.0 - version: 3.1.1(@types/react@19.2.13)(react@18.3.1) + version: 3.1.1(@types/react@19.2.14)(react@18.3.1) autoprefixer: specifier: ^10.4.17 version: 10.4.24(postcss@8.5.6) docusaurus-lunr-search: specifier: ^3.3.2 - version: 3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lunr: specifier: ^2.3.9 version: 2.3.9 @@ -281,13 +281,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) utimes: specifier: ^5.2.1 version: 5.2.1(encoding@0.1.13) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) e2e-auth-server: devDependencies: @@ -317,7 +317,7 @@ importers: dependencies: '@oazapfts/runtime': specifier: ^1.0.2 - version: 1.1.0 + version: 1.2.0 devDependencies: '@types/node': specifier: ^24.10.13 @@ -345,7 +345,7 @@ importers: version: 2.0.0-rc13 '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.67.3) + version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0) '@nestjs/common': specifier: ^11.0.4 version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -426,7 +426,7 @@ importers: version: 2.2.2 bullmq: specifier: ^5.51.0 - version: 5.67.3 + version: 5.68.0 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -516,7 +516,7 @@ importers: version: 7.0.13 openid-client: specifier: ^6.3.3 - version: 6.8.1 + version: 6.8.2 pg: specifier: ^8.11.3 version: 8.18.0 @@ -652,7 +652,7 @@ importers: version: 6.0.5 '@types/react': specifier: ^19.0.0 - version: 19.2.13 + version: 19.2.14 '@types/sanitize-html': specifier: ^2.13.0 version: 2.16.0 @@ -670,7 +670,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^9.14.0 version: 9.39.2(jiti@2.6.1) @@ -718,16 +718,16 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) unplugin-swc: specifier: ^1.4.5 version: 1.5.9(@swc/core@1.15.11(@swc/helpers@0.5.17))(rollup@4.55.1) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: @@ -742,7 +742,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.63.0 - version: 0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) + version: 0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -775,7 +775,7 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.50.0) + version: 0.3.9(svelte@5.50.2) dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -793,7 +793,7 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.5.0 + version: 20.6.1 intl-messageformat: specifier: ^11.0.0 version: 11.1.2 @@ -808,7 +808,7 @@ importers: version: 3.7.2 maplibre-gl: specifier: ^5.6.2 - version: 5.17.0 + version: 5.18.0 pmtiles: specifier: ^4.3.0 version: 4.4.0 @@ -826,16 +826,16 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.50.0) + version: 4.0.1(svelte@5.50.2) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.50.0) + version: 3.11.0(svelte@5.50.2) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.6(svelte@5.50.0) + version: 1.2.6(svelte@5.50.2) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.50.0) + version: 0.12.0(svelte@5.50.2) tabbable: specifier: ^6.2.0 version: 6.4.0 @@ -863,16 +863,16 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.10.0 - version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.4 - version: 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.7 version: 4.1.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -881,7 +881,7 @@ importers: version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.3.1(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -905,7 +905,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@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv: specifier: ^17.0.0 version: 17.2.4 @@ -920,7 +920,7 @@ importers: version: 6.1.0(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.0) + version: 3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.2) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -941,19 +941,19 @@ importers: version: 4.2.0(prettier@3.8.1) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.4.1(prettier@3.8.1)(svelte@5.50.0) + version: 3.4.1(prettier@3.8.1)(svelte@5.50.2) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.55.1) svelte: - specifier: 5.50.0 - version: 5.50.0 + specifier: 5.50.2 + version: 5.50.2 svelte-check: specifier: ^4.1.5 - version: 4.3.6(picomatch@4.0.3)(svelte@5.50.0)(typescript@5.9.3) + version: 4.3.6(picomatch@4.0.3)(svelte@5.50.2)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.1(svelte@5.50.0) + version: 1.4.1(svelte@5.50.2) tailwindcss: specifier: ^4.1.7 version: 4.1.18 @@ -962,13 +962,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.45.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -3600,8 +3600,8 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true - '@oazapfts/runtime@1.1.0': - resolution: {integrity: sha512-PwCn69pexqg/uhc0bpEHSlRFdfTtSnq3icXHd0wf4BQwZSMKsCerTnydzegVScEegYkokzIxMcl9li7on86A2w==} + '@oazapfts/runtime@1.2.0': + resolution: {integrity: sha512-fi7dp7dNayyh/vzqhf0ZdoPfC7tJvYfjaE8MBL1yR+iIsH7cFoqHt+DV70VU49OMCqLc7wQa+yVJcSmIRnV4wA==} '@opentelemetry/api-logs@0.211.0': resolution: {integrity: sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==} @@ -5056,8 +5056,8 @@ packages: '@types/react-router@5.1.20': resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} - '@types/react@19.2.13': - resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} @@ -5140,63 +5140,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.54.0': - resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + '@typescript-eslint/eslint-plugin@8.55.0': + resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.54.0 + '@typescript-eslint/parser': ^8.55.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.54.0': - resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + '@typescript-eslint/parser@8.55.0': + resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.54.0': - resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + '@typescript-eslint/project-service@8.55.0': + resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.54.0': - resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + '@typescript-eslint/scope-manager@8.55.0': + resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.54.0': - resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + '@typescript-eslint/tsconfig-utils@8.55.0': + resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.54.0': - resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + '@typescript-eslint/type-utils@8.55.0': + resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.54.0': - resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} + '@typescript-eslint/types@8.55.0': + resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.54.0': - resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} + '@typescript-eslint/typescript-estree@8.55.0': + resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.54.0': - resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} + '@typescript-eslint/utils@8.55.0': + resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.54.0': - resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} + '@typescript-eslint/visitor-keys@8.55.0': + resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -5744,8 +5744,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.67.3: - resolution: {integrity: sha512-eeQobOJn8M0Rj8tcZCVFLrimZgJQallJH1JpclOoyut2nDNkDwTEPMVcZzLeSR2fGeIVbfJTjU96F563Qkge5A==} + bullmq@5.68.0: + resolution: {integrity: sha512-PywC7eTcPrKVQN5iEfhs5ats90nSLr8dzsyIhgviO8qQRTHnTq/SnETq2E8Do1RLg7Qw1Q0p5htBPI/cUGAlHg==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -7034,11 +7034,11 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-svelte@3.14.0: - resolution: {integrity: sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g==} + eslint-plugin-svelte@3.15.0: + resolution: {integrity: sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.1 || ^9.0.0 + eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: svelte: @@ -7619,8 +7619,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.5.0: - resolution: {integrity: sha512-VQe+Q5CYiGOgcCERXhcfNsbnrN92FDEKciMH/x6LppU9dd0j4aTjCTlqONFOIMcAm/5JxS3+utowbXV1OoFr+g==} + happy-dom@20.6.1: + resolution: {integrity: sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -8667,8 +8667,8 @@ packages: resolution: {integrity: sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==} engines: {node: '>=6.4.0'} - maplibre-gl@5.17.0: - resolution: {integrity: sha512-gwS6NpXBfWD406dtT5YfEpl2hmpMm+wcPqf04UAez/TxY1OBjiMdK2ZoMGcNIlGHelKc4+Uet6zhDdDEnlJVHA==} + maplibre-gl@5.18.0: + resolution: {integrity: sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} mark.js@8.11.1: @@ -9316,8 +9316,8 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true - oauth4webapi@3.8.3: - resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -9382,8 +9382,8 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true - openid-client@6.8.1: - resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} + openid-client@6.8.2: + resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} @@ -11227,8 +11227,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.50.0: - resolution: {integrity: sha512-FR9kTLmX5i0oyeQ5j/+w8DuagIkQ7MWMuPpPVioW2zx9Dw77q+1ufLzF1IqNtcTXPRnIIio4PlasliVn43OnbQ==} + svelte@5.50.2: + resolution: {integrity: sha512-WCxzm3BBf+Ase6RwiDPR4G36cM4Kb0NuhmLK6x44I+D6reaxizDDg8kBkk4jT/19+Rgmc44eZkOvMO6daoSFIw==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11593,8 +11593,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.54.0: - resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + typescript-eslint@8.55.0: + resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -11849,8 +11849,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite-tsconfig-paths@6.1.0: - resolution: {integrity: sha512-kpd3sY9glHIDaq4V/Tlc1Y8WaKtutoc3B525GHxEVKWX42FKfQsXvjFOemu1I8VIN8pNbrMLWVTbW79JaRUxKg==} + vite-tsconfig-paths@6.1.1: + resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: vite: '*' @@ -13637,26 +13637,26 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@docsearch/core@4.3.1(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docsearch/core@4.3.1(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': optionalDependencies: - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) '@docsearch/css@4.3.2': {} - '@docsearch/react@4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': + '@docsearch/react@4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': dependencies: '@ai-sdk/react': 2.0.115(react@18.3.1)(zod@4.2.1) '@algolia/autocomplete-core': 1.19.2(@algolia/client-search@5.46.0)(algoliasearch@5.46.0)(search-insights@2.17.3) - '@docsearch/core': 4.3.1(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docsearch/core': 4.3.1(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docsearch/css': 4.3.2 ai: 5.0.113(zod@4.2.1) algoliasearch: 5.46.0 marked: 16.4.2 zod: 4.2.1 optionalDependencies: - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) search-insights: 2.17.3 @@ -13730,7 +13730,7 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: '@docusaurus/babel': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/bundler': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) @@ -13739,7 +13739,7 @@ snapshots: '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@18.3.1) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@18.3.1) boxen: 6.2.1 chalk: 4.1.2 chokidar: 3.6.0 @@ -13845,7 +13845,7 @@ snapshots: dependencies: '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router-config': 5.0.11 '@types/react-router-dom': 5.3.3 react: 18.3.1 @@ -13859,13 +13859,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-content-blog@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-blog@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13900,13 +13900,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13940,9 +13940,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13970,9 +13970,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-css-cascade-layers@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-css-cascade-layers@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13997,9 +13997,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fs-extra: 11.3.2 @@ -14025,9 +14025,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -14051,9 +14051,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/gtag.js': 0.0.12 @@ -14078,9 +14078,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-tag-manager@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -14104,9 +14104,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14135,9 +14135,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14165,22 +14165,22 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/preset-classic@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': + '@docusaurus/preset-classic@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-css-cascade-layers': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-debug': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-analytics': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-gtag': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-tag-manager': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-sitemap': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-svgr': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-classic': 3.9.2(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-search-algolia': 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-css-cascade-layers': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-debug': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-analytics': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-gtag': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-tag-manager': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-sitemap': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-svgr': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-classic': 3.9.2(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-search-algolia': 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -14207,25 +14207,25 @@ snapshots: '@docusaurus/react-loadable@6.0.0(react@18.3.1)': dependencies: - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 - '@docusaurus/theme-classic@3.9.2(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/theme-classic@3.9.2(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-translations': 3.9.2 '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@18.3.1) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@18.3.1) clsx: 2.1.1 infima: 0.2.0-alpha.45 lodash: 4.17.23 @@ -14257,15 +14257,15 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router-config': 5.0.11 clsx: 2.1.1 parse-numeric-range: 1.3.0 @@ -14281,11 +14281,11 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-mermaid@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/theme-mermaid@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) mermaid: 11.12.2 @@ -14311,13 +14311,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-search-algolia@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': + '@docusaurus/theme-search-algolia@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': dependencies: - '@docsearch/react': 4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docsearch/react': 4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-translations': 3.9.2 '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14364,7 +14364,7 @@ snapshots: '@mdx-js/mdx': 3.1.1 '@types/history': 4.7.11 '@types/mdast': 4.0.4 - '@types/react': 19.2.13 + '@types/react': 19.2.14 commander: 5.1.0 joi: 17.13.3 react: 18.3.1 @@ -14955,22 +14955,22 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.0)': + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.2)': dependencies: front-matter: 4.0.2 marked: 17.0.1 node-emoji: 2.2.0 - svelte: 5.50.0 + svelte: 5.50.2 - '@immich/ui@0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)': + '@immich/ui@0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)': dependencies: - '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.0) + '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.2) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) + bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) luxon: 3.7.2 simple-icons: 16.4.0 - svelte: 5.50.0 + svelte: 5.50.2 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) @@ -15247,8 +15247,8 @@ snapshots: '@koddsson/eslint-plugin-tscompat@0.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@mdn/browser-compat-data': 6.1.5 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) browserslist: 4.28.1 transitivePeerDependencies: - eslint @@ -15415,10 +15415,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1)': + '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1)': dependencies: '@types/mdx': 2.0.13 - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 '@mermaid-js/parser@0.6.3': @@ -15453,12 +15453,12 @@ snapshots: '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.67.3)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0)': dependencies: '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.67.3 + bullmq: 5.68.0 tslib: 2.8.1 '@nestjs/cli@11.0.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@24.10.13)': @@ -15635,7 +15635,7 @@ snapshots: dependencies: consola: 3.4.2 - '@oazapfts/runtime@1.1.0': {} + '@oazapfts/runtime@1.2.0': {} '@opentelemetry/api-logs@0.211.0': dependencies: @@ -16321,17 +16321,17 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@sveltejs/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.50.0 - svelte-parse-markup: 0.1.5(svelte@5.50.0) + svelte: 5.50.2 + svelte-parse-markup: 0.1.5(svelte@5.50.2) vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-imagetools: 9.0.2(rollup@4.55.1) zimmerframe: 1.1.4 @@ -16339,11 +16339,11 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16355,28 +16355,28 @@ snapshots: sade: 1.8.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.50.0 + svelte: 5.50.2 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@opentelemetry/api': 1.9.0 typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 - svelte: 5.50.0 + svelte: 5.50.2 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.50.0 + svelte: 5.50.2 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitefu: 1.1.1(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: @@ -16624,18 +16624,18 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.50.0)': + '@testing-library/svelte-core@1.0.0(svelte@5.50.2)': dependencies: - svelte: 5.50.0 + svelte: 5.50.2 - '@testing-library/svelte@5.3.1(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.0.0(svelte@5.50.0) - svelte: 5.50.0 + '@testing-library/svelte-core': 1.0.0(svelte@5.50.2) + svelte: 5.50.2 optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -17118,21 +17118,21 @@ snapshots: '@types/react-router-config@5.0.11': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router': 5.1.20 '@types/react-router-dom@5.3.3': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router': 5.1.20 '@types/react-router@5.1.20': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 - '@types/react@19.2.13': + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -17236,14 +17236,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -17252,41 +17252,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.54.0': + '@typescript-eslint/scope-manager@8.55.0': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 - '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17294,14 +17294,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.54.0': {} + '@typescript-eslint/types@8.55.0': {} - '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.4 @@ -17311,27 +17311,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.54.0': + '@typescript-eslint/visitor-keys@8.55.0': dependencies: - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17346,11 +17346,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@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17365,7 +17365,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@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -17503,10 +17503,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.50.0)': + '@zoom-image/svelte@0.3.9(svelte@5.50.2)': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.50.0 + svelte: 5.50.2 abab@2.0.6: optional: true @@ -17867,15 +17867,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0): + bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) - svelte: 5.50.0 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) + runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + svelte: 5.50.2 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -17988,7 +17988,7 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.67.3: + bullmq@5.68.0: dependencies: cron-parser: 4.9.0 ioredis: 5.9.2 @@ -19036,9 +19036,9 @@ snapshots: transitivePeerDependencies: - supports-color - docusaurus-lunr-search@3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + docusaurus-lunr-search@3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) autocomplete.js: 0.37.1 clsx: 2.1.1 gauge: 3.0.2 @@ -19403,7 +19403,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.0): + eslint-plugin-svelte@3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.2): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -19415,9 +19415,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.4 - svelte-eslint-parser: 1.4.1(svelte@5.50.0) + svelte-eslint-parser: 1.4.1(svelte@5.50.2) optionalDependencies: - svelte: 5.50.0 + svelte: 5.50.2 transitivePeerDependencies: - ts-node @@ -20155,12 +20155,12 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.5.0: + happy-dom@20.6.1: dependencies: '@types/node': 24.10.13 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 - entities: 4.5.0 + entities: 6.0.1 whatwg-mimetype: 3.0.0 ws: 8.19.0 transitivePeerDependencies: @@ -21355,7 +21355,7 @@ snapshots: tinyqueue: 2.0.3 vt-pbf: 3.1.3 - maplibre-gl@5.17.0: + maplibre-gl@5.18.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -22317,7 +22317,7 @@ snapshots: pkg-types: 2.3.0 tinyexec: 0.3.2 - oauth4webapi@3.8.3: {} + oauth4webapi@3.8.5: {} object-assign@4.1.1: {} @@ -22390,10 +22390,10 @@ snapshots: opener@1.5.2: {} - openid-client@6.8.1: + openid-client@6.8.2: dependencies: jose: 6.1.3 - oauth4webapi: 3.8.3 + oauth4webapi: 3.8.5 optionator@0.9.4: dependencies: @@ -23207,10 +23207,10 @@ snapshots: dependencies: prettier: 3.8.1 - prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.50.0): + prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.50.2): dependencies: prettier: 3.8.1 - svelte: 5.50.0 + svelte: 5.50.2 prettier@3.8.1: {} @@ -23837,14 +23837,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0): + runed@0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.50.0 + svelte: 5.50.2 optionalDependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -24474,23 +24474,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.50.0): + svelte-awesome@3.3.5(svelte@5.50.2): dependencies: - svelte: 5.50.0 + svelte: 5.50.2 - svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.0)(typescript@5.9.3): + svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.2)(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.50.0 + svelte: 5.50.2 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.1(svelte@5.50.0): + svelte-eslint-parser@1.4.1(svelte@5.50.2): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -24499,7 +24499,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.1 optionalDependencies: - svelte: 5.50.0 + svelte: 5.50.2 svelte-floating-ui@1.5.8: dependencies: @@ -24512,7 +24512,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.50.0): + svelte-i18n@4.0.1(svelte@5.50.2): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -24520,10 +24520,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.50.0 + svelte: 5.50.2 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.50.0): + svelte-jsoneditor@3.11.0(svelte@5.50.2): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -24550,42 +24550,42 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.50.0 - svelte-awesome: 3.3.5(svelte@5.50.0) + svelte: 5.50.2 + svelte-awesome: 3.3.5(svelte@5.50.2) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.2.6(svelte@5.50.0): + svelte-maplibre@1.2.6(svelte@5.50.2): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 - maplibre-gl: 5.17.0 + maplibre-gl: 5.18.0 pmtiles: 3.2.1 - svelte: 5.50.0 + svelte: 5.50.2 - svelte-parse-markup@0.1.5(svelte@5.50.0): + svelte-parse-markup@0.1.5(svelte@5.50.2): dependencies: - svelte: 5.50.0 + svelte: 5.50.2 - svelte-persisted-store@0.12.0(svelte@5.50.0): + svelte-persisted-store@0.12.0(svelte@5.50.2): dependencies: - svelte: 5.50.0 + svelte: 5.50.2 svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) + runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) style-to-object: 1.0.14 - svelte: 5.50.0 + svelte: 5.50.2 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.50.0: + svelte@5.50.2: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -25005,12 +25005,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -25330,7 +25330,7 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 @@ -25380,11 +25380,11 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25412,7 +25412,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.13 - happy-dom: 20.5.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -25428,7 +25428,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25456,7 +25456,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.13 - happy-dom: 20.5.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25472,7 +25472,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25500,7 +25500,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 25.2.3 - happy-dom: 20.5.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti diff --git a/web/package.json b/web/package.json index db5a05617e..507b01f6bb 100644 --- a/web/package.json +++ b/web/package.json @@ -98,7 +98,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.50.0", + "svelte": "5.50.2", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", From 0767ae0c8a8ffb5a99eadb85f8a1f6ff4af0070b Mon Sep 17 00:00:00 2001 From: ewinnd <82260303+ewinnd@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:50:11 +0400 Subject: [PATCH 027/143] fix(docs): remove truenas link from synology community guide (#26277) * Update synology.md to remove Truenas link Removed link to Truenas github community repo. * remove blank line --------- Co-authored-by: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> --- docs/docs/install/synology.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/install/synology.md b/docs/docs/install/synology.md index 3e5b780db2..b86561dbbf 100644 --- a/docs/docs/install/synology.md +++ b/docs/docs/install/synology.md @@ -8,8 +8,6 @@ sidebar_position: 85 This is a community contribution and not officially supported by the Immich team, but included here for convenience. Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/). - -**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).** ::: Immich can easily be installed on a Synology NAS using Container Manager within DSM. If you have not installed Container Manager already, you can install it in the Packages Center. Refer to the [Container Manager docs](https://kb.synology.com/en-us/DSM/help/ContainerManager/docker_desc?version=7) for more information on using Container Manager. From 455afbb11944275a9c1750020aa3a707f8c7e718 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 17 Feb 2026 06:51:15 -0500 Subject: [PATCH 028/143] ci: fix formatting task (#26274) --- .github/workflows/fix-format.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 11a9ef06e4..2d4cc1e5f9 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -39,7 +39,7 @@ jobs: cache-dependency-path: '**/pnpm-lock.yaml' - name: Fix formatting - run: pnpm --recursive install && pnpm run --recursive --parallel fix:format + run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix - name: Commit and push uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 From 06d487782e902e896ac14271ae05785a4310694b Mon Sep 17 00:00:00 2001 From: Damien Nozay <205466+dnozay@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:55:34 +0100 Subject: [PATCH 029/143] fix(release): add docker-compose.rootless.yml to released assets (#26261) * fix(release): add docker-compose files to released assets Since there is a warning: "Make sure to use the docker-compose.yml of the current release" This should apply to other docker-compose files, so it would make sense to release them. It also makes it slightly easier to get the asset for rootless (e.g., PR 2750). * release docker-compose.rootless.yml --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30783f5e9b..3376e42d9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,6 +88,7 @@ jobs: draft: true files: | docker/docker-compose.yml + docker/docker-compose.rootless.yml docker/example.env docker/hwaccel.ml.yml docker/hwaccel.transcoding.yml From 5c6433b4ca12d6c97f7addfd1034f76b0ea408e9 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:24:34 +0000 Subject: [PATCH 030/143] feat(mobile): inline asset details (#25952) The existing implementation for showing asset details uses a bottom sheet, and is not in sync with the preview or scroll intent. Other apps use inline details, which is much cleaner and feels better to use. --- mobile/lib/domain/models/events.model.dart | 5 +- .../lib/domain/services/timeline.service.dart | 4 +- mobile/lib/extensions/scroll_extensions.dart | 122 ++++ .../pages/drift_activities.page.dart | 18 +- .../presentation/pages/drift_memory.page.dart | 2 +- .../add_action_button.widget.dart | 2 +- .../edit_image_action_button.widget.dart | 2 +- .../like_activity_action_button.widget.dart | 2 +- .../widgets/album/album_selector.widget.dart | 2 +- .../activities_bottom_sheet.widget.dart | 85 --- .../asset_viewer/asset_details.widget.dart | 45 ++ .../appears_in_details.widget.dart | 78 +++ .../date_time_details.widget.dart | 142 ++++ .../asset_details/drag_handle.widget.dart | 21 + .../location_details.widget.dart} | 11 +- .../people_details.widget.dart} | 16 +- .../asset_details/rating_details.widget.dart | 52 ++ .../technical_details.widget.dart | 129 ++++ .../asset_viewer/asset_page.widget.dart | 454 +++++++++++++ .../widgets/asset_viewer/asset_preloader.dart | 46 ++ .../asset_viewer/asset_stack.widget.dart | 18 +- .../asset_viewer/asset_viewer.page.dart | 621 +++--------------- .../asset_viewer/asset_viewer.state.dart | 43 +- .../asset_viewer/bottom_bar.widget.dart | 63 +- .../asset_viewer/bottom_sheet.widget.dart | 409 ------------ .../asset_viewer/video_viewer.widget.dart | 4 +- .../video_viewer_controls.widget.dart | 6 +- .../viewer_bottom_app_bar.widget.dart | 32 + .../viewer_kebab_menu.widget.dart | 2 +- ...et.dart => viewer_top_app_bar.widget.dart} | 35 +- .../infrastructure/action.provider.dart | 2 +- ...sset.provider.dart => asset.provider.dart} | 12 + mobile/lib/routing/router.gr.dart | 29 +- mobile/lib/utils/action_button.utils.dart | 2 +- mobile/lib/widgets/map/asset_market_icon.dart | 107 +++ mobile/lib/widgets/map/map_thumbnail.dart | 36 +- .../map/positioned_asset_marker_icon.dart | 102 +-- mobile/lib/widgets/photo_view/photo_view.dart | 9 +- .../photo_view/photo_view_gallery.dart | 9 +- .../photo_view/src/core/photo_view_core.dart | 4 + .../src/core/photo_view_gesture_detector.dart | 5 +- .../photo_view/src/photo_view_wrappers.dart | 7 + 42 files changed, 1518 insertions(+), 1277 deletions(-) delete mode 100644 mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart rename mobile/lib/presentation/widgets/asset_viewer/{bottom_sheet/sheet_location_details.widget.dart => asset_details/location_details.widget.dart} (93%) rename mobile/lib/presentation/widgets/asset_viewer/{bottom_sheet/sheet_people_details.widget.dart => asset_details/people_details.widget.dart} (93%) create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart delete mode 100644 mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart rename mobile/lib/presentation/widgets/asset_viewer/{top_app_bar.widget.dart => viewer_top_app_bar.widget.dart} (80%) rename mobile/lib/providers/infrastructure/asset_viewer/{current_asset.provider.dart => asset.provider.dart} (85%) create mode 100644 mobile/lib/widgets/map/asset_market_icon.dart diff --git a/mobile/lib/domain/models/events.model.dart b/mobile/lib/domain/models/events.model.dart index fc9cebc80f..9bbe00852e 100644 --- a/mobile/lib/domain/models/events.model.dart +++ b/mobile/lib/domain/models/events.model.dart @@ -16,9 +16,8 @@ class ScrollToDateEvent extends Event { } // Asset Viewer Events -class ViewerOpenBottomSheetEvent extends Event { - final bool activitiesMode; - const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); +class ViewerShowDetailsEvent extends Event { + const ViewerShowDetailsEvent(); } class ViewerReloadAssetEvent extends Event { diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index bd36d0b569..39aeb867a3 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -183,8 +183,8 @@ class TimelineService { return _buffer.slice(start, start + count); } - // Pre-cache assets around the given index for asset viewer - Future preCacheAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); + // Preload assets around the given index for asset viewer + Future preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length)); diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart index 169032ff5d..5917e127bc 100644 --- a/mobile/lib/extensions/scroll_extensions.dart +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -32,3 +32,125 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics { damping: 80, ); } + +class SnapScrollPhysics extends ScrollPhysics { + static const _minFlingVelocity = 700.0; + static const minSnapDistance = 30.0; + + static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300); + + const SnapScrollPhysics({super.parent}); + + @override + SnapScrollPhysics applyTo(ScrollPhysics? ancestor) { + return SnapScrollPhysics(parent: buildParent(ancestor)); + } + + @override + Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { + assert( + position is SnapScrollPosition, + 'SnapScrollPhysics can only be used with Scrollables that use a ' + 'controller whose createScrollPosition returns a SnapScrollPosition', + ); + + final snapOffset = (position as SnapScrollPosition).snapOffset; + if (snapOffset <= 0) { + return super.createBallisticSimulation(position, velocity); + } + + if (position.pixels >= snapOffset) { + final simulation = super.createBallisticSimulation(position, velocity); + if (simulation == null || simulation.x(double.infinity) >= snapOffset) { + return simulation; + } + } + + return ScrollSpringSimulation( + _spring, + position.pixels, + target(position, velocity, snapOffset), + velocity, + tolerance: toleranceFor(position), + ); + } + + static double target(ScrollMetrics position, double velocity, double snapOffset) { + if (velocity > _minFlingVelocity) return snapOffset; + if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset; + return position.pixels < minSnapDistance ? 0.0 : snapOffset; + } +} + +class SnapScrollPosition extends ScrollPositionWithSingleContext { + double snapOffset; + + SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition}); +} + +class ProxyScrollController extends ScrollController { + final ScrollController scrollController; + + ProxyScrollController({required this.scrollController}); + + SnapScrollPosition get snapPosition => position as SnapScrollPosition; + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { + return ProxyScrollPosition( + scrollController: scrollController, + physics: physics, + context: context, + oldPosition: oldPosition, + ); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } +} + +class ProxyScrollPosition extends SnapScrollPosition { + final ScrollController scrollController; + + ProxyScrollPosition({ + required this.scrollController, + required super.physics, + required super.context, + super.oldPosition, + }); + + @override + double setPixels(double newPixels) { + final overscroll = super.setPixels(newPixels); + if (scrollController.hasClients && scrollController.position.pixels != pixels) { + scrollController.position.forcePixels(pixels); + } + return overscroll; + } + + @override + void forcePixels(double value) { + super.forcePixels(value); + if (scrollController.hasClients && scrollController.position.pixels != pixels) { + scrollController.position.forcePixels(pixels); + } + } + + @override + double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions + ? scrollController.position.maxScrollExtent + : super.maxScrollExtent; + + @override + double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions + ? scrollController.position.minScrollExtent + : super.minScrollExtent; + + @override + double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension + ? scrollController.position.viewportDimension + : super.viewportDimension; +} diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index ac0cd7f309..fa5737443f 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -14,13 +14,15 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da @RoutePage() class DriftActivitiesPage extends HookConsumerWidget { final RemoteAlbum album; + final String? assetId; + final String? assetName; - const DriftActivitiesPage({super.key, required this.album}); + const DriftActivitiesPage({super.key, required this.album, this.assetId, this.assetName}); @override Widget build(BuildContext context, WidgetRef ref) { - final activityNotifier = ref.read(albumActivityProvider(album.id).notifier); - final activities = ref.watch(albumActivityProvider(album.id)); + final activityNotifier = ref.read(albumActivityProvider(album.id, assetId).notifier); + final activities = ref.watch(albumActivityProvider(album.id, assetId)); final listViewScrollController = useScrollController(); void scrollToBottom() { @@ -36,7 +38,13 @@ class DriftActivitiesPage extends HookConsumerWidget { overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], child: Scaffold( appBar: AppBar( - title: Text(album.name), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(album.name), + if (assetName != null) Text(assetName!, style: context.textTheme.bodySmall), + ], + ), actions: [const LikeActivityActionButton(iconOnly: true)], actionsPadding: const EdgeInsets.only(right: 8), ), @@ -47,7 +55,7 @@ class DriftActivitiesPage extends HookConsumerWidget { activityWidgets.add( Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: CommentBubble(activity: activity), + child: CommentBubble(activity: activity, isAssetActivity: assetId != null), ), ); } diff --git a/mobile/lib/presentation/pages/drift_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart index 9042f2f1f5..147165f2a3 100644 --- a/mobile/lib/presentation/pages/drift_memory.page.dart +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.wid import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; 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 index 23cd19f363..4162f43a24 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -4,7 +4,7 @@ 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/providers/infrastructure/asset_viewer/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'; diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index 4c7b6ffbdc..440985a0bb 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class EditImageActionButton extends ConsumerWidget { diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index 8c326974a7..a44b0b5815 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.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/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 8f3cee9215..15749fb9af 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar import 'package:immich_mobile/providers/app_settings.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/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; 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 deleted file mode 100644 index 3b46b69958..0000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -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'; - -class ActivitiesBottomSheet extends HookConsumerWidget { - final DraggableScrollableController controller; - final double initialChildSize; - final bool scrollToBottomInitially; - - const ActivitiesBottomSheet({ - required this.controller, - this.initialChildSize = 0.35, - this.scrollToBottomInitially = true, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(currentRemoteAlbumProvider)!; - final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; - - final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); - final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); - - Future onAddComment(String comment) async { - await activityNotifier.addComment(comment); - } - - Widget buildActivitiesSliver() { - return activities.widgetWhen( - onLoading: () => const SliverToBoxAdapter(child: SizedBox.shrink()), - onData: (data) { - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (index == data.length) { - return const SizedBox.shrink(); - } - final activity = data[data.length - 1 - index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: CommentBubble(activity: activity, isAssetActivity: true), - ); - }, childCount: data.length + 1), - ); - }, - ); - } - - return BaseBottomSheet( - actions: [], - slivers: [buildActivitiesSliver()], - footer: Padding( - // TODO: avoid fixed padding, use context.padding.bottom - padding: const EdgeInsets.only(bottom: 32), - child: Column( - children: [ - const Divider(indent: 16, endIndent: 16), - DriftActivityTextField( - isEnabled: album.isActivityEnabled, - isBottomSheet: true, - // likeId: likedId, - onSubmit: onAddComment, - ), - ], - ), - ), - controller: controller, - initialChildSize: initialChildSize, - minChildSize: 0.1, - maxChildSize: 0.88, - expand: false, - shouldCloseOnMinExtent: false, - resizeOnScroll: false, - backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white, - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart new file mode 100644 index 0000000000..949a6917e9 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -0,0 +1,45 @@ +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/asset_viewer/asset_details/appears_in_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; + +class AssetDetails extends ConsumerWidget { + final double minHeight; + + const AssetDetails({required this.minHeight, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + return Container( + constraints: BoxConstraints(minHeight: minHeight), + decoration: BoxDecoration( + color: context.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const DragHandle(), + const DateTimeDetails(), + const PeopleDetails(), + const LocationDetails(), + const TechnicalDetails(), + const RatingDetails(), + const AppearsInDetails(), + SizedBox(height: context.padding.bottom + 48), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart new file mode 100644 index 0000000000..a3d6bdb8ab --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class AppearsInDetails extends ConsumerWidget { + const AppearsInDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null || !asset.hasRemote) return const SizedBox.shrink(); + + String? remoteAssetId; + if (asset is RemoteAsset) { + remoteAssetId = asset.id; + } else if (asset is LocalAsset) { + remoteAssetId = asset.remoteAssetId; + } + + if (remoteAssetId == null) return const SizedBox.shrink(); + + final userId = ref.watch(currentUserProvider)?.id; + final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId)); + + return assetAlbums.when( + data: (albums) { + if (albums.isEmpty) return const SizedBox.shrink(); + + albums.sortBy((a) => a.name); + + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + spacing: 12, + children: [ + SheetTile( + title: 'appears_in'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + Padding( + padding: const EdgeInsets.only(left: 12), + child: Column( + spacing: 12, + children: albums.map((album) { + final isOwner = album.ownerId == userId; + return AlbumTile( + album: album, + isOwner: isOwner, + onAlbumSelected: (album) async { + ref.invalidate(assetViewerProvider); + unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); + }, + ); + }).toList(), + ), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart new file mode 100644 index 0000000000..4872bf9e75 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/timezone.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +const _kSeparator = ' • '; + +class DateTimeDetails extends ConsumerWidget { + const DateTimeDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); + + return Column( + children: [ + SheetTile( + title: _getDateTime(context, asset, exifInfo), + titleStyle: context.textTheme.labelLarge, + trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, + onTap: asset.hasRemote && isOwner + ? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context) + : null, + ), + if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner), + ], + ); + } + + static String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { + DateTime dateTime = asset.createdAt.toLocal(); + Duration timeZoneOffset = dateTime.timeZoneOffset; + + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, timeZoneOffset) = applyTimezoneOffset( + dateTime: exifInfo!.dateTimeOriginal!, + timeZone: exifInfo.timeZone, + ); + } + + final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); + final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); + final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; + return '$date$_kSeparator$time $timezone'; + } +} + +class _SheetAssetDescription extends ConsumerStatefulWidget { + final ExifInfo exif; + final bool isEditable; + + const _SheetAssetDescription({required this.exif, this.isEditable = true}); + + @override + ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); +} + +class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> { + late TextEditingController _controller; + final _descriptionFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.exif.description ?? ''); + } + + Future saveDescription(String? previousDescription) async { + final newDescription = _controller.text.trim(); + + if (newDescription == previousDescription) { + _descriptionFocus.unfocus(); + return; + } + + final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription); + + if (!editAction.success) { + _controller.text = previousDescription ?? ''; + + ImmichToast.show( + context: context, + msg: 'exif_bottom_sheet_description_error'.t(context: context), + toastType: ToastType.error, + ); + } + + _descriptionFocus.unfocus(); + } + + @override + Widget build(BuildContext context) { + final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + + final currentDescription = currentExifInfo?.description ?? ''; + final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( + context: context, + ); + if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { + _controller.text = currentDescription; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: IgnorePointer( + ignoring: !widget.isEditable, + child: TextField( + controller: _controller, + keyboardType: TextInputType.multiline, + maxLines: null, + focusNode: _descriptionFocus, + decoration: InputDecoration( + hintText: hintText, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + ), + onTapOutside: (_) => saveDescription(currentExifInfo?.description), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart new file mode 100644 index 0000000000..8c24c5004c --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class DragHandle extends StatelessWidget { + const DragHandle({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Container( + width: 32, + height: 4, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(2)), + color: context.colorScheme.onSurfaceVariant, + ), + ), + ), + ); +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart similarity index 93% rename from mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart index ce561c4016..0665f4d46c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart @@ -8,18 +8,18 @@ import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class SheetLocationDetails extends ConsumerStatefulWidget { - const SheetLocationDetails({super.key}); +class LocationDetails extends ConsumerStatefulWidget { + const LocationDetails({super.key}); @override - ConsumerState createState() => _SheetLocationDetailsState(); + ConsumerState createState() => _LocationDetailsState(); } -class _SheetLocationDetailsState extends ConsumerState { +class _LocationDetailsState extends ConsumerState { MapLibreMapController? _mapController; String? _getLocationName(ExifInfo? exifInfo) { @@ -42,7 +42,6 @@ class _SheetLocationDetailsState extends ConsumerState { void _onExifChanged(AsyncValue? previous, AsyncValue current) { final currentExif = current.valueOrNull; - if (currentExif != null && currentExif.hasCoordinates) { _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!))); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart similarity index 93% rename from mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 7eb9e578ff..5074c63c9c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; @@ -15,14 +15,14 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/people.utils.dart'; -class SheetPeopleDetails extends ConsumerStatefulWidget { - const SheetPeopleDetails({super.key}); +class PeopleDetails extends ConsumerStatefulWidget { + const PeopleDetails({super.key}); @override - ConsumerState createState() => _SheetPeopleDetailsState(); + ConsumerState createState() => _PeopleDetailsState(); } -class _SheetPeopleDetailsState extends ConsumerState { +class _PeopleDetailsState extends ConsumerState { @override Widget build(BuildContext context) { final asset = ref.watch(currentAssetNotifier); @@ -65,7 +65,7 @@ class _SheetPeopleDetailsState extends ConsumerState { scrollDirection: Axis.horizontal, children: [ for (final person in people) - _PeopleAvatar( + _Avatar( person: person, assetFileCreatedAt: asset.createdAt, onTap: () { @@ -97,14 +97,14 @@ class _SheetPeopleDetailsState extends ConsumerState { } } -class _PeopleAvatar extends StatelessWidget { +class _Avatar extends StatelessWidget { final DriftPerson person; final DateTime assetFileCreatedAt; final VoidCallback? onTap; final VoidCallback? onNameTap; final double imageSize = 96; - const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap}); + const _Avatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap}); @override Widget build(BuildContext context) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart new file mode 100644 index 0000000000..982ea67583 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; + +class RatingDetails extends ConsumerWidget { + const RatingDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isRatingEnabled = ref + .watch(userMetadataPreferencesProvider) + .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + + if (!isRatingEnabled) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + + return Padding( + padding: const EdgeInsets.only(left: 16.0, top: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + 'rating'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + RatingBar( + initialRating: exifInfo?.rating?.toDouble() ?? 0, + filledColor: context.themeData.colorScheme.primary, + unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), + itemSize: 40, + onRatingUpdate: (rating) async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); + }, + onClearRating: () async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); + }, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart new file mode 100644 index 0000000000..d79362b559 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; + +const _kSeparator = ' • '; + +class TechnicalDetails extends ConsumerWidget { + const TechnicalDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final cameraTitle = _getCameraInfoTitle(exifInfo); + final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; + + return Column( + children: [ + SheetTile( + title: 'details'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + _buildFileInfoTile(context, ref, asset, exifInfo), + if (cameraTitle != null) ...[ + const SizedBox(height: 16), + SheetTile( + title: cameraTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getCameraInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + if (lensTitle != null) ...[ + const SizedBox(height: 16), + 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.colorScheme.onSurfaceSecondary), + ), + ], + ], + ); + } + + Widget _buildFileInfoTile(BuildContext context, WidgetRef ref, BaseAsset asset, ExifInfo? exifInfo) { + final icon = Icon( + asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, + size: 24, + color: context.textTheme.labelLarge?.color, + ); + final subtitle = _getFileInfo(asset, exifInfo); + final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary); + + if (asset is LocalAsset) { + final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); + return FutureBuilder( + future: assetMediaRepository.getOriginalFilename(asset.id), + builder: (context, snapshot) { + return SheetTile( + title: snapshot.data ?? asset.name, + titleStyle: context.textTheme.labelLarge, + leading: icon, + subtitle: subtitle, + subtitleStyle: subtitleStyle, + ); + }, + ); + } + + return SheetTile( + title: asset.name, + titleStyle: context.textTheme.labelLarge, + leading: icon, + subtitle: subtitle, + subtitleStyle: subtitleStyle, + ); + } + + static String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { + final height = asset.height; + final width = asset.width; + final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null; + final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; + + return switch ((fileSize, resolution)) { + (null, null) => '', + (String fileSize, null) => fileSize, + (null, String resolution) => resolution, + (String fileSize, String resolution) => '$fileSize$_kSeparator$resolution', + }; + } + + static String? _getCameraInfoTitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + return switch ((exifInfo.make, exifInfo.model)) { + (null, null) => null, + (String make, null) => make, + (null, String model) => model, + (String make, String model) => '$make $model', + }; + } + + static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; + final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; + return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } + + static 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); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart new file mode 100644 index 0000000000..a8f5f9d14a --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -0,0 +1,454 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/gestures.dart' show Drag, kTouchSlop; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; + +enum _DragIntent { none, scroll, dismiss } + +class AssetPage extends ConsumerStatefulWidget { + final int index; + final int heroOffset; + + const AssetPage({super.key, required this.index, required this.heroOffset}); + + @override + ConsumerState createState() => _AssetPageState(); +} + +class _AssetPageState extends ConsumerState { + PhotoViewControllerBase? _viewController; + StreamSubscription? _scaleBoundarySub; + StreamSubscription? _eventSubscription; + + AssetViewerStateNotifier get _viewer => ref.read(assetViewerProvider.notifier); + + late PhotoViewControllerValue _initialPhotoViewState; + + bool _blockGestures = false; + bool _showingDetails = false; + bool _isZoomed = false; + + final _scrollController = ScrollController(); + late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); + + double _snapOffset = 0.0; + double _lastScrollOffset = 0.0; + + DragStartDetails? _dragStart; + _DragIntent _dragIntent = _DragIntent.none; + Drag? _drag; + bool _dragInProgress = false; + bool _shouldPopOnDrag = false; + + @override + void initState() { + super.initState(); + _proxyScrollController.addListener(_onScroll); + _eventSubscription = EventStream.shared.listen(_onEvent); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_proxyScrollController.hasClients) return; + _proxyScrollController.snapPosition.snapOffset = _snapOffset; + if (_showingDetails && _snapOffset > 0) { + _proxyScrollController.jumpTo(_snapOffset); + } + }); + } + + @override + void dispose() { + _proxyScrollController.dispose(); + _scaleBoundarySub?.cancel(); + _eventSubscription?.cancel(); + super.dispose(); + } + + void _onEvent(Event event) { + switch (event) { + case ViewerShowDetailsEvent(): + _showDetails(); + default: + } + } + + void _showDetails() { + if (!_proxyScrollController.hasClients || _snapOffset <= 0) return; + _lastScrollOffset = _proxyScrollController.offset; + _proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); + } + + bool _willClose(double scrollVelocity) { + if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false; + + final position = _proxyScrollController.position; + return _proxyScrollController.position.pixels < _snapOffset && + SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance; + } + + void _onScroll() { + final offset = _proxyScrollController.offset; + if (offset > SnapScrollPhysics.minSnapDistance && offset > _lastScrollOffset) { + _viewer.setShowingDetails(true); + } else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) { + _viewer.setShowingDetails(false); + } + _lastScrollOffset = offset; + } + + void _beginDrag(DragStartDetails details) { + _dragStart = details; + _shouldPopOnDrag = false; + _lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0; + + if (_viewController != null) { + _initialPhotoViewState = _viewController!.value; + } + + if (_showingDetails) { + _dragIntent = _DragIntent.scroll; + _startProxyDrag(); + } + } + + void _startProxyDrag() { + if (_proxyScrollController.hasClients && _dragStart != null) { + _drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null); + } + } + + void _updateDrag(DragUpdateDetails details) { + if (_blockGestures) return; + + _dragInProgress = true; + + if (_dragIntent == _DragIntent.none) { + _dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) { + < -kTouchSlop => _DragIntent.scroll, + > kTouchSlop => _DragIntent.dismiss, + _ => _DragIntent.none, + }; + } + + switch (_dragIntent) { + case _DragIntent.none: + case _DragIntent.scroll: + if (_drag == null) _startProxyDrag(); + _drag?.update(details); + case _DragIntent.dismiss: + _handleDragDown(context, details.localPosition - _dragStart!.localPosition); + } + } + + void _endDrag(DragEndDetails details) { + _dragInProgress = false; + + if (_blockGestures) { + _blockGestures = false; + return; + } + + final intent = _dragIntent; + _dragIntent = _DragIntent.none; + _dragStart = null; + + switch (intent) { + case _DragIntent.none: + case _DragIntent.scroll: + final scrollVelocity = -(details.primaryVelocity ?? 0.0); + if (_willClose(scrollVelocity)) { + _viewer.setShowingDetails(false); + } + _drag?.end(details); + _drag = null; + case _DragIntent.dismiss: + if (_shouldPopOnDrag) { + context.maybePop(); + return; + } + _viewController?.animateMultiple( + position: _initialPhotoViewState.position, + scale: _viewController?.initialScale ?? _initialPhotoViewState.scale, + rotation: _initialPhotoViewState.rotation, + ); + _viewer.setOpacity(1.0); + } + } + + void _onDragStart( + BuildContext context, + DragStartDetails details, + PhotoViewControllerBase controller, + PhotoViewScaleStateController scaleStateController, + ) { + _viewController = controller; + if (!_showingDetails && _isZoomed) { + _blockGestures = true; + return; + } + _beginDrag(details); + } + + void _onDragUpdate(BuildContext context, DragUpdateDetails details, PhotoViewControllerValue _) => + _updateDrag(details); + + void _onDragEnd(BuildContext context, DragEndDetails details, PhotoViewControllerValue _) => _endDrag(details); + + void _onDragCancel() => _endDrag(DragEndDetails(primaryVelocity: 0.0)); + + void _handleDragDown(BuildContext context, Offset delta) { + const dragRatio = 0.2; + const popThreshold = 75.0; + + _shouldPopOnDrag = delta.dy > popThreshold; + + final distance = delta.dy.abs(); + + final maxScaleDistance = context.height * 0.5; + final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); + final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale; + final updatedScale = initialScale != null ? initialScale * (1.0 - scaleReduction) : null; + + final opacity = 1.0 - (scaleReduction / dragRatio); + + _viewController?.updateMultiple(position: _initialPhotoViewState.position + delta, scale: updatedScale); + _viewer.setOpacity(opacity); + } + + void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) { + if (!_showingDetails && !_dragInProgress) _viewer.toggleControls(); + } + + void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) => + ref.read(isPlayingMotionVideoProvider.notifier).playing = true; + + void _onScaleStateChanged(PhotoViewScaleState scaleState) { + _isZoomed = switch (scaleState) { + PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true, + _ => false, + }; + _viewer.setZoomed(_isZoomed); + + if (scaleState != PhotoViewScaleState.initial) { + if (!_dragInProgress) _viewer.setControls(false); + + ref.read(videoPlayerControlsProvider.notifier).pause(); + return; + } + + if (!_showingDetails) _viewer.setControls(true); + } + + void _listenForScaleBoundaries(PhotoViewControllerBase? controller) { + _scaleBoundarySub?.cancel(); + _scaleBoundarySub = null; + if (controller == null || controller.scaleBoundaries != null) return; + _scaleBoundarySub = controller.outputStateStream.listen((_) { + if (controller.scaleBoundaries != null) { + _scaleBoundarySub?.cancel(); + _scaleBoundarySub = null; + if (mounted) setState(() {}); + } + }); + } + + double _getImageHeight(double maxWidth, double maxHeight, BaseAsset? asset) { + final sb = _viewController?.scaleBoundaries; + if (sb != null) return sb.childSize.height * sb.initialScale; + + if (asset == null || asset.width == null || asset.height == null) return maxHeight; + + final r = asset.width! / asset.height!; + return math.min(maxWidth / r, maxHeight); + } + + void _onPageBuild(PhotoViewControllerBase controller) { + _viewController = controller; + _listenForScaleBoundaries(controller); + } + + Widget _buildPhotoView( + BaseAsset displayAsset, + BaseAsset asset, { + required bool isCurrentPage, + required bool showingDetails, + required bool isPlayingMotionVideo, + required BoxDecoration backgroundDecoration, + }) { + final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null; + + if (displayAsset.isImage && !isPlayingMotionVideo) { + final size = context.sizeData; + return PhotoView( + key: ValueKey(displayAsset.heroTag), + index: widget.index, + imageProvider: getFullImageProvider(displayAsset, size: size), + heroAttributes: heroAttributes, + loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), + backgroundDecoration: backgroundDecoration, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + tightMode: true, + enablePanAlways: true, + disableScaleGestures: showingDetails, + scaleStateChangedCallback: _onScaleStateChanged, + onPageBuild: _onPageBuild, + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + onDragCancel: _onDragCancel, + onTapUp: _onTapUp, + onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null, + errorBuilder: (_, __, ___) => SizedBox( + width: size.width, + height: size.height, + child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain), + ), + ); + } + + return PhotoView.customChild( + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + onDragCancel: _onDragCancel, + onTapUp: _onTapUp, + heroAttributes: heroAttributes, + filterQuality: FilterQuality.high, + maxScale: 1.0, + basePosition: Alignment.center, + disableScaleGestures: true, + scaleStateChangedCallback: _onScaleStateChanged, + onPageBuild: _onPageBuild, + enablePanAlways: true, + backgroundDecoration: backgroundDecoration, + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewer( + key: ValueKey(displayAsset.heroTag), + asset: displayAsset, + image: Image( + key: ValueKey(displayAsset), + image: getFullImageProvider(displayAsset, size: context.sizeData), + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); + _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); + final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); + + final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index); + if (asset == null) { + return const Center(child: ImmichLoadingIndicator()); + } + + BaseAsset displayAsset = asset; + final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull; + if (stackChildren != null && stackChildren.isNotEmpty) { + displayAsset = stackChildren.elementAt(stackIndex); + } + + final viewportWidth = MediaQuery.widthOf(context); + final viewportHeight = MediaQuery.heightOf(context); + final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset); + + final margin = (viewportHeight - imageHeight) / 2; + final overflowBoxHeight = margin + imageHeight - (kMinInteractiveDimension / 2); + _snapOffset = (margin + imageHeight) - (viewportHeight / 4); + + if (_proxyScrollController.hasClients) { + _proxyScrollController.snapPosition.snapOffset = _snapOffset; + } + + return ProviderScope( + overrides: [ + currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)), + currentAssetExifProvider.overrideWith((ref) { + final a = ref.watch(currentAssetNotifier); + if (a == null) return Future.value(null); + return ref.watch(assetServiceProvider).getExif(a); + }), + ], + child: Stack( + children: [ + Offstage( + child: SingleChildScrollView( + controller: _proxyScrollController, + physics: const SnapScrollPhysics(), + child: const SizedBox.shrink(), + ), + ), + SingleChildScrollView( + controller: _scrollController, + physics: const NeverScrollableScrollPhysics(), + child: Stack( + children: [ + SizedBox( + width: viewportWidth, + height: viewportHeight, + child: _buildPhotoView( + displayAsset, + asset, + isCurrentPage: currentHeroTag == asset.heroTag, + showingDetails: _showingDetails, + isPlayingMotionVideo: isPlayingMotionVideo, + backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), + ), + ), + IgnorePointer( + ignoring: !_showingDetails, + child: Column( + children: [ + SizedBox(height: overflowBoxHeight), + GestureDetector( + onVerticalDragStart: _beginDrag, + onVerticalDragUpdate: _updateDrag, + onVerticalDragEnd: _endDrag, + onVerticalDragCancel: _onDragCancel, + child: AnimatedOpacity( + opacity: _showingDetails ? 1.0 : 0.0, + duration: Durations.short2, + child: AssetDetails(minHeight: _snapOffset + viewportHeight - overflowBoxHeight), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart new file mode 100644 index 0000000000..ca7498a37f --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; + +class AssetPreloader { + static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); + + final TimelineService timelineService; + final bool Function() mounted; + + Timer? _timer; + ImageStream? _prevStream; + ImageStream? _nextStream; + + AssetPreloader({required this.timelineService, required this.mounted}); + + void preload(int index, Size size) { + unawaited(timelineService.preloadAssets(index)); + _timer?.cancel(); + _timer = Timer(Durations.medium4, () async { + if (!mounted()) return; + final (prev, next) = await ( + timelineService.getAssetAsync(index - 1), + timelineService.getAssetAsync(index + 1), + ).wait; + if (!mounted()) return; + _prevStream?.removeListener(_dummyListener); + _nextStream?.removeListener(_dummyListener); + _prevStream = prev != null ? _resolveImage(prev, size) : null; + _nextStream = next != null ? _resolveImage(next, size) : null; + }); + } + + ImageStream _resolveImage(BaseAsset asset, Size size) { + return getFullImageProvider(asset, size: size).resolve(ImageConfiguration.empty)..addListener(_dummyListener); + } + + void dispose() { + _timer?.cancel(); + _prevStream?.removeListener(_dummyListener); + _nextStream?.removeListener(_dummyListener); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart index 0978b3c9af..2835342b85 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; class AssetStackRow extends ConsumerWidget { const AssetStackRow({super.key}); @@ -21,17 +21,11 @@ class AssetStackRow extends ConsumerWidget { return const SizedBox.shrink(); } - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0; - - return IgnorePointer( - ignoring: opacity < 255, - child: AnimatedOpacity( - opacity: opacity / 255, - duration: Durations.short2, - child: _StackList(stack: stackChildren), - ), - ); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + if (showingDetails) { + return const SizedBox.shrink(); + } + return _StackList(stack: stackChildren); } } 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 ed2ab9d15d..13311fc4b2 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -14,27 +14,19 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; -import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; -import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; @RoutePage() class AssetViewerPage extends StatelessWidget { @@ -79,10 +71,6 @@ class AssetViewer extends ConsumerStatefulWidget { _setAsset(ref, asset); } - void changeAsset(WidgetRef ref, BaseAsset asset) { - _setAsset(ref, asset); - } - static void _setAsset(WidgetRef ref, BaseAsset asset) { // Always holds the current asset from the timeline ref.read(assetViewerProvider.notifier).setAsset(asset); @@ -94,45 +82,20 @@ class AssetViewer extends ConsumerStatefulWidget { ref.read(videoPlayerControlsProvider.notifier).pause(); } // Hide controls by default for videos - if (asset.isVideo) { - ref.read(assetViewerProvider.notifier).setControls(false); - } + if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false); } } -const double _kBottomSheetMinimumExtent = 0.4; -const double _kBottomSheetSnapExtent = 0.67; - class _AssetViewerState extends ConsumerState { - static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); late PageController pageController; - late DraggableScrollableController bottomSheetController; - PersistentBottomSheetController? sheetCloseController; - // PhotoViewGallery takes care of disposing it's controllers - PhotoViewControllerBase? viewController; - StreamSubscription? reloadSubscription; + + StreamSubscription? _reloadSubscription; late final int heroOffset; - late PhotoViewControllerValue initialPhotoViewState; - bool? hasDraggedDown; - bool isSnapping = false; - bool blockGestures = false; - bool dragInProgress = false; - bool shouldPopOnDrag = false; - bool assetReloadRequested = false; - double previousExtent = _kBottomSheetMinimumExtent; - Offset dragDownPosition = Offset.zero; - int totalAssets = 0; - int stackIndex = 0; - BuildContext? scaffoldContext; - Map videoPlayerKeys = {}; - - // Delayed operations that should be cancelled on disposal - final List _delayedOperations = []; - - ImageStream? _prevPreCacheStream; - ImageStream? _nextPreCacheStream; + bool _assetReloadRequested = false; + int _totalAssets = 0; + late final AssetPreloader _preloader; KeepAliveLink? _stackChildrenKeepAlive; @override @@ -140,94 +103,38 @@ class _AssetViewerState extends ConsumerState { super.initState(); assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); pageController = PageController(initialPage: widget.initialIndex); - totalAssets = ref.read(timelineServiceProvider).totalAssets; - bottomSheetController = DraggableScrollableController(); + final timelineService = ref.read(timelineServiceProvider); + _totalAssets = timelineService.totalAssets; + _preloader = AssetPreloader(timelineService: timelineService, mounted: () => mounted); WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); - reloadSubscription = EventStream.shared.listen(_onEvent); + _reloadSubscription = EventStream.shared.listen(_onEvent); heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; final asset = ref.read(currentAssetNotifier); - if (asset != null) { - _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); - } - if (ref.read(assetViewerProvider).showingControls) { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } else { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)); - } + if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); } @override void dispose() { pageController.dispose(); - bottomSheetController.dispose(); - _cancelTimers(); - reloadSubscription?.cancel(); - _prevPreCacheStream?.removeListener(_dummyListener); - _nextPreCacheStream?.removeListener(_dummyListener); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + _preloader.dispose(); + _reloadSubscription?.cancel(); _stackChildrenKeepAlive?.close(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); } - bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet)); - - Color get backgroundColor { - final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity)); - return Colors.black.withAlpha(opacity); - } - - void _cancelTimers() { - for (final timer in _delayedOperations) { - timer.cancel(); - } - _delayedOperations.clear(); - } - - double _getVerticalOffsetForBottomSheet(double extent) => - (context.height * extent) - (context.height * _kBottomSheetMinimumExtent); - - ImageStream _precacheImage(BaseAsset asset) { - final provider = getFullImageProvider(asset, size: context.sizeData); - return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener); - } - - void _precacheAssets(int index) { - final timelineService = ref.read(timelineServiceProvider); - unawaited(timelineService.preCacheAssets(index)); - _cancelTimers(); - // This will trigger the pre-caching of adjacent assets ensuring - // that they are ready when the user navigates to them. - final timer = Timer(Durations.medium4, () async { - // Check if widget is still mounted before proceeding - if (!mounted) return; - - final (prevAsset, nextAsset) = await ( - timelineService.getAssetAsync(index - 1), - timelineService.getAssetAsync(index + 1), - ).wait; - if (!mounted) return; - _prevPreCacheStream?.removeListener(_dummyListener); - _nextPreCacheStream?.removeListener(_dummyListener); - _prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null; - _nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null; - }); - _delayedOperations.add(timer); - } - - void _onAssetInit(Duration _) { - _precacheAssets(widget.initialIndex); + void _onAssetInit(Duration timeStamp) { + _preloader.preload(widget.initialIndex, context.sizeData); _handleCasting(); } void _onAssetChanged(int index) async { final timelineService = ref.read(timelineServiceProvider); final asset = await timelineService.getAssetAsync(index); - if (asset == null) { - return; - } + if (asset == null) return; - widget.changeAsset(ref, asset); - _precacheAssets(index); + AssetViewer._setAsset(ref, asset); + _preloader.preload(index, context.sizeData); _handleCasting(); _stackChildrenKeepAlive?.close(); _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); @@ -238,223 +145,40 @@ class _AssetViewerState extends ConsumerState { final asset = ref.read(currentAssetNotifier); if (asset == null) return; - // hide any casting snackbars if they exist - context.scaffoldMessenger.hideCurrentSnackBar(); - - // send image to casting if the server has it if (asset is RemoteAsset) { + context.scaffoldMessenger.hideCurrentSnackBar(); ref.read(castProvider.notifier).loadMedia(asset, false); - } else { - // casting cannot show local assets - context.scaffoldMessenger.clearSnackBars(); - - if (ref.read(castProvider).isCasting) { - ref.read(castProvider.notifier).stop(); - context.scaffoldMessenger.showSnackBar( - SnackBar( - duration: const Duration(seconds: 2), - content: Text( - "local_asset_cast_failed".tr(), - style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), - ), - ), - ); - } - } - } - - void _onPageBuild(PhotoViewControllerBase controller) { - viewController ??= controller; - if (showingBottomSheet && bottomSheetController.isAttached) { - final verticalOffset = - (context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent); - controller.position = Offset(0, -verticalOffset); - // Apply the zoom effect when the bottom sheet is showing - controller.scale = (controller.scale ?? 1.0) + 0.01; - } - } - - void _onPageChanged(int index, PhotoViewControllerBase? controller) { - _onAssetChanged(index); - viewController = controller; - } - - void _onDragStart( - _, - DragStartDetails details, - PhotoViewControllerBase controller, - PhotoViewScaleStateController scaleStateController, - ) { - viewController = controller; - dragDownPosition = details.localPosition; - initialPhotoViewState = controller.value; - final isZoomed = - scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || - scaleStateController.scaleState == PhotoViewScaleState.covering; - if (!showingBottomSheet && isZoomed) { - blockGestures = true; - } - } - - void _onDragEnd(BuildContext ctx, _, __) { - dragInProgress = false; - - if (shouldPopOnDrag) { - // Dismiss immediately without state updates to avoid rebuilds - ctx.maybePop(); return; } - // Do not reset the state if the bottom sheet is showing - if (showingBottomSheet) { - _snapBottomSheet(); - return; - } - - // If the gestures are blocked, do not reset the state - if (blockGestures) { - blockGestures = false; - return; - } - - shouldPopOnDrag = false; - hasDraggedDown = null; - viewController?.animateMultiple( - position: initialPhotoViewState.position, - scale: viewController?.initialScale ?? initialPhotoViewState.scale, - rotation: initialPhotoViewState.rotation, + context.scaffoldMessenger.clearSnackBars(); + ref.read(castProvider.notifier).stop(); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "local_asset_cast_failed".tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), ); - ref.read(assetViewerProvider.notifier).setOpacity(255); - } - - void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) { - if (blockGestures) { - return; - } - - dragInProgress = true; - final delta = details.localPosition - dragDownPosition; - hasDraggedDown ??= delta.dy > 0; - if (!hasDraggedDown! || showingBottomSheet) { - _handleDragUp(ctx, delta); - return; - } - - _handleDragDown(ctx, delta); - } - - void _handleDragUp(BuildContext ctx, Offset delta) { - const double openThreshold = 50; - - final position = initialPhotoViewState.position + Offset(0, delta.dy); - final distanceToOrigin = position.distance; - - viewController?.updateMultiple(position: position); - // Moves the bottom sheet when the asset is being dragged up - if (showingBottomSheet && bottomSheetController.isAttached) { - final centre = (ctx.height * _kBottomSheetMinimumExtent); - bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height); - } - - if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) { - _openBottomSheet(ctx); - } - } - - void _handleDragDown(BuildContext ctx, Offset delta) { - const double dragRatio = 0.2; - const double popThreshold = 75; - - final distance = delta.distance; - shouldPopOnDrag = delta.dy > 0 && distance > popThreshold; - - final maxScaleDistance = ctx.height * 0.5; - final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); - double? updatedScale; - double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale; - if (initialScale != null) { - updatedScale = initialScale * (1.0 - scaleReduction); - } - - final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round(); - - viewController?.updateMultiple(position: initialPhotoViewState.position + delta, scale: updatedScale); - ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity); - } - - void _onTapDown(_, __, ___) { - if (!showingBottomSheet) { - ref.read(assetViewerProvider.notifier).toggleControls(); - } - } - - bool _onNotification(Notification delta) { - if (delta is DraggableScrollableNotification) { - _handleDraggableNotification(delta); - } - - // Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after - // the isSnapping guard is to prevent the notification from recursively handling the - // notification, eventually resulting in a heap overflow - if (!isSnapping && delta is ScrollEndNotification) { - _snapBottomSheet(); - } - return false; - } - - void _handleDraggableNotification(DraggableScrollableNotification delta) { - final currentExtent = delta.extent; - final isDraggingDown = currentExtent < previousExtent; - previousExtent = currentExtent; - // Closes the bottom sheet if the user is dragging down - if (isDraggingDown && delta.extent < 0.67) { - if (dragInProgress) { - blockGestures = true; - } - // Jump to a lower position before starting close animation to prevent glitch - if (bottomSheetController.isAttached) { - bottomSheetController.jumpTo(0.67); - } - sheetCloseController?.close(); - } - - // If the asset is being dragged down, we do not want to update the asset position again - if (dragInProgress) { - return; - } - - final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent); - // Moves the asset when the bottom sheet is being dragged - if (verticalOffset > 0) { - viewController?.position = Offset(0, -verticalOffset); - } } void _onEvent(Event event) { - if (event is TimelineReloadEvent) { - _onTimelineReloadEvent(); - return; - } - - if (event is ViewerReloadAssetEvent) { - assetReloadRequested = true; - return; - } - - if (event is ViewerOpenBottomSheetEvent) { - final extent = _kBottomSheetMinimumExtent + 0.3; - _openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode); - final offset = _getVerticalOffsetForBottomSheet(extent); - viewController?.position = Offset(0, -offset); - return; + switch (event) { + case TimelineReloadEvent(): + _onTimelineReloadEvent(); + case ViewerReloadAssetEvent(): + _assetReloadRequested = true; + default: } } void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); - totalAssets = timelineService.totalAssets; + _totalAssets = timelineService.totalAssets; - if (totalAssets == 0) { + if (_totalAssets == 0) { context.maybePop(); return; } @@ -469,229 +193,58 @@ class _AssetViewerState extends ConsumerState { } } - if (index >= totalAssets) { - index = totalAssets - 1; + if (index >= _totalAssets) { + index = _totalAssets - 1; pageController.jumpToPage(index); } - if (assetReloadRequested) { - assetReloadRequested = false; + if (_assetReloadRequested) { + _assetReloadRequested = false; _onAssetReloadEvent(index); } } void _onAssetReloadEvent(int index) async { final timelineService = ref.read(timelineServiceProvider); - final newAsset = await timelineService.getAssetAsync(index); - if (newAsset == null) { - return; - } + final newAsset = await timelineService.getAssetAsync(index); + if (newAsset == null) return; final currentAsset = ref.read(currentAssetNotifier); - // Do not reload / close the bottom sheet if the asset has not changed - if (newAsset.heroTag == currentAsset?.heroTag) { - return; - } - setState(() { - _onAssetChanged(pageController.page!.round()); - sheetCloseController?.close(); - }); - } + // Do not reload if the asset has not changed + if (newAsset.heroTag == currentAsset?.heroTag) return; - void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) { - ref.read(assetViewerProvider.notifier).setBottomSheet(true); - previousExtent = _kBottomSheetMinimumExtent; - sheetCloseController = showBottomSheet( - context: ctx, - sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2), - constraints: const BoxConstraints(maxWidth: double.infinity), - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))), - backgroundColor: ctx.colorScheme.surfaceContainerLowest, - builder: (_) { - return NotificationListener( - onNotification: _onNotification, - child: activitiesMode - ? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent) - : AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent), - ); - }, - ); - sheetCloseController?.closed.then((_) => _handleSheetClose()); - } - - void _handleSheetClose() { - viewController?.animateMultiple(position: Offset.zero); - viewController?.updateMultiple(scale: viewController?.initialScale); - ref.read(assetViewerProvider.notifier).setBottomSheet(false); - sheetCloseController = null; - shouldPopOnDrag = false; - hasDraggedDown = null; - } - - void _snapBottomSheet() { - if (!bottomSheetController.isAttached || - bottomSheetController.size > _kBottomSheetSnapExtent || - bottomSheetController.size < 0.4) { - return; - } - isSnapping = true; - bottomSheetController.animateTo(_kBottomSheetSnapExtent, duration: Durations.short3, curve: Curves.easeOut); - } - - Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) { - return const Center(child: ImmichLoadingIndicator()); - } - - void _onScaleStateChanged(PhotoViewScaleState scaleState) { - if (scaleState != PhotoViewScaleState.initial) { - if (!dragInProgress) { - ref.read(assetViewerProvider.notifier).setControls(false); - } - ref.read(videoPlayerControlsProvider.notifier).pause(); - return; - } - - if (!showingBottomSheet) { - ref.read(assetViewerProvider.notifier).setControls(true); - } - } - - void _onLongPress(_, __, ___) { - ref.read(isPlayingMotionVideoProvider.notifier).playing = true; - } - - PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { - scaffoldContext ??= ctx; - final timelineService = ref.read(timelineServiceProvider); - final asset = timelineService.getAssetSafe(index); - - // If asset is not available in buffer, return a placeholder - if (asset == null) { - return PhotoViewGalleryPageOptions.customChild( - heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'), - child: Container( - width: ctx.width, - height: ctx.height, - color: backgroundColor, - child: const Center(child: CircularProgressIndicator()), - ), - ); - } - - BaseAsset displayAsset = asset; - final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; - if (stackChildren != null && stackChildren.isNotEmpty) { - displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex); - } - - final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); - if (displayAsset.isImage && !isPlayingMotionVideo) { - return _imageBuilder(ctx, displayAsset); - } - - return _videoBuilder(ctx, displayAsset); - } - - PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) { - final size = ctx.sizeData; - return PhotoViewGalleryPageOptions( - key: ValueKey(asset.heroTag), - imageProvider: getFullImageProvider(asset, size: size), - heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), - filterQuality: FilterQuality.high, - tightMode: true, - disableScaleGestures: showingBottomSheet, - onDragStart: _onDragStart, - onDragUpdate: _onDragUpdate, - onDragEnd: _onDragEnd, - onTapDown: _onTapDown, - onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, - errorBuilder: (_, __, ___) => Container( - width: size.width, - height: size.height, - color: backgroundColor, - child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), - ), - ); - } - - GlobalKey _getVideoPlayerKey(String id) { - videoPlayerKeys.putIfAbsent(id, () => GlobalKey()); - return videoPlayerKeys[id]!; - } - - PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) { - return PhotoViewGalleryPageOptions.customChild( - onDragStart: _onDragStart, - onDragUpdate: _onDragUpdate, - onDragEnd: _onDragEnd, - onTapDown: _onTapDown, - heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), - filterQuality: FilterQuality.high, - maxScale: 1.0, - basePosition: Alignment.center, - disableScaleGestures: true, - child: SizedBox( - width: ctx.width, - height: ctx.height, - child: NativeVideoViewer( - key: _getVideoPlayerKey(asset.heroTag), - asset: asset, - image: Image( - key: ValueKey(asset), - image: getFullImageProvider(asset, size: ctx.sizeData), - fit: BoxFit.contain, - height: ctx.height, - width: ctx.width, - alignment: Alignment.center, - ), - ), - ), - ); - } - - void _onPop(bool didPop, T? result) { - ref.read(currentAssetNotifier.notifier).dispose(); + _onAssetChanged(index); } @override Widget build(BuildContext context) { - // 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.backgroundOpacity)); - ref.watch(assetViewerProvider.select((s) => s.stackIndex)); - ref.watch(isPlayingMotionVideoProvider); final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + final isZoomed = ref.watch(assetViewerProvider.select((s) => s.isZoomed)); + final backgroundColor = showingDetails + ? context.colorScheme.surface + : Colors.black.withValues(alpha: ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity))); // Listen for casting changes and send initial asset to the cast provider - ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async { + ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) { if (!isCasting) return; - - final asset = ref.read(currentAssetNotifier); - if (asset == null) return; - WidgetsBinding.instance.addPostFrameCallback((_) { _handleCasting(); }); }); - // Listen for control visibility changes and change system UI mode accordingly - ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async { - if (showingControls) { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } else { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)); - } + ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) { + final (controls, details) = state; + final mode = !controls || (CurrentPlatform.isIOS && details) + ? SystemUiMode.immersiveSticky + : SystemUiMode.edgeToEdge; + unawaited(SystemChrome.setEnabledSystemUIMode(mode)); }); - // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. - // Issue: https://github.com/flutter/flutter/issues/109037 - // TODO: Add a custom scrum builder once the fix lands on stable return PopScope( - onPopInvokedWithResult: _onPop, + onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(), child: Scaffold( backgroundColor: backgroundColor, appBar: const ViewerTopAppBar(), @@ -705,33 +258,29 @@ class _AssetViewerState extends ConsumerState { child: const DownloadStatusFloatingButton(), ), ), + bottomNavigationBar: const ViewerBottomAppBar(), body: Stack( children: [ - PhotoViewGallery.builder( - gaplessPlayback: true, - loadingBuilder: _placeholderBuilder, - pageController: pageController, - scrollPhysics: CurrentPlatform.isIOS - ? const FastScrollPhysics() // Use bouncing physics for iOS - : const FastClampingScrollPhysics(), // Use heavy physics for Android - itemCount: totalAssets, - onPageChanged: _onPageChanged, - onPageBuild: _onPageBuild, - scaleStateChangedCallback: _onScaleStateChanged, - builder: _assetBuilder, - backgroundDecoration: BoxDecoration(color: backgroundColor), - enablePanAlways: true, + PhotoViewGestureDetectorScope( + axis: Axis.horizontal, + child: PageView.builder( + controller: pageController, + physics: isZoomed + ? const NeverScrollableScrollPhysics() + : CurrentPlatform.isIOS + ? const FastScrollPhysics() + : const FastClampingScrollPhysics(), + itemCount: _totalAssets, + onPageChanged: (index) => _onAssetChanged(index), + itemBuilder: (context, index) => AssetPage(index: index, heroOffset: heroOffset), + ), ), - if (!showingBottomSheet) - const Positioned( - bottom: 0, - left: 0, - right: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [AssetStackRow(), ViewerBottomBar()], + if (!CurrentPlatform.isIOS) + IgnorePointer( + child: AnimatedContainer( + duration: Durations.short2, + color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0), + height: context.padding.top, ), ), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index 36e5bf67d9..dc510d6017 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -3,31 +3,35 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:riverpod_annotation/riverpod_annotation.dart'; class AssetViewerState { - final int backgroundOpacity; - final bool showingBottomSheet; + final double backgroundOpacity; + final bool showingDetails; final bool showingControls; + final bool isZoomed; final BaseAsset? currentAsset; final int stackIndex; const AssetViewerState({ - this.backgroundOpacity = 255, - this.showingBottomSheet = false, + this.backgroundOpacity = 1.0, + this.showingDetails = false, this.showingControls = true, + this.isZoomed = false, this.currentAsset, this.stackIndex = 0, }); AssetViewerState copyWith({ - int? backgroundOpacity, - bool? showingBottomSheet, + double? backgroundOpacity, + bool? showingDetails, bool? showingControls, + bool? isZoomed, BaseAsset? currentAsset, int? stackIndex, }) { return AssetViewerState( backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, - showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet, + showingDetails: showingDetails ?? this.showingDetails, showingControls: showingControls ?? this.showingControls, + isZoomed: isZoomed ?? this.isZoomed, currentAsset: currentAsset ?? this.currentAsset, stackIndex: stackIndex ?? this.stackIndex, ); @@ -35,7 +39,7 @@ class AssetViewerState { @override String toString() { - return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)'; + return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)'; } @override @@ -44,8 +48,9 @@ class AssetViewerState { if (other.runtimeType != runtimeType) return false; return other is AssetViewerState && other.backgroundOpacity == backgroundOpacity && - other.showingBottomSheet == showingBottomSheet && + other.showingDetails == showingDetails && other.showingControls == showingControls && + other.isZoomed == isZoomed && other.currentAsset == currentAsset && other.stackIndex == stackIndex; } @@ -53,8 +58,9 @@ class AssetViewerState { @override int get hashCode => backgroundOpacity.hashCode ^ - showingBottomSheet.hashCode ^ + showingDetails.hashCode ^ showingControls.hashCode ^ + isZoomed.hashCode ^ currentAsset.hashCode ^ stackIndex.hashCode; } @@ -76,18 +82,18 @@ class AssetViewerStateNotifier extends Notifier { state = state.copyWith(currentAsset: asset, stackIndex: 0); } - void setOpacity(int opacity) { + void setOpacity(double opacity) { if (opacity == state.backgroundOpacity) { return; } - state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls); + state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls); } - void setBottomSheet(bool showing) { - if (showing == state.showingBottomSheet) { + void setShowingDetails(bool showing) { + if (showing == state.showingDetails) { return; } - state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls); + state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); if (showing) { ref.read(videoPlayerControlsProvider.notifier).pause(); } @@ -104,6 +110,13 @@ class AssetViewerStateNotifier extends Notifier { state = state.copyWith(showingControls: !state.showingControls); } + void setZoomed(bool isZoomed) { + if (isZoomed == state.isZoomed) { + return; + } + state = state.copyWith(isZoomed: isZoomed); + } + void setStackIndex(int index) { if (index == state.stackIndex) { return; diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 537f2fc31d..93006ab978 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -29,15 +29,9 @@ class ViewerBottomBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; - final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); - if (!showControls) { - opacity = 0; - } - final originalTheme = context.themeData; final actions = [ @@ -56,37 +50,30 @@ class ViewerBottomBar extends ConsumerWidget { ], ]; - return IgnorePointer( - ignoring: opacity < 255, - child: AnimatedOpacity( - opacity: opacity / 255, - duration: Durations.short2, - child: AnimatedSwitcher( - duration: Durations.short4, - child: isSheetOpen - ? const SizedBox.shrink() - : Theme( - data: context.themeData.copyWith( - iconTheme: const IconThemeData(size: 22, color: Colors.white), - textTheme: context.themeData.textTheme.copyWith( - labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white), - ), - ), - child: Container( - color: Colors.black.withAlpha(125), - padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (asset.isVideo) const VideoControls(), - if (!isReadonlyModeEnabled) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), - ], - ), - ), + return AnimatedSwitcher( + duration: Durations.short4, + child: showingDetails + ? const SizedBox.shrink() + : Theme( + data: context.themeData.copyWith( + iconTheme: const IconThemeData(size: 22, color: Colors.white), + textTheme: context.themeData.textTheme.copyWith( + labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white), ), - ), - ), + ), + child: Container( + color: Colors.black.withAlpha(125), + padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (asset.isVideo) const VideoControls(), + if (!isReadonlyModeEnabled) + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ], + ), + ), + ), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart deleted file mode 100644 index 2e10e6856b..0000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/exif.model.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/duration_extensions.dart'; -import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; -import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/repositories/asset_media.repository.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/bytes_units.dart'; -import 'package:immich_mobile/utils/timezone.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; - -const _kSeparator = ' • '; - -class AssetDetailBottomSheet extends ConsumerWidget { - final DraggableScrollableController? controller; - final double initialChildSize; - - const AssetDetailBottomSheet({this.controller, this.initialChildSize = 0.35, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } - - return BaseBottomSheet( - actions: [], - slivers: const [_AssetDetailBottomSheet()], - controller: controller, - initialChildSize: initialChildSize, - minChildSize: 0.1, - maxChildSize: 0.88, - expand: false, - shouldCloseOnMinExtent: false, - resizeOnScroll: false, - backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white, - ); - } -} - -class _AssetDetailBottomSheet extends ConsumerWidget { - const _AssetDetailBottomSheet(); - - String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { - DateTime dateTime = asset.createdAt.toLocal(); - Duration timeZoneOffset = dateTime.timeZoneOffset; - - // Use EXIF timezone information if available (matching web app behavior) - if (exifInfo?.dateTimeOriginal != null) { - (dateTime, timeZoneOffset) = applyTimezoneOffset( - dateTime: exifInfo!.dateTimeOriginal!, - timeZone: exifInfo.timeZone, - ); - } - - final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); - final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); - final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; - return '$date$_kSeparator$time $timezone'; - } - - String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { - final height = asset.height; - final width = asset.width; - final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null; - final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; - - return switch ((fileSize, resolution)) { - (null, null) => '', - (String fileSize, null) => fileSize, - (null, String resolution) => resolution, - (String fileSize, String resolution) => '$fileSize$_kSeparator$resolution', - }; - } - - String? _getCameraInfoTitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - - return switch ((exifInfo.make, exifInfo.model)) { - (null, null) => null, - (String make, null) => make, - (null, String model) => model, - (String make, String model) => '$make $model', - }; - } - - String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; - final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; - return [exposureTime, 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 { - await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context); - } - - Widget _buildAppearsInList(WidgetRef ref, BuildContext context) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } - - if (!asset.hasRemote) { - return const SizedBox.shrink(); - } - - String? remoteAssetId; - if (asset is RemoteAsset) { - remoteAssetId = asset.id; - } else if (asset is LocalAsset) { - remoteAssetId = asset.remoteAssetId; - } - - if (remoteAssetId == null) { - return const SizedBox.shrink(); - } - - final userId = ref.watch(currentUserProvider)?.id; - final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId)); - - return assetAlbums.when( - data: (albums) { - if (albums.isEmpty) { - return const SizedBox.shrink(); - } - - albums.sortBy((a) => a.name); - - return Column( - spacing: 12, - children: [ - if (albums.isNotEmpty) - SheetTile( - title: 'appears_in'.t(context: context), - titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - Padding( - padding: const EdgeInsets.only(left: 24), - child: Column( - spacing: 12, - children: albums.map((album) { - final isOwner = album.ownerId == userId; - return AlbumTile( - album: album, - isOwner: isOwner, - onAlbumSelected: (album) async { - ref.invalidate(assetViewerProvider); - unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); - }, - ); - }).toList(), - ), - ), - ], - ); - }, - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - - 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); - final isRatingEnabled = ref - .watch(userMetadataPreferencesProvider) - .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); - - // Build file info tile based on asset type - Widget buildFileInfoTile() { - if (asset is LocalAsset) { - final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); - return FutureBuilder( - future: assetMediaRepository.getOriginalFilename(asset.id), - builder: (context, snapshot) { - final displayName = snapshot.data ?? asset.name; - return SheetTile( - title: displayName, - titleStyle: context.textTheme.labelLarge, - leading: Icon( - asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, - size: 24, - color: context.textTheme.labelLarge?.color, - ), - subtitle: _getFileInfo(asset, exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ); - }, - ); - } else { - // For remote assets, use the name directly - return SheetTile( - title: asset.name, - titleStyle: context.textTheme.labelLarge, - leading: Icon( - asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, - size: 24, - color: context.textTheme.labelLarge?.color, - ), - subtitle: _getFileInfo(asset, exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ); - } - } - - return SliverList.list( - children: [ - // Asset Date and Time - SheetTile( - title: _getDateTime(context, asset, exifInfo), - titleStyle: context.textTheme.labelLarge, - trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, - onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null, - ), - if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner), - const SheetPeopleDetails(), - const SheetLocationDetails(), - // Details header - SheetTile( - title: 'details'.t(context: context), - titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - // File info - buildFileInfoTile(), - // Camera info - if (cameraTitle != null) ...[ - const SizedBox(height: 16), - SheetTile( - title: cameraTitle, - titleStyle: context.textTheme.labelLarge, - leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), - subtitle: _getCameraInfoSubtitle(exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - ], - // Lens info - if (lensTitle != null) ...[ - const SizedBox(height: 16), - 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.colorScheme.onSurfaceSecondary), - ), - ], - // Rating bar - if (isRatingEnabled) ...[ - Padding( - padding: const EdgeInsets.only(left: 16.0, top: 16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text( - 'rating'.t(context: context), - style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - RatingBar( - initialRating: exifInfo?.rating?.toDouble() ?? 0, - filledColor: context.themeData.colorScheme.primary, - unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), - itemSize: 40, - onRatingUpdate: (rating) async { - await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); - }, - onClearRating: () async { - await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); - }, - ), - ], - ), - ), - ], - // Appears in (Albums) - Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)), - // padding at the bottom to avoid cut-off - const SizedBox(height: 60), - ], - ); - } -} - -class _SheetAssetDescription extends ConsumerStatefulWidget { - final ExifInfo exif; - final bool isEditable; - - const _SheetAssetDescription({required this.exif, this.isEditable = true}); - - @override - ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); -} - -class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> { - late TextEditingController _controller; - final _descriptionFocus = FocusNode(); - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.exif.description ?? ''); - } - - Future saveDescription(String? previousDescription) async { - final newDescription = _controller.text.trim(); - - if (newDescription == previousDescription) { - _descriptionFocus.unfocus(); - return; - } - - final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription); - - if (!editAction.success) { - _controller.text = previousDescription ?? ''; - - ImmichToast.show( - context: context, - msg: 'exif_bottom_sheet_description_error'.t(context: context), - toastType: ToastType.error, - ); - } - - _descriptionFocus.unfocus(); - } - - @override - Widget build(BuildContext context) { - // Watch the current asset EXIF provider to get updates - final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - - // Update controller text when EXIF data changes - final currentDescription = currentExifInfo?.description ?? ''; - final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( - context: context, - ); - if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { - _controller.text = currentDescription; - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), - child: IgnorePointer( - ignoring: !widget.isEditable, - child: TextField( - controller: _controller, - keyboardType: TextInputType.multiline, - focusNode: _descriptionFocus, - maxLines: null, // makes it grow as text is added - decoration: InputDecoration( - hintText: hintText, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - ), - onTapOutside: (_) => saveDescription(currentExifInfo?.description), - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 538a9bde20..643d3e87ef 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -205,7 +205,7 @@ class NativeVideoViewer extends HookConsumerWidget { final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) { + if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) { return; } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart index c1324b8ac0..a2c1372c83 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; @@ -19,8 +19,8 @@ class VideoViewerControls extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo)); bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final showBottomSheet = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - if (showBottomSheet) { + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + if (showingDetails) { showControls = false; } final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart new file mode 100644 index 0000000000..aa3b8bb93f --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; + +class ViewerBottomAppBar extends ConsumerWidget { + const ViewerBottomAppBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); + final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + + if (!showControls) { + opacity = 0.0; + } + + return IgnorePointer( + ignoring: opacity < 1.0, + child: AnimatedOpacity( + opacity: opacity, + duration: Durations.short2, + child: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [AssetStackRow(), ViewerBottomBar()], + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 10f3595d01..fb25e9e1cb 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart similarity index 80% rename from mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 193cf60220..4b748abc27 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -3,16 +3,15 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/events.model.dart'; -import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.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/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -35,8 +34,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isInLockedView = ref.watch(inLockedViewProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); - final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); - int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); + final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) { @@ -44,7 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { } if (!showControls) { - opacity = 0; + opacity = 0.0; } final originalTheme = context.themeData; @@ -55,7 +54,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { IconButton( icon: const Icon(Icons.chat_outlined), onPressed: () { - EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); + context.router.push( + DriftActivitiesRoute( + album: album, + assetId: asset is RemoteAsset ? asset.id : null, + assetName: asset.name, + ), + ); }, ), @@ -70,17 +75,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final lockedViewActions = [ViewerKebabMenu(originalTheme: originalTheme)]; return IgnorePointer( - ignoring: opacity < 255, + ignoring: opacity < 1.0, child: AnimatedOpacity( - opacity: opacity / 255, + opacity: opacity, duration: Durations.short2, child: AppBar( - backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125), + backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5), leading: const _AppBarBackButton(), iconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), shape: const Border(), - actions: isShowingSheet || isReadonlyModeEnabled + actions: showingDetails || isReadonlyModeEnabled ? null : isInLockedView ? lockedViewActions @@ -99,9 +104,9 @@ class _AppBarBackButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); - final backgroundColor = isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black; - final foregroundColor = isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white; + final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black; + final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white; return Padding( padding: const EdgeInsets.only(left: 12.0), @@ -112,7 +117,7 @@ class _AppBarBackButton extends ConsumerWidget { iconSize: 22, iconColor: foregroundColor, padding: EdgeInsets.zero, - elevation: isShowingSheet ? 4 : 0, + elevation: showingDetails ? 4 : 0, ), onPressed: context.maybePop, child: const Icon(Icons.arrow_back_rounded), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 75f40ca290..f6d05277ab 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; diff --git a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart similarity index 85% rename from mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart rename to mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart index 1956170c1e..5718333759 100644 --- a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart @@ -31,6 +31,18 @@ class CurrentAssetNotifier extends AutoDisposeNotifier { } } +class ScopedAssetNotifier extends CurrentAssetNotifier { + final BaseAsset _asset; + + ScopedAssetNotifier(this._asset); + + @override + BaseAsset? build() { + setAsset(_asset); + return _asset; + } +} + final currentAssetExifProvider = FutureProvider.autoDispose((ref) { final currentAsset = ref.watch(currentAssetNotifier); if (currentAsset == null) { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b287d73114..5fd8d2be85 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -753,10 +753,17 @@ class DriftActivitiesRoute extends PageRouteInfo { DriftActivitiesRoute({ Key? key, required RemoteAlbum album, + String? assetId, + String? assetName, List? children, }) : super( DriftActivitiesRoute.name, - args: DriftActivitiesRouteArgs(key: key, album: album), + args: DriftActivitiesRouteArgs( + key: key, + album: album, + assetId: assetId, + assetName: assetName, + ), initialChildren: children, ); @@ -766,21 +773,35 @@ class DriftActivitiesRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return DriftActivitiesPage(key: args.key, album: args.album); + return DriftActivitiesPage( + key: args.key, + album: args.album, + assetId: args.assetId, + assetName: args.assetName, + ); }, ); } class DriftActivitiesRouteArgs { - const DriftActivitiesRouteArgs({this.key, required this.album}); + const DriftActivitiesRouteArgs({ + this.key, + required this.album, + this.assetId, + this.assetName, + }); final Key? key; final RemoteAlbum album; + final String? assetId; + + final String? assetName; + @override String toString() { - return 'DriftActivitiesRouteArgs{key: $key, album: $album}'; + return 'DriftActivitiesRouteArgs{key: $key, album: $album, assetId: $assetId, assetName: $assetName}'; } } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 1a2883bee7..dccb765760 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -225,7 +225,7 @@ enum ActionButtonType { iconData: Icons.info_outline, iconColor: context.originalTheme?.iconTheme.color, menuItem: true, - onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), + onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()), ), ActionButtonType.viewInTimeline => BaseActionButton( label: 'view_in_timeline'.tr(), diff --git a/mobile/lib/widgets/map/asset_market_icon.dart b/mobile/lib/widgets/map/asset_market_icon.dart new file mode 100644 index 0000000000..ff6058161b --- /dev/null +++ b/mobile/lib/widgets/map/asset_market_icon.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +class AssetMarkerIcon extends StatelessWidget { + const AssetMarkerIcon({required this.id, required this.thumbhash, super.key}); + + final String id; + final String thumbhash; + + @override + Widget build(BuildContext context) { + final imageUrl = getThumbnailUrlForRemoteId(id); + return LayoutBuilder( + builder: (context, constraints) { + final pinHeight = constraints.maxHeight * 0.14; + final pinWidth = constraints.maxWidth * 0.14; + return SizedOverflowBox( + size: Size(pinWidth, pinHeight), + child: Stack( + // alignment: AlignmentGeometry.center, + children: [ + Positioned( + bottom: 0, + left: constraints.maxWidth * 0.5, + child: CustomPaint( + painter: _PinPainter( + primaryColor: context.colorScheme.onSurface, + secondaryColor: context.colorScheme.surface, + primaryRadius: constraints.maxHeight * 0.06, + secondaryRadius: constraints.maxHeight * 0.038, + ), + child: SizedBox(height: pinHeight, width: pinWidth), + ), + ), + Positioned( + top: constraints.maxHeight * 0.07, + left: constraints.maxWidth * 0.17, + child: CircleAvatar( + radius: constraints.maxHeight * 0.40, + backgroundColor: context.colorScheme.onSurface, + child: CircleAvatar( + radius: constraints.maxHeight * 0.37, + backgroundImage: RemoteImageProvider(url: imageUrl), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _PinPainter extends CustomPainter { + final Color primaryColor; + final Color secondaryColor; + final double primaryRadius; + final double secondaryRadius; + + const _PinPainter({ + required this.primaryColor, + required this.secondaryColor, + required this.primaryRadius, + required this.secondaryRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + Paint primaryBrush = Paint() + ..color = primaryColor + ..style = PaintingStyle.fill; + + Paint secondaryBrush = Paint() + ..color = secondaryColor + ..style = PaintingStyle.fill; + + Paint lineBrush = Paint() + ..color = primaryColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush); + canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush); + canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush); + // The line is to make the above triangluar path more prominent since it has a slight curve + canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush); + } + + Path getTrianglePath(double x, double y) { + final firstEndPoint = Offset(x / 2, y); + final controlPoint = Offset(x / 2, y * 0.3); + final secondEndPoint = Offset(x, 0); + + return Path() + ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy) + ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy) + ..lineTo(0, 0); + } + + @override + bool shouldRepaint(_PinPainter old) { + return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor; + } +} diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index 32d90a28d9..e0ab1cfd04 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; -import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; +import 'package:immich_mobile/widgets/map/asset_market_icon.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; /// A non-interactive thumbnail of a map in the given coordinates with optional markers @@ -45,21 +45,12 @@ class MapThumbnail extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude); final controller = useRef(null); final styleLoaded = useState(false); - final position = useValueNotifier?>(null); Future onMapCreated(MapLibreMapController mapController) async { controller.value = mapController; styleLoaded.value = false; - if (assetMarkerRemoteId != null) { - // The iOS impl returns wrong toScreenLocation without the delay - Future.delayed( - const Duration(milliseconds: 100), - () async => position.value = await mapController.toScreenLocation(centre), - ); - } onCreated?.call(mapController); } @@ -90,11 +81,11 @@ class MapThumbnail extends HookConsumerWidget { child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(15)), child: Stack( - alignment: Alignment.center, + alignment: AlignmentGeometry.topCenter, children: [ style.widgetWhen( onData: (style) => MapLibreMap( - initialCameraPosition: CameraPosition(target: offsettedCentre, zoom: zoom), + initialCameraPosition: CameraPosition(target: centre, zoom: zoom), styleString: style, onMapCreated: onMapCreated, onStyleLoadedCallback: onStyleLoaded, @@ -109,17 +100,16 @@ class MapThumbnail extends HookConsumerWidget { attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null, ), ), - ValueListenableBuilder( - valueListenable: position, - builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null - ? PositionedAssetMarkerIcon( - size: height / 2, - point: value, - assetRemoteId: assetMarkerRemoteId!, - assetThumbhash: assetThumbhash!, - ) - : const SizedBox.shrink(), - ), + if (assetMarkerRemoteId != null && assetThumbhash != null) + Container( + width: width, + height: height / 2, + alignment: Alignment.bottomCenter, + child: SizedBox.square( + dimension: height / 2.5, + child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!), + ), + ), ], ), ), diff --git a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart index 95b127f5b7..41d49abf1a 100644 --- a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart +++ b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart @@ -3,8 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/map/asset_market_icon.dart'; class PositionedAssetMarkerIcon extends StatelessWidget { final Point point; @@ -36,106 +35,9 @@ class PositionedAssetMarkerIcon extends StatelessWidget { onTap: () => onTap?.call(), child: SizedBox.square( dimension: size, - child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), + child: AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), ), ), ); } } - -class _AssetMarkerIcon extends StatelessWidget { - const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key}); - - final String id; - final String thumbhash; - - @override - Widget build(BuildContext context) { - final imageUrl = getThumbnailUrlForRemoteId(id); - return LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - Positioned( - bottom: 0, - left: constraints.maxWidth * 0.5, - child: CustomPaint( - painter: _PinPainter( - primaryColor: context.colorScheme.onSurface, - secondaryColor: context.colorScheme.surface, - primaryRadius: constraints.maxHeight * 0.06, - secondaryRadius: constraints.maxHeight * 0.038, - ), - child: SizedBox(height: constraints.maxHeight * 0.14, width: constraints.maxWidth * 0.14), - ), - ), - Positioned( - top: constraints.maxHeight * 0.07, - left: constraints.maxWidth * 0.17, - child: CircleAvatar( - radius: constraints.maxHeight * 0.40, - backgroundColor: context.colorScheme.onSurface, - child: CircleAvatar( - radius: constraints.maxHeight * 0.37, - backgroundImage: RemoteImageProvider(url: imageUrl), - ), - ), - ), - ], - ); - }, - ); - } -} - -class _PinPainter extends CustomPainter { - final Color primaryColor; - final Color secondaryColor; - final double primaryRadius; - final double secondaryRadius; - - const _PinPainter({ - required this.primaryColor, - required this.secondaryColor, - required this.primaryRadius, - required this.secondaryRadius, - }); - - @override - void paint(Canvas canvas, Size size) { - Paint primaryBrush = Paint() - ..color = primaryColor - ..style = PaintingStyle.fill; - - Paint secondaryBrush = Paint() - ..color = secondaryColor - ..style = PaintingStyle.fill; - - Paint lineBrush = Paint() - ..color = primaryColor - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - - canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush); - canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush); - canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush); - // The line is to make the above triangluar path more prominent since it has a slight curve - canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush); - } - - Path getTrianglePath(double x, double y) { - final firstEndPoint = Offset(x / 2, y); - final controlPoint = Offset(x / 2, y * 0.3); - final secondEndPoint = Offset(x, 0); - - return Path() - ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy) - ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy) - ..lineTo(0, 0); - } - - @override - bool shouldRepaint(_PinPainter old) { - return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor; - } -} diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index 69be96ed53..f9d3c66767 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -257,6 +257,7 @@ class PhotoView extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.customSize, @@ -299,6 +300,7 @@ class PhotoView extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.customSize, @@ -417,6 +419,9 @@ class PhotoView extends StatefulWidget { /// location. final PhotoViewImageDragUpdateCallback? onDragUpdate; + /// A callback when a drag gesture is canceled by the system. + final VoidCallback? onDragCancel; + /// A pointer that will trigger a scale has stopped contacting the screen at a /// particular location. final PhotoViewImageScaleEndCallback? onScaleEnd; @@ -543,7 +548,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final computedOuterSize = widget.customSize ?? constraints.biggest; - final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.black); + final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.transparent); return widget._isCustomChild ? CustomChildWrapper( @@ -564,6 +569,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: computedOuterSize, @@ -596,6 +602,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: computedOuterSize, diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index af5b9a7ce7..aa33d18403 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -284,6 +284,7 @@ class _PhotoViewGalleryState extends State { onDragStart: pageOption.onDragStart, onDragEnd: pageOption.onDragEnd, onDragUpdate: pageOption.onDragUpdate, + onDragCancel: pageOption.onDragCancel, onScaleEnd: pageOption.onScaleEnd, onLongPressStart: pageOption.onLongPressStart, gestureDetectorBehavior: pageOption.gestureDetectorBehavior, @@ -321,6 +322,7 @@ class _PhotoViewGalleryState extends State { onDragStart: pageOption.onDragStart, onDragEnd: pageOption.onDragEnd, onDragUpdate: pageOption.onDragUpdate, + onDragCancel: pageOption.onDragCancel, onScaleEnd: pageOption.onScaleEnd, onLongPressStart: pageOption.onLongPressStart, gestureDetectorBehavior: pageOption.gestureDetectorBehavior, @@ -367,6 +369,7 @@ class PhotoViewGalleryPageOptions { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -397,6 +400,7 @@ class PhotoViewGalleryPageOptions { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -454,9 +458,12 @@ class PhotoViewGalleryPageOptions { /// Mirror to [PhotoView.onDragDown] final PhotoViewImageDragEndCallback? onDragEnd; - /// Mirror to [PhotoView.onDraUpdate] + /// Mirror to [PhotoView.onDragUpdate] final PhotoViewImageDragUpdateCallback? onDragUpdate; + /// Mirror to [PhotoView.onDragCancel] + final VoidCallback? onDragCancel; + /// Mirror to [PhotoView.onTapDown] final PhotoViewImageTapDownCallback? onTapDown; diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index d21b49f020..72c4766c45 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -36,6 +36,7 @@ class PhotoViewCore extends StatefulWidget { required this.onDragStart, required this.onDragEnd, required this.onDragUpdate, + required this.onDragCancel, required this.onScaleEnd, required this.onLongPressStart, required this.gestureDetectorBehavior, @@ -62,6 +63,7 @@ class PhotoViewCore extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -100,6 +102,7 @@ class PhotoViewCore extends StatefulWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageLongPressStartCallback? onLongPressStart; @@ -386,6 +389,7 @@ class PhotoViewCoreState extends State onDragUpdate: widget.onDragUpdate != null ? (details) => widget.onDragUpdate!(context, details, widget.controller.value) : null, + onDragCancel: widget.onDragCancel, hitDetector: this, onTapUp: widget.onTapUp != null ? (details) => widget.onTapUp!(context, details, value) : null, onTapDown: widget.onTapDown != null ? (details) => widget.onTapDown!(context, details, value) : null, diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart index 0d2f6fa457..6cbcec8d82 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart @@ -16,6 +16,7 @@ class PhotoViewGestureDetector extends StatelessWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onLongPressStart, this.child, this.onTapUp, @@ -34,6 +35,7 @@ class PhotoViewGestureDetector extends StatelessWidget { final GestureDragEndCallback? onDragEnd; final GestureDragStartCallback? onDragStart; final GestureDragUpdateCallback? onDragUpdate; + final GestureDragCancelCallback? onDragCancel; final GestureTapUpCallback? onTapUp; final GestureTapDownCallback? onTapDown; @@ -73,7 +75,8 @@ class PhotoViewGestureDetector extends StatelessWidget { instance ..onStart = onDragStart ..onUpdate = onDragUpdate - ..onEnd = onDragEnd; + ..onEnd = onDragEnd + ..onCancel = onDragCancel; }, ); } diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index cd70745703..ee18668f52 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -28,6 +28,7 @@ class ImageWrapper extends StatefulWidget { required this.onDragStart, required this.onDragEnd, required this.onDragUpdate, + required this.onDragCancel, required this.onScaleEnd, required this.onLongPressStart, required this.outerSize, @@ -62,6 +63,7 @@ class ImageWrapper extends StatefulWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageLongPressStartCallback? onLongPressStart; final Size outerSize; @@ -203,6 +205,7 @@ class _ImageWrapperState extends State { onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: widget.outerSize, @@ -233,6 +236,7 @@ class _ImageWrapperState extends State { onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, gestureDetectorBehavior: widget.gestureDetectorBehavior, @@ -281,6 +285,7 @@ class CustomChildWrapper extends StatelessWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, required this.outerSize, @@ -313,6 +318,7 @@ class CustomChildWrapper extends StatelessWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageLongPressStartCallback? onLongPressStart; final Size outerSize; @@ -348,6 +354,7 @@ class CustomChildWrapper extends StatelessWidget { onDragStart: onDragStart, onDragEnd: onDragEnd, onDragUpdate: onDragUpdate, + onDragCancel: onDragCancel, onScaleEnd: onScaleEnd, onLongPressStart: onLongPressStart, gestureDetectorBehavior: gestureDetectorBehavior, From 3f41916ad752e73fbb9440d5ff32c284c9183c5b Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:53:44 +0000 Subject: [PATCH 031/143] chore(mobile): fix asset marker icon file name (#26290) --- .../map/{asset_market_icon.dart => asset_marker_icon.dart} | 0 mobile/lib/widgets/map/map_thumbnail.dart | 2 +- mobile/lib/widgets/map/positioned_asset_marker_icon.dart | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename mobile/lib/widgets/map/{asset_market_icon.dart => asset_marker_icon.dart} (100%) diff --git a/mobile/lib/widgets/map/asset_market_icon.dart b/mobile/lib/widgets/map/asset_marker_icon.dart similarity index 100% rename from mobile/lib/widgets/map/asset_market_icon.dart rename to mobile/lib/widgets/map/asset_marker_icon.dart diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index e0ab1cfd04..7defb52264 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; -import 'package:immich_mobile/widgets/map/asset_market_icon.dart'; +import 'package:immich_mobile/widgets/map/asset_marker_icon.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; /// A non-interactive thumbnail of a map in the given coordinates with optional markers diff --git a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart index 41d49abf1a..b6d7241cf4 100644 --- a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart +++ b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/widgets/map/asset_market_icon.dart'; +import 'package:immich_mobile/widgets/map/asset_marker_icon.dart'; class PositionedAssetMarkerIcon extends StatelessWidget { final Point point; From 8f9ea6a17189dc093c7873f139442df1e5ce4ce9 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:59:52 +0100 Subject: [PATCH 032/143] fix: utc time zone upserts (#26258) fix: utc timezone upserts --- server/src/services/metadata.service.spec.ts | 6 +- server/src/services/metadata.service.ts | 15 ++++- .../specs/services/asset.service.spec.ts | 58 ++++++++++++++++++- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 8530f6fed2..7424c2154f 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -919,7 +919,7 @@ describe(MetadataService.name, () => { Orientation: 0, ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', - tz: 'UTC-11:30', + zone: 'UTC-11:30', TagsList: ['parent/child'], Rating: 3, }; @@ -955,7 +955,7 @@ describe(MetadataService.name, () => { orientation: tags.Orientation?.toString(), profileDescription: tags.ProfileDescription, projectionType: 'EQUIRECTANGULAR', - timeZone: tags.tz, + timeZone: tags.zone, rating: tags.Rating, country: null, state: null, @@ -987,7 +987,7 @@ describe(MetadataService.name, () => { const tags: ImmichTags = { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), - tz: undefined, + zone: undefined, }; mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(tags); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4113025914..8b9db5b376 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -527,6 +527,15 @@ export class MetadataService extends BaseService { for (const tag of EXIF_DATE_TAGS) { delete mediaTags[tag]; } + + // exiftool-vendored derives tz information from the date. + // if the sidecar file has date information, we also assume the tz information come from there. + // + // this is especially important in the case of UTC+0 where exiftool-vendored does not return tz/zone fields + // and as such the tags aren't overwritten when returning all tags. + for (const tag of ['zone', 'tz', 'tzSource'] as const) { + delete mediaTags[tag]; + } } } @@ -897,8 +906,8 @@ export class MetadataService extends BaseService { } // timezone - let timeZone = exifTags.tz ?? null; - if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) { + let timeZone = exifTags.zone ?? null; + if (timeZone == null && (dateTime?.rawValue?.endsWith('Z') || dateTime?.rawValue?.endsWith('+00:00'))) { // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly // https://github.com/photostructure/exiftool-vendored.js/issues/203 timeZone = 'UTC+0'; @@ -906,7 +915,7 @@ export class MetadataService extends BaseService { if (timeZone) { this.logger.verbose( - `Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`, + `Found timezone ${timeZone} via ${exifTags.zoneSource} for asset ${asset.id}: ${asset.originalPath}`, ); } else { this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 29e7ea7039..db1b944e1f 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -398,6 +398,23 @@ describe(AssetService.name, () => { }), ); }); + + it('should update dateTimeOriginal with time zone UTC+0', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test', timeZone: 'UTC-7' }); + + await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000Z' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }), + }), + ); + }); }); describe('updateAll', () => { @@ -456,7 +473,7 @@ describe(AssetService.name, () => { ); }); - it('should relatively update an assets with timezone', async () => { + it('should relatively update assets with timezone', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); const { user } = await ctx.newUser(); @@ -477,7 +494,7 @@ describe(AssetService.name, () => { ); }); - it('should relatively update an assets and set a timezone', async () => { + it('should relatively update assets and set a timezone', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); const { user } = await ctx.newUser(); @@ -497,6 +514,26 @@ describe(AssetService.name, () => { ); }); + it('should set asset time zones to UTC', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00', timeZone: 'UTC-7' }); + + await sut.updateAll(auth, { ids: [asset.id], timeZone: 'UTC' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2023-11-19T18:11:00+00:00', + timeZone: 'UTC', + }), + }), + ); + }); + it('should update dateTimeOriginal', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -530,6 +567,23 @@ describe(AssetService.name, () => { }), ); }); + + it('should update dateTimeOriginal with UTC time zone', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test', timeZone: 'UTC-7' }); + + await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000Z' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }), + }), + ); + }); }); describe('upsertBulkMetadata', () => { From 5adb75c272f494c2c3830f356f61e2afe59b63ab Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:05:41 +0100 Subject: [PATCH 033/143] fix(deps): update dependency @mapbox/mapbox-gl-rtl-text to v0.3.0 (#23353) * fix(deps): update dependency @mapbox/mapbox-gl-rtl-text to v0.3.0 * fix: maplibre rtl import --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- pnpm-lock.yaml | 151 ++---------------- web/package.json | 2 +- .../shared-components/map/map.svelte | 2 +- 3 files changed, 15 insertions(+), 140 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71dece4861..a67ee43489 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -744,8 +744,8 @@ importers: specifier: ^0.63.0 version: 0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) '@mapbox/mapbox-gl-rtl-text': - specifier: 0.2.3 - version: 0.2.3(mapbox-gl@1.13.3) + specifier: 0.3.0 + version: 0.3.0 '@mdi/js': specifier: ^7.4.47 version: 7.4.47 @@ -3310,48 +3310,26 @@ packages: resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} hasBin: true - '@mapbox/geojson-types@1.0.2': - resolution: {integrity: sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==} - '@mapbox/jsonlint-lines-primitives@2.0.2': resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} engines: {node: '>= 0.6'} - '@mapbox/mapbox-gl-rtl-text@0.2.3': - resolution: {integrity: sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw==} - peerDependencies: - mapbox-gl: '>=0.32.1 <2.0.0' - - '@mapbox/mapbox-gl-supported@1.5.0': - resolution: {integrity: sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==} - peerDependencies: - mapbox-gl: '>=0.32.1 <2.0.0' + '@mapbox/mapbox-gl-rtl-text@0.3.0': + resolution: {integrity: sha512-OwQplFqAAEYRobrTKm2wiVP+wcpUVlgXXiUMNQ8tcm5gPN5SQRXFADmITdQOaec4LhDhuuFchS7TS8ua8dUl4w==} '@mapbox/node-pre-gyp@1.0.11': resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true - '@mapbox/point-geometry@0.1.0': - resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==} - '@mapbox/point-geometry@1.1.0': resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} - '@mapbox/tiny-sdf@1.2.5': - resolution: {integrity: sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==} - '@mapbox/tiny-sdf@2.0.7': resolution: {integrity: sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==} - '@mapbox/unitbezier@0.0.0': - resolution: {integrity: sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==} - '@mapbox/unitbezier@0.0.1': resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} - '@mapbox/vector-tile@1.3.1': - resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==} - '@mapbox/vector-tile@2.0.4': resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} @@ -6342,9 +6320,6 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - csscolorparser@1.0.3: - resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} - cssdb@8.5.2: resolution: {integrity: sha512-Pmoj9RmD8RIoIzA2EQWO4D4RMeDts0tgAH0VXdlNdxjuBGI3a9wMOIcUwaPNmD4r2qtIa06gqkIf7sECl+cBCg==} @@ -6840,9 +6815,6 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - earcut@2.2.4: - resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} - earcut@3.0.2: resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} @@ -7470,9 +7442,6 @@ packages: resolution: {integrity: sha512-ASgKwEAQQRnyNFHNvpd5uAwstbVYmiTW0Caw3fBb509tNTqXyAAPMyFs5NNihsLZhLxU1j/kjFhkhLWA9djuVg==} hasBin: true - geojson-vt@3.2.1: - resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==} - geojson@0.5.0: resolution: {integrity: sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==} engines: {node: '>= 0.10'} @@ -7601,9 +7570,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - grid-index@1.1.0: - resolution: {integrity: sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==} - gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -8329,9 +8295,6 @@ packages: resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} hasBin: true - kdbush@3.0.0: - resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} - kdbush@4.0.2: resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} @@ -8663,10 +8626,6 @@ packages: resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} engines: {node: ^20.17.0 || >=22.9.0} - mapbox-gl@1.13.3: - resolution: {integrity: sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==} - engines: {node: '>=6.4.0'} - maplibre-gl@5.18.0: resolution: {integrity: sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} @@ -8694,8 +8653,8 @@ packages: engines: {node: '>= 20'} hasBin: true - marked@17.0.1: - resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + marked@17.0.3: + resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==} engines: {node: '>= 20'} hasBin: true @@ -10147,9 +10106,6 @@ packages: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} - potpack@1.0.2: - resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} - potpack@2.1.0: resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} @@ -10301,9 +10257,6 @@ packages: resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} engines: {node: '>=18'} - quickselect@2.0.0: - resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} - quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} @@ -10868,8 +10821,8 @@ packages: resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==} engines: {node: '>=0.12.18'} - simple-icons@16.4.0: - resolution: {integrity: sha512-8CKtCvx1Zq3L0CBsR4RR1MjGCXkXbzdspwl2yCxs8oWkstbzj2+DatRKDee/tuj3Ffd/2CDzwEky9RgG2yggew==} + simple-icons@16.9.0: + resolution: {integrity: sha512-aKst2C7cLkFyaiQ/Crlwxt9xYOpGPk05XuJZ0ZTJNNCzHCKYrGWz2ebJSi5dG8CmTCxUF/BGs6A8uyJn/EQxqw==} engines: {node: '>=0.12.18'} sirv@2.0.4: @@ -11127,9 +11080,6 @@ packages: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} - supercluster@7.1.5: - resolution: {integrity: sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==} - supercluster@8.0.1: resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} @@ -11430,9 +11380,6 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} - tinyqueue@2.0.3: - resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==} - tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} @@ -11956,9 +11903,6 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - vt-pbf@3.1.3: - resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} - w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -14958,7 +14902,7 @@ snapshots: '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.2)': dependencies: front-matter: 4.0.2 - marked: 17.0.1 + marked: 17.0.3 node-emoji: 2.2.0 svelte: 5.50.2 @@ -14969,7 +14913,7 @@ snapshots: '@mdi/js': 7.4.47 bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) luxon: 3.7.2 - simple-icons: 16.4.0 + simple-icons: 16.9.0 svelte: 5.50.2 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 @@ -15280,17 +15224,9 @@ snapshots: get-stream: 6.0.1 minimist: 1.2.8 - '@mapbox/geojson-types@1.0.2': {} - '@mapbox/jsonlint-lines-primitives@2.0.2': {} - '@mapbox/mapbox-gl-rtl-text@0.2.3(mapbox-gl@1.13.3)': - dependencies: - mapbox-gl: 1.13.3 - - '@mapbox/mapbox-gl-supported@1.5.0(mapbox-gl@1.13.3)': - dependencies: - mapbox-gl: 1.13.3 + '@mapbox/mapbox-gl-rtl-text@0.3.0': {} '@mapbox/node-pre-gyp@1.0.11': dependencies: @@ -15323,22 +15259,12 @@ snapshots: - encoding - supports-color - '@mapbox/point-geometry@0.1.0': {} - '@mapbox/point-geometry@1.1.0': {} - '@mapbox/tiny-sdf@1.2.5': {} - '@mapbox/tiny-sdf@2.0.7': {} - '@mapbox/unitbezier@0.0.0': {} - '@mapbox/unitbezier@0.0.1': {} - '@mapbox/vector-tile@1.3.1': - dependencies: - '@mapbox/point-geometry': 0.1.0 - '@mapbox/vector-tile@2.0.4': dependencies: '@mapbox/point-geometry': 1.1.0 @@ -18584,8 +18510,6 @@ snapshots: css.escape@1.5.1: {} - csscolorparser@1.0.3: {} - cssdb@8.5.2: {} cssesc@3.0.0: {} @@ -19128,8 +19052,6 @@ snapshots: duplexer@0.1.2: {} - earcut@2.2.4: {} - earcut@3.0.2: {} eastasianwidth@0.2.0: {} @@ -19985,8 +19907,6 @@ snapshots: pbf: 3.3.0 shapefile: 0.6.6 - geojson-vt@3.2.1: {} - geojson@0.5.0: {} get-caller-file@2.0.5: {} @@ -20136,8 +20056,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - grid-index@1.1.0: {} - gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -21037,8 +20955,6 @@ snapshots: dependencies: commander: 8.3.0 - kdbush@3.0.0: {} - kdbush@4.0.2: {} keygrip@1.1.0: @@ -21330,31 +21246,6 @@ snapshots: transitivePeerDependencies: - supports-color - mapbox-gl@1.13.3: - dependencies: - '@mapbox/geojson-rewind': 0.5.2 - '@mapbox/geojson-types': 1.0.2 - '@mapbox/jsonlint-lines-primitives': 2.0.2 - '@mapbox/mapbox-gl-supported': 1.5.0(mapbox-gl@1.13.3) - '@mapbox/point-geometry': 0.1.0 - '@mapbox/tiny-sdf': 1.2.5 - '@mapbox/unitbezier': 0.0.0 - '@mapbox/vector-tile': 1.3.1 - '@mapbox/whoots-js': 3.1.0 - csscolorparser: 1.0.3 - earcut: 2.2.4 - geojson-vt: 3.2.1 - gl-matrix: 3.4.4 - grid-index: 1.1.0 - murmurhash-js: 1.0.0 - pbf: 3.3.0 - potpack: 1.0.2 - quickselect: 2.0.0 - rw: 1.3.3 - supercluster: 7.1.5 - tinyqueue: 2.0.3 - vt-pbf: 3.1.3 - maplibre-gl@5.18.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 @@ -21394,7 +21285,7 @@ snapshots: marked@16.4.2: {} - marked@17.0.1: {} + marked@17.0.3: {} math-intrinsics@1.1.0: {} @@ -23188,8 +23079,6 @@ snapshots: postgres@3.4.8: {} - potpack@1.0.2: {} - potpack@2.1.0: {} prelude-ls@1.2.1: {} @@ -23349,8 +23238,6 @@ snapshots: quick-lru@7.3.0: {} - quickselect@2.0.0: {} - quickselect@3.0.0: {} railroad-diagrams@1.0.0: {} @@ -24142,7 +24029,7 @@ snapshots: simple-icons@15.22.0: {} - simple-icons@16.4.0: {} + simple-icons@16.9.0: {} sirv@2.0.4: dependencies: @@ -24448,10 +24335,6 @@ snapshots: transitivePeerDependencies: - supports-color - supercluster@7.1.5: - dependencies: - kdbush: 3.0.0 - supercluster@8.0.1: dependencies: kdbush: 4.0.2 @@ -24860,8 +24743,6 @@ snapshots: tinypool@1.1.1: {} - tinyqueue@2.0.3: {} - tinyqueue@3.0.0: {} tinyrainbow@2.0.0: {} @@ -25533,12 +25414,6 @@ snapshots: vscode-uri@3.0.8: {} - vt-pbf@3.1.3: - dependencies: - '@mapbox/point-geometry': 0.1.0 - '@mapbox/vector-tile': 1.3.1 - pbf: 3.3.0 - w3c-keyname@2.2.8: {} w3c-xmlserializer@4.0.0: diff --git a/web/package.json b/web/package.json index 507b01f6bb..65eb7a2396 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", "@immich/ui": "^0.63.0", - "@mapbox/mapbox-gl-rtl-text": "0.2.3", + "@mapbox/mapbox-gl-rtl-text": "0.3.0", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0", diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index f008df4cb8..0b19306d6e 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -1,5 +1,5 @@ -
- +
- - -
{ocrBox.text}
diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index f671aa1b1c..f4ba6868e0 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -2,8 +2,10 @@ import { shortcuts } from '$lib/actions/shortcut'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; + import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; + import { calculateBoundingBoxMatrix, getOcrBoundingBoxesAtSize, type Point } from '$lib/utils/ocr-utils'; import { EquirectangularAdapter, Viewer, @@ -27,6 +29,17 @@ strokeLinejoin: 'round', }; + // Adapted as well as possible from classlist 'border-2 border-blue-500 bg-blue-500/10 hover:border-blue-600 hover:border-3' + const OCR_BOX_SVG_STYLE = { + fill: 'var(--color-blue-500)', + fillOpacity: '0.1', + stroke: 'var(--color-blue-500)', + strokeWidth: '2px', + }; + + const OCR_TOOLTIP_HTML_CLASS = + 'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text'; + type Props = { panorama: string | { source: string }; originalPanorama?: string | { source: string }; @@ -96,6 +109,59 @@ } }); + $effect(() => { + updateOcrBoxes(ocrManager.showOverlay, ocrManager.data); + }); + + /** Use updateOnly=true on zoom, pan, or resize. */ + const updateOcrBoxes = (showOverlay: boolean, ocrData: OcrBoundingBox[], updateOnly = false) => { + if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) { + return; + } + const markersPlugin = viewer.getPlugin(MarkersPlugin); + if (!showOverlay) { + markersPlugin.clearMarkers(); + return; + } + if (!updateOnly) { + markersPlugin.clearMarkers(); + } + + const boxes = getOcrBoundingBoxesAtSize(ocrData, { + width: viewer.state.textureData.panoData.croppedWidth, + height: viewer.state.textureData.panoData.croppedHeight, + }); + + for (const [index, box] of boxes.entries()) { + const points = box.points.map((p) => texturePointToViewerPoint(viewer, p)); + const { matrix, width, height } = calculateBoundingBoxMatrix(points); + + const fontSize = (1.4 * width) / box.text.length; // fits almost all strings within the box, depends on font family + const transform = `matrix3d(${matrix.join(',')})`; + const content = `
${box.text}
`; + + if (updateOnly) { + markersPlugin.updateMarker({ + id: `box_${index}`, + polygonPixels: box.points.map((b) => [b.x, b.y]), + tooltip: { content }, + }); + } else { + markersPlugin.addMarker({ + id: `box_${index}`, + polygonPixels: box.points.map((b) => [b.x, b.y]), + svgStyle: OCR_BOX_SVG_STYLE, + tooltip: { content, trigger: 'click' }, + }); + } + } + }; + + const texturePointToViewerPoint = (viewer: Viewer, point: Point) => { + const spherical = viewer.dataHelper.textureCoordsToSphericalCoords({ textureX: point.x, textureY: point.y }); + return viewer.dataHelper.sphericalCoordsToViewerCoords(spherical); + }; + const onZoom = () => { viewer?.animate({ zoom: assetViewerManager.zoom > 1 ? 50 : 83.3, speed: 250 }); }; @@ -160,7 +226,20 @@ viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true }); } - return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); + const onReadyHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, false); + const updateHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, true); + viewer.addEventListener(events.ReadyEvent.type, onReadyHandler); + viewer.addEventListener(events.PositionUpdatedEvent.type, updateHandler); + viewer.addEventListener(events.SizeUpdatedEvent.type, updateHandler); + viewer.addEventListener(events.ZoomUpdatedEvent.type, updateHandler, { passive: true }); + + return () => { + viewer.removeEventListener(events.ReadyEvent.type, onReadyHandler); + viewer.removeEventListener(events.PositionUpdatedEvent.type, updateHandler); + viewer.removeEventListener(events.SizeUpdatedEvent.type, updateHandler); + viewer.removeEventListener(events.ZoomUpdatedEvent.type, updateHandler); + viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); + }; }); onDestroy(() => { @@ -176,3 +255,25 @@
+ + diff --git a/web/src/lib/utils/ocr-utils.ts b/web/src/lib/utils/ocr-utils.ts index 97364d06f5..01f118a4e5 100644 --- a/web/src/lib/utils/ocr-utils.ts +++ b/web/src/lib/utils/ocr-utils.ts @@ -12,70 +12,58 @@ const getContainedSize = (img: HTMLImageElement): { width: number; height: numbe return { width, height }; }; +export type Point = { + x: number; + y: number; +}; + export interface OcrBox { id: string; - points: { x: number; y: number }[]; + points: Point[]; text: string; confidence: number; } -export interface BoundingBoxDimensions { - minX: number; - maxX: number; - minY: number; - maxY: number; - width: number; - height: number; - centerX: number; - centerY: number; - rotation: number; - skewX: number; - skewY: number; -} - /** - * Calculate bounding box dimensions and properties from OCR points + * Calculate bounding box transform from OCR points. Result matrix can be used as input for css matrix3d. * @param points - Array of 4 corner points of the bounding box - * @returns Dimensions, rotation, and skew values for the bounding box + * @returns 4x4 matrix to transform the div with text onto the polygon defined by the corner points, and size to set on the source div. */ -export const calculateBoundingBoxDimensions = (points: { x: number; y: number }[]): BoundingBoxDimensions => { +export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => { const [topLeft, topRight, bottomRight, bottomLeft] = points; - const minX = Math.min(...points.map(({ x }) => x)); - const maxX = Math.max(...points.map(({ x }) => x)); - const minY = Math.min(...points.map(({ y }) => y)); - const maxY = Math.max(...points.map(({ y }) => y)); - const width = maxX - minX; - const height = maxY - minY; - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - // Calculate rotation angle from the bottom edge (bottomLeft to bottomRight) - const rotation = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x) * (180 / Math.PI); + // Approximate width and height to prevent text distortion as much as possible + const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); + const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight)); + const height = Math.max(distance(topLeft, bottomLeft), distance(topRight, bottomRight)); - // Calculate skew angles to handle perspective distortion - // SkewX: compare left and right edges - const leftEdgeAngle = Math.atan2(bottomLeft.y - topLeft.y, bottomLeft.x - topLeft.x); - const rightEdgeAngle = Math.atan2(bottomRight.y - topRight.y, bottomRight.x - topRight.x); - const skewX = (rightEdgeAngle - leftEdgeAngle) * (180 / Math.PI); + const dx1 = topRight.x - bottomRight.x; + const dx2 = bottomLeft.x - bottomRight.x; + const dx3 = topLeft.x - topRight.x + bottomRight.x - bottomLeft.x; - // SkewY: compare top and bottom edges - const topEdgeAngle = Math.atan2(topRight.y - topLeft.y, topRight.x - topLeft.x); - const bottomEdgeAngle = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x); - const skewY = (bottomEdgeAngle - topEdgeAngle) * (180 / Math.PI); + const dy1 = topRight.y - bottomRight.y; + const dy2 = bottomLeft.y - bottomRight.y; + const dy3 = topLeft.y - topRight.y + bottomRight.y - bottomLeft.y; - return { - minX, - maxX, - minY, - maxY, - width, - height, - centerX, - centerY, - rotation, - skewX, - skewY, - }; + const det = dx1 * dy2 - dx2 * dy1; + const a13 = (dx3 * dy2 - dx2 * dy3) / det; + const a23 = (dx1 * dy3 - dx3 * dy1) / det; + + const a11 = (1 + a13) * topRight.x - topLeft.x; + const a21 = (1 + a23) * bottomLeft.x - topLeft.x; + + const a12 = (1 + a13) * topRight.y - topLeft.y; + const a22 = (1 + a23) * bottomLeft.y - topLeft.y; + + // prettier-ignore + const matrix = [ + a11 / width, a12 / width, 0, a13 / width, + a21 / height, a22 / height, 0, a23 / height, + 0, 0, 1, 0, + topLeft.x, topLeft.y, 0, 1, + ]; + + return { matrix, width, height }; }; /** @@ -87,18 +75,32 @@ export const getOcrBoundingBoxes = ( zoom: ZoomImageWheelState, photoViewer: HTMLImageElement | null, ): OcrBox[] => { - const boxes: OcrBox[] = []; - if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) { - return boxes; + return []; } const clientHeight = photoViewer.clientHeight; const clientWidth = photoViewer.clientWidth; const { width, height } = getContainedSize(photoViewer); - const imageWidth = photoViewer.naturalWidth; - const imageHeight = photoViewer.naturalHeight; + const offset = { + x: ((clientWidth - width) / 2) * zoom.currentZoom + zoom.currentPositionX, + y: ((clientHeight - height) / 2) * zoom.currentZoom + zoom.currentPositionY, + }; + + return getOcrBoundingBoxesAtSize( + ocrData, + { width: width * zoom.currentZoom, height: height * zoom.currentZoom }, + offset, + ); +}; + +export const getOcrBoundingBoxesAtSize = ( + ocrData: OcrBoundingBox[], + targetSize: { width: number; height: number }, + offset?: Point, +) => { + const boxes: OcrBox[] = []; for (const ocr of ocrData) { // Convert normalized coordinates (0-1) to actual pixel positions @@ -109,14 +111,8 @@ export const getOcrBoundingBoxes = ( { x: ocr.x3, y: ocr.y3 }, { x: ocr.x4, y: ocr.y4 }, ].map((point) => ({ - x: - (width / imageWidth) * zoom.currentZoom * point.x * imageWidth + - ((clientWidth - width) / 2) * zoom.currentZoom + - zoom.currentPositionX, - y: - (height / imageHeight) * zoom.currentZoom * point.y * imageHeight + - ((clientHeight - height) / 2) * zoom.currentZoom + - zoom.currentPositionY, + x: targetSize.width * point.x + (offset?.x ?? 0), + y: targetSize.height * point.y + (offset?.y ?? 0), })); boxes.push({ From b3b9834c0040f1fbffb9dc43c9fb90b439158219 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 19 Feb 2026 02:29:13 +0100 Subject: [PATCH 052/143] feat(web): loop chromecast video (#24410) --- web/src/lib/utils/cast/gcast-destination.svelte.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/lib/utils/cast/gcast-destination.svelte.ts b/web/src/lib/utils/cast/gcast-destination.svelte.ts index 8e72c71e0b..d85d1f513b 100644 --- a/web/src/lib/utils/cast/gcast-destination.svelte.ts +++ b/web/src/lib/utils/cast/gcast-destination.svelte.ts @@ -115,12 +115,17 @@ export class GCastDestination implements ICastDestination { // build the authenticated media request and send it to the cast device const authenticatedUrl = `${mediaUrl}&sessionKey=${sessionKey}`; const mediaInfo = new chrome.cast.media.MediaInfo(authenticatedUrl, contentType); - const request = new chrome.cast.media.LoadRequest(mediaInfo); + + // Create a queue with a single item and set it to repeat + const queueItem = new chrome.cast.media.QueueItem(mediaInfo); + const queueLoadRequest = new chrome.cast.media.QueueLoadRequest([queueItem]); + queueLoadRequest.repeatMode = chrome.cast.media.RepeatMode.SINGLE; + const successCallback = this.onMediaDiscovered.bind(this, SESSION_DISCOVERY_CAUSE.LOAD_MEDIA); this.currentUrl = mediaUrl; - return this.session.loadMedia(request, successCallback, this.onError.bind(this)); + return this.session.queueLoad(queueLoadRequest, successCallback, this.onError.bind(this)); } /// From e520fc3b63e49108008d861c25f477528d28f8c4 Mon Sep 17 00:00:00 2001 From: Hao Xi Date: Thu, 19 Feb 2026 01:20:36 -0500 Subject: [PATCH 053/143] fix: include `DROP INDEX` in transaction to prevent missing index on rollback (#25399) * fix: ERR_PNPM_ENOENT error while `make dev` on macOS. * fix: include `DROP INDEX` in transaction to prevent missing index on rollback. * chore: clean up this PR. --- server/src/repositories/database.repository.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 17647d065d..650820b18e 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -248,11 +248,11 @@ export class DatabaseRepository { } const dimSize = await this.getDimensionSize(table); lists ||= this.targetListCount(await this.getRowCount(table)); - await this.db.schema.dropIndex(indexName).ifExists().execute(); - if (table === 'smart_search') { - await this.db.schema.alterTable(table).dropConstraint('dim_size_constraint').ifExists().execute(); - } await this.db.transaction().execute(async (tx) => { + await sql`DROP INDEX IF EXISTS ${sql.raw(indexName)}`.execute(tx); + if (table === 'smart_search') { + await sql`ALTER TABLE ${sql.raw(table)} DROP CONSTRAINT IF EXISTS dim_size_constraint`.execute(tx); + } if (!rows.some((row) => row.columnName === 'embedding')) { this.logger.warn(`Column 'embedding' does not exist in table '${table}', truncating and adding column.`); await sql`TRUNCATE TABLE ${sql.raw(table)}`.execute(tx); From 316f86d25e0300481d30b1aab6f77b9dcc6e4b90 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 07:39:41 +0100 Subject: [PATCH 054/143] feat: add .mxf file support (#24644) * feat: add support for MXF format in media handling * Updated supported formats documentation to include MXF. * Added MXF to valid video extensions in tests. * Registered MXF MIME type in mime-types utility. * fix: enhance MXF handling in mime-types utility * Updated video mime type validation to include 'application/mxf'. * Adjusted asset type determination to recognize MXF as a video container. * chore: clean up --------- Co-authored-by: Jason Rasmussen --- docs/docs/features/supported-formats.md | 1 + server/src/services/asset-media.service.spec.ts | 1 + server/src/utils/mime-types.spec.ts | 5 ++++- server/src/utils/mime-types.ts | 6 +++++- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/docs/features/supported-formats.md b/docs/docs/features/supported-formats.md index 16f1ab0b6b..4c4ac6039a 100644 --- a/docs/docs/features/supported-formats.md +++ b/docs/docs/features/supported-formats.md @@ -38,6 +38,7 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a | `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | | | `MP4` | `.mp4` `.insv` | :white_check_mark: | | | `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | | +| `MXF` | `.mxf` | :white_check_mark: | | | `QUICKTIME` | `.mov` | :white_check_mark: | | | `WEBM` | `.webm` | :white_check_mark: | | | `WMV` | `.wmv` | :white_check_mark: | | diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 84440fd4b6..5fb45690cf 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -110,6 +110,7 @@ const validVideos = [ '.mp4', '.mpg', '.mts', + '.mxf', '.vob', '.webm', '.wmv', diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index c09f3a381b..b0e31afe39 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -76,6 +76,7 @@ describe('mimeTypes', () => { { mimetype: 'image/x-sony-sr2', extension: '.sr2' }, { mimetype: 'image/x-sony-srf', extension: '.srf' }, { mimetype: 'image/x3f', extension: '.x3f' }, + { mimetype: 'application/mxf', extension: '.mxf' }, { mimetype: 'video/3gpp', extension: '.3gp' }, { mimetype: 'video/3gpp', extension: '.3gpp' }, { mimetype: 'video/avi', extension: '.avi' }, @@ -188,7 +189,9 @@ describe('mimeTypes', () => { it('should contain only video mime types', () => { const values = Object.values(mimeTypes.video).flat(); - expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('video/'))); + expect(values).toEqual( + values.filter((mimeType) => mimeType.startsWith('video/') || mimeType === 'application/mxf'), + ); }); for (const [extension, v] of Object.entries(mimeTypes.video)) { diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index f6dca4e103..4e91bbd7f1 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -98,6 +98,7 @@ const video: Record = { '.mpeg': ['video/mpeg'], '.mpg': ['video/mpeg'], '.mts': ['video/mp2t'], + '.mxf': ['application/mxf'], '.vob': ['video/mpeg'], '.webm': ['video/webm'], '.wmv': ['video/x-ms-wmv'], @@ -141,9 +142,12 @@ export const mimeTypes = { const contentType = lookup(filename); if (contentType.startsWith('image/')) { return AssetType.Image; - } else if (contentType.startsWith('video/')) { + } + + if (contentType.startsWith('video/') || contentType === 'application/mxf') { return AssetType.Video; } + return AssetType.Other; }, getSupportedFileExtensions: () => [...Object.keys(image), ...Object.keys(video)], From f965daa8d2d8d7c1581450f5d10c56533bc895d4 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 13:14:26 +0100 Subject: [PATCH 055/143] chore: remove push trigger for check-openapi workflow (#26341) --- .github/workflows/check-openapi.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 20902698f6..eee2c9f488 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -5,11 +5,6 @@ on: paths: - 'open-api/**' - '.github/workflows/check-openapi.yml' - push: - branches: [main] - paths: - - 'open-api/**' - - '.github/workflows/check-openapi.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} From e0bb5f70ec67f60a0daed8b6f4b623aa3999009d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:28:12 +0000 Subject: [PATCH 056/143] fix(deps): update dependency fabric to v7 [security] (#26342) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 247 ++--------------------------------------------- web/package.json | 2 +- 2 files changed, 8 insertions(+), 241 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4719d9752..007acaa828 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -780,8 +780,8 @@ importers: specifier: ^2.6.0 version: 2.6.0 fabric: - specifier: ^6.5.4 - version: 6.9.1 + specifier: ^7.0.0 + version: 7.2.0 geo-coordinates-parser: specifier: ^1.7.4 version: 1.7.4 @@ -4646,10 +4646,6 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} - engines: {node: '>= 10'} - '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -5281,10 +5277,6 @@ packages: peerDependencies: svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 - abab@2.0.6: - resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} - deprecated: Use your platform's native atob() and btoa() methods instead - abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -5304,9 +5296,6 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - acorn-globals@7.0.1: - resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} - acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -6359,16 +6348,6 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - cssom@0.3.8: - resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} - - cssom@0.5.0: - resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} - - cssstyle@2.3.0: - resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} - engines: {node: '>=8'} - cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -6536,10 +6515,6 @@ packages: dagre-d3-es@7.0.13: resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} - data-urls@3.0.2: - resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} - engines: {node: '>=12'} - data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -6778,11 +6753,6 @@ packages: domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - domexception@4.0.0: - resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} - engines: {node: '>=12'} - deprecated: Use your platform's native DOMException instead - domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} @@ -6978,11 +6948,6 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -7199,9 +7164,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - fabric@6.9.1: - resolution: {integrity: sha512-TqG08Xbt4rtlPsXgCjSUcZz/RsyEP57Qo21nCVRkw7zz9nR0co4SLkL9Q/zQh3tC1Yxap6M5jKFHUKV6SgPovg==} - engines: {node: '>=16.20.0'} + fabric@7.2.0: + resolution: {integrity: sha512-XSYmSqSMrlbCg+/j7/uU/PFeZuA5hHRDp7sGbDlMvz/T6BHt2MQSOYtz/AIdr+kmReA1s5jTzHJ8AjHwYUcmfQ==} + engines: {node: '>=20.0.0'} factory.ts@1.4.2: resolution: {integrity: sha512-8x2hqK1+EGkja4Ah8H3nkP7rDUJsBK1N3iFDqzqsaOV114o2IphSdVkFIw9nDHHr37gFFy2NXeN6n10ieqHzZg==} @@ -7690,10 +7655,6 @@ packages: hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} - html-encoding-sniffer@3.0.0: - resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} - engines: {node: '>=12'} - html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -7765,10 +7726,6 @@ packages: http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} - http-proxy-agent@5.0.0: - resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} - engines: {node: '>= 6'} - http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -8207,15 +8164,6 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsdom@20.0.3: - resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} - engines: {node: '>=14'} - peerDependencies: - canvas: 2.11.2 - peerDependenciesMeta: - canvas: - optional: true - jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -10220,9 +10168,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -10246,9 +10191,6 @@ packages: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} - querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -11424,10 +11366,6 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - tough-cookie@4.1.4: - resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} - engines: {node: '>=6'} - tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -11435,10 +11373,6 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tr46@3.0.0: - resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} - engines: {node: '>=12'} - tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -11663,10 +11597,6 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} - universalify@0.2.0: - resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} - engines: {node: '>= 4.0.0'} - universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -11710,9 +11640,6 @@ packages: file-loader: optional: true - url-parse@1.5.10: - resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - url@0.11.4: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} @@ -11909,10 +11836,6 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - w3c-xmlserializer@4.0.0: - resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} - engines: {node: '>=14'} - w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -12010,11 +11933,6 @@ packages: resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} engines: {node: '>=0.8.0'} - whatwg-encoding@2.0.0: - resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} - engines: {node: '>=12'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -12028,10 +11946,6 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - whatwg-url@11.0.0: - resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} - engines: {node: '>=12'} - whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} @@ -12144,10 +12058,6 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true - xml-name-validator@4.0.0: - resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} - engines: {node: '>=12'} - xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -16579,9 +16489,6 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@tootallnate/once@2.0.0': - optional: true - '@trysound/sax@0.2.0': {} '@turf/boolean-point-in-polygon@7.3.2': @@ -17437,9 +17344,6 @@ snapshots: '@zoom-image/core': 0.42.0 svelte: 5.50.2 - abab@2.0.6: - optional: true - abbrev@1.1.1: {} abbrev@4.0.0: {} @@ -17458,12 +17362,6 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-globals@7.0.1: - dependencies: - acorn: 8.15.0 - acorn-walk: 8.3.4 - optional: true - acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -18583,17 +18481,6 @@ snapshots: dependencies: css-tree: 2.2.1 - cssom@0.3.8: - optional: true - - cssom@0.5.0: - optional: true - - cssstyle@2.3.0: - dependencies: - cssom: 0.3.8 - optional: true - cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -18791,13 +18678,6 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.23 - data-urls@3.0.2: - dependencies: - abab: 2.0.6 - whatwg-mimetype: 3.0.0 - whatwg-url: 11.0.0 - optional: true - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -19014,11 +18894,6 @@ snapshots: domelementtype@2.3.0: {} - domexception@4.0.0: - dependencies: - webidl-conversions: 7.0.0 - optional: true - domhandler@4.3.1: dependencies: domelementtype: 2.3.0 @@ -19300,15 +19175,6 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - optional: true - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -19647,10 +19513,10 @@ snapshots: extend@3.0.2: {} - fabric@6.9.1: + fabric@7.2.0: optionalDependencies: canvas: 2.11.2 - jsdom: 20.0.3(canvas@2.11.2) + jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - bufferutil - encoding @@ -20288,11 +20154,6 @@ snapshots: readable-stream: 2.3.8 wbuf: 1.7.3 - html-encoding-sniffer@3.0.0: - dependencies: - whatwg-encoding: 2.0.0 - optional: true - html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -20390,15 +20251,6 @@ snapshots: http-parser-js@0.5.10: {} - http-proxy-agent@5.0.0: - dependencies: - '@tootallnate/once': 2.0.0 - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - optional: true - http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -20799,42 +20651,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@20.0.3(canvas@2.11.2): - dependencies: - abab: 2.0.6 - acorn: 8.15.0 - acorn-globals: 7.0.1 - cssom: 0.5.0 - cssstyle: 2.3.0 - data-urls: 3.0.2 - decimal.js: 10.6.0 - domexception: 4.0.0 - escodegen: 2.1.0 - form-data: 4.0.5 - html-encoding-sniffer: 3.0.0 - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.23 - parse5: 7.3.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 4.1.4 - w3c-xmlserializer: 4.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 2.0.0 - whatwg-mimetype: 3.0.0 - whatwg-url: 11.0.0 - ws: 8.19.0 - xml-name-validator: 4.0.0 - optionalDependencies: - canvas: 2.11.2 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - optional: true - jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)): dependencies: cssstyle: 4.6.0 @@ -23211,11 +23027,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - psl@1.15.0: - dependencies: - punycode: 2.3.1 - optional: true - pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -23239,9 +23050,6 @@ snapshots: dependencies: side-channel: 1.1.0 - querystringify@2.2.0: - optional: true - queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -24788,14 +24596,6 @@ snapshots: totalist@3.0.1: {} - tough-cookie@4.1.4: - dependencies: - psl: 1.15.0 - punycode: 2.3.1 - universalify: 0.2.0 - url-parse: 1.5.10 - optional: true - tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -24803,11 +24603,6 @@ snapshots: tr46@0.0.3: {} - tr46@3.0.0: - dependencies: - punycode: 2.3.1 - optional: true - tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -25029,9 +24824,6 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - universalify@0.2.0: - optional: true - universalify@2.0.1: {} unpipe@1.0.0: {} @@ -25090,12 +24882,6 @@ snapshots: optionalDependencies: file-loader: 6.2.0(webpack@5.104.1) - url-parse@1.5.10: - dependencies: - querystringify: 2.2.0 - requires-port: 1.0.0 - optional: true - url@0.11.4: dependencies: punycode: 1.4.1 @@ -25426,11 +25212,6 @@ snapshots: w3c-keyname@2.2.8: {} - w3c-xmlserializer@4.0.0: - dependencies: - xml-name-validator: 4.0.0 - optional: true - w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -25627,11 +25408,6 @@ snapshots: websocket-extensions@0.1.4: {} - whatwg-encoding@2.0.0: - dependencies: - iconv-lite: 0.6.3 - optional: true - whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -25642,12 +25418,6 @@ snapshots: whatwg-mimetype@4.0.0: optional: true - whatwg-url@11.0.0: - dependencies: - tr46: 3.0.0 - webidl-conversions: 7.0.0 - optional: true - whatwg-url@14.2.0: dependencies: tr46: 5.1.1 @@ -25735,9 +25505,6 @@ snapshots: dependencies: sax: 1.4.3 - xml-name-validator@4.0.0: - optional: true - xml-name-validator@5.0.0: optional: true diff --git a/web/package.json b/web/package.json index 65eb7a2396..53b8ae77db 100644 --- a/web/package.json +++ b/web/package.json @@ -40,7 +40,7 @@ "@zoom-image/core": "^0.42.0", "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", - "fabric": "^6.5.4", + "fabric": "^7.0.0", "geo-coordinates-parser": "^1.7.4", "geojson": "^0.5.0", "handlebars": "^4.7.8", From d0ed76dc37c6512f2258467e128234cb1a512744 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:51:18 +0100 Subject: [PATCH 057/143] refactor: small face tests (#26340) --- server/src/queries/person.repository.sql | 24 +- server/src/repositories/person.repository.ts | 19 +- server/src/services/media.service.spec.ts | 4 +- server/src/services/person.service.spec.ts | 423 ++++++++++--------- server/src/services/person.service.ts | 8 +- server/test/factories/asset-face.factory.ts | 12 +- server/test/factories/asset.factory.ts | 2 +- server/test/fixtures/face.stub.ts | 160 ------- server/test/mappers.ts | 29 ++ 9 files changed, 287 insertions(+), 394 deletions(-) delete mode 100644 server/test/fixtures/face.stub.ts diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 59f0f12424..964aaaccee 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -286,19 +286,6 @@ from -- PersonRepository.getFacesByIds select "asset_face".*, - ( - select - to_json(obj) - from - ( - select - "asset".* - from - "asset" - where - "asset"."id" = "asset_face"."assetId" - ) as obj - ) as "asset", ( select to_json(obj) @@ -355,3 +342,14 @@ from "person" where "id" in ($1) + +-- PersonRepository.getForFeatureFaceUpdate +select + "asset_face"."id" +from + "asset_face" + inner join "asset" on "asset"."id" = "asset_face"."assetId" + and "asset"."isOffline" = $1 +where + "asset_face"."assetId" = $2 + and "asset_face"."personId" = $3 diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 85e75483c5..00156a2492 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFace } from 'src/database'; @@ -485,12 +485,6 @@ export class PersonRepository { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select((eb) => - jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as( - 'asset', - ), - ) - .$narrowType<{ asset: NotNull }>() .select(withPerson) .where('asset_face.assetId', 'in', assetIds) .where('asset_face.personId', 'in', personIds) @@ -583,4 +577,15 @@ export class PersonRepository { } }); } + + @GenerateSql({ params: [{ personId: DummyValue.UUID, assetId: DummyValue.UUID }] }) + getForFeatureFaceUpdate({ personId, assetId }: { personId: string; assetId: string }) { + return this.db + .selectFrom('asset_face') + .select('asset_face.id') + .where('asset_face.assetId', '=', assetId) + .where('asset_face.personId', '=', personId) + .innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false)) + .executeTakeFirst(); + } } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index bf2cbc62fa..399eb5d6a0 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -21,8 +21,8 @@ import { } from 'src/enum'; import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; -import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; @@ -108,7 +108,7 @@ describe(MediaService.name, () => { it('should queue all people with missing thumbnail path', async () => { mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()])); mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); - mocks.person.getRandomFace.mockResolvedValueOnce(faceStub.face1); + mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create()); await sut.handleQueueGenerateThumbnails({ force: false }); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 0928b57f97..4b60cd8e7f 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -2,16 +2,19 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; -import { DetectedFaces } from 'src/repositories/machine-learning.repository'; import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; import { ImmichFileResponse } from 'src/utils/file'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { PersonFactory } from 'test/factories/person.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; -import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { factory } from 'test/small.factory'; +import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers'; +import { newDate, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const responseDto: PersonResponseDto = { @@ -27,35 +30,6 @@ const responseDto: PersonResponseDto = { const statistics = { assets: 3 }; -const faceId = 'face-id'; -const face = { - id: faceId, - assetId: 'asset-id', - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageHeight: 500, - imageWidth: 400, -}; -const faceSearch = { faceId, embedding: '[1, 2, 3, 4]' }; -const detectFaceMock: DetectedFaces = { - faces: [ - { - boundingBox: { - x1: face.boundingBoxX1, - y1: face.boundingBoxY1, - x2: face.boundingBoxX2, - y2: face.boundingBoxY2, - }, - embedding: faceSearch.embedding, - score: 0.2, - }, - ], - imageHeight: face.imageHeight, - imageWidth: face.imageWidth, -}; - describe(PersonService.name, () => { let sut: PersonService; let mocks: ServiceMocks; @@ -259,27 +233,25 @@ describe(PersonService.name, () => { }); it("should update a person's thumbnailPath", async () => { + const face = AssetFaceFactory.create(); + const auth = AuthFactory.create(); mocks.person.update.mockResolvedValue(personStub.withName); - mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); + mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect( - sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), - ).resolves.toEqual(responseDto); + await expect(sut.update(auth, 'person-1', { featureFaceAssetId: face.assetId })).resolves.toEqual(responseDto); - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); - expect(mocks.person.getFacesByIds).toHaveBeenCalledWith([ - { - assetId: faceStub.face1.assetId, - personId: 'person-1', - }, - ]); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: face.id }); + expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({ + assetId: face.assetId, + personId: 'person-1', + }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonGenerateThumbnail, data: { id: 'person-1' }, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['person-1'])); }); it('should throw an error when the face feature assetId is invalid', async () => { @@ -319,19 +291,21 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should reassign a face', async () => { + const face = AssetFaceFactory.create(); + const auth = AuthFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); mocks.person.getById.mockResolvedValue(personStub.noName); - mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); + mocks.person.getFacesByIds.mockResolvedValue([face]); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); mocks.person.refreshFaces.mockResolvedValue(); mocks.person.reassignFace.mockResolvedValue(5); mocks.person.update.mockResolvedValue(personStub.noName); await expect( - sut.reassignFaces(authStub.admin, personStub.noName.id, { - data: [{ personId: personStub.withName.id, assetId: faceStub.face1.assetId }], + sut.reassignFaces(auth, personStub.noName.id, { + data: [{ personId: personStub.withName.id, assetId: face.assetId }], }), ).resolves.toBeDefined(); @@ -352,18 +326,20 @@ describe(PersonService.name, () => { describe('getFacesById', () => { it('should get the bounding boxes for an asset', async () => { - const asset = AssetFactory.from({ id: faceStub.face1.assetId }).exif().build(); + const auth = AuthFactory.create(); + const face = AssetFaceFactory.create(); + const asset = AssetFactory.from({ id: face.assetId }).exif().build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); + mocks.person.getFaces.mockResolvedValue([face]); mocks.asset.getById.mockResolvedValue(asset); - await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ - mapFaces(faceStub.primaryFace1, authStub.admin), - ]); + await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([mapFaces(face, auth)]); }); + it('should reject if the user has not access to the asset', async () => { + const face = AssetFaceFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); - mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); - await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf( + mocks.person.getFaces.mockResolvedValue([face]); + await expect(sut.getFacesById(AuthFactory.create(), { id: face.assetId })).rejects.toBeInstanceOf( BadRequestException, ); }); @@ -371,7 +347,7 @@ describe(PersonService.name, () => { describe('createNewFeaturePhoto', () => { it('should change person feature photo', async () => { - mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { @@ -384,38 +360,38 @@ describe(PersonService.name, () => { describe('reassignFacesById', () => { it('should create a new person', async () => { + const face = AssetFaceFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); + mocks.person.getFaceById.mockResolvedValue(face); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getById.mockResolvedValue(personStub.noName); - await expect( - sut.reassignFacesById(authStub.admin, personStub.noName.id, { - id: faceStub.face1.id, - }), - ).resolves.toEqual({ - birthDate: personStub.noName.birthDate, - isHidden: personStub.noName.isHidden, - isFavorite: personStub.noName.isFavorite, - id: personStub.noName.id, - name: personStub.noName.name, - thumbnailPath: personStub.noName.thumbnailPath, - updatedAt: expect.any(Date), - color: personStub.noName.color, - }); + await expect(sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { id: face.id })).resolves.toEqual( + { + birthDate: personStub.noName.birthDate, + isHidden: personStub.noName.isHidden, + isFavorite: personStub.noName.isFavorite, + id: personStub.noName.id, + name: personStub.noName.name, + thumbnailPath: personStub.noName.thumbnailPath, + updatedAt: expect.any(Date), + color: personStub.noName.color, + }, + ); expect(mocks.job.queue).not.toHaveBeenCalledWith(); expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should fail if user has not the correct permissions on the asset', async () => { + const face = AssetFaceFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.person.getFaceById.mockResolvedValue(face); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getById.mockResolvedValue(personStub.noName); await expect( - sut.reassignFacesById(authStub.admin, personStub.noName.id, { - id: faceStub.face1.id, + sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { + id: face.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -513,8 +489,9 @@ describe(PersonService.name, () => { it('should delete existing people and faces if forced', async () => { const asset = AssetFactory.create(); - mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + const face = AssetFaceFactory.from().person().build(); + mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.deleteFaces.mockResolvedValue(); @@ -568,6 +545,7 @@ describe(PersonService.name, () => { }); it('should queue missing assets', async () => { + const face = AssetFaceFactory.create(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -576,7 +554,7 @@ describe(PersonService.name, () => { failed: 0, delayed: 0, }); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); @@ -588,7 +566,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { @@ -598,6 +576,7 @@ describe(PersonService.name, () => { }); it('should queue all assets', async () => { + const face = AssetFaceFactory.create(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -607,7 +586,7 @@ describe(PersonService.name, () => { delayed: 0, }); mocks.person.getAll.mockReturnValue(makeStream()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); @@ -616,7 +595,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { @@ -626,8 +605,9 @@ describe(PersonService.name, () => { }); it('should run nightly if new face has been added since last run', async () => { + const face = AssetFaceFactory.create(); mocks.person.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -637,7 +617,7 @@ describe(PersonService.name, () => { delayed: 0, }); mocks.person.getAll.mockReturnValue(makeStream()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); mocks.person.unassignFaces.mockResolvedValue(); @@ -652,7 +632,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { @@ -666,7 +646,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); mocks.person.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([AssetFaceFactory.create()])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); @@ -680,6 +660,7 @@ describe(PersonService.name, () => { }); it('should delete existing people if forced', async () => { + const face = AssetFaceFactory.from().person().build(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -688,8 +669,8 @@ describe(PersonService.name, () => { failed: 0, delayed: 0, }); - mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.unassignFaces.mockResolvedValue(); @@ -700,7 +681,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); @@ -710,10 +691,6 @@ describe(PersonService.name, () => { }); describe('handleDetectFaces', () => { - beforeEach(() => { - mocks.crypto.randomUUID.mockReturnValue(faceId); - }); - it('should skip if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); @@ -753,85 +730,104 @@ describe(PersonService.name, () => { it('should create a face with no person and queue recognition job', async () => { const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); - mocks.search.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); + const face = AssetFaceFactory.create({ assetId: asset.id }); + mocks.crypto.randomUUID.mockReturnValue(face.id); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); + mocks.search.searchFaces.mockResolvedValue([{ ...face, distance: 0.7 }]); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([{ ...face, assetId: asset.id }], [], [faceSearch]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( + [expect.objectContaining({ id: face.id, assetId: asset.id })], + [], + [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }], + ); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, - { name: JobName.FacialRecognition, data: { id: faceId } }, + { name: JobName.FacialRecognition, data: { id: face.id } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should delete an existing face not among the new detected faces', async () => { - const asset = AssetFactory.from().face(faceStub.primaryFace1).file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [asset.faces[0].id], []); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add new face and delete an existing face not among the new detected faces', async () => { - const asset = AssetFactory.from().face(faceStub.primaryFace1).file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + const assetId = newUuid(); + const face = AssetFaceFactory.create({ + assetId, + boundingBoxX1: 200, + boundingBoxX2: 300, + boundingBoxY1: 200, + boundingBoxY2: 300, + }); + const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).build(); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.crypto.randomUUID.mockReturnValue(face.id); mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: asset.id }); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( - [{ ...face, assetId: asset.id }], - [faceStub.primaryFace1.id], - [faceSearch], + [expect.objectContaining({ id: face.id, assetId: asset.id })], + [asset.faces[0].id], + [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }], ); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, - { name: JobName.FacialRecognition, data: { id: faceId } }, + { name: JobName.FacialRecognition, data: { id: face.id } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add embedding to matching metadata face', async () => { - const asset = AssetFactory.from().face(faceStub.fromExif1).file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + const face = AssetFaceFactory.create({ sourceType: SourceType.Exif }); + const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).build(); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith( - [], - [], - [{ faceId: faceStub.fromExif1.id, embedding: faceSearch.embedding }], - ); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [], [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }]); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should not add embedding to non-matching metadata face', async () => { - const asset = AssetFactory.from().face(faceStub.fromExif2).file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + const assetId = newUuid(); + const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif }); + const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).build(); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.crypto.randomUUID.mockReturnValue(face.id); await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([{ ...face, assetId: asset.id }], [], [faceSearch]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( + [expect.objectContaining({ id: face.id, assetId: asset.id })], + [], + [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }], + ); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, - { name: JobName.FacialRecognition, data: { id: faceId } }, + { name: JobName.FacialRecognition, data: { id: face.id } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); @@ -840,153 +836,172 @@ describe(PersonService.name, () => { describe('handleRecognizeFaces', () => { it('should fail if face does not exist', async () => { - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Failed); + expect(await sut.handleRecognizeFaces({ id: 'unknown-face' })).toBe(JobStatus.Failed); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should fail if face does not have asset', async () => { - const face = { ...faceStub.face1, asset: null }; - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(face); + const face = AssetFaceFactory.create(); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, null)); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Failed); + expect(await sut.handleRecognizeFaces({ id: face.id })).toBe(JobStatus.Failed); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should skip if face already has an assigned person', async () => { - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.face1); + const asset = AssetFactory.create(); + const face = AssetFaceFactory.from({ assetId: asset.id }).person().build(); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset)); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Skipped); + expect(await sut.handleRecognizeFaces({ id: face.id })).toBe(JobStatus.Skipped); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should match existing person', async () => { - if (!faceStub.primaryFace1.person) { - throw new Error('faceStub.primaryFace1.person is null'); - } + const asset = AssetFactory.create(); + + const [noPerson1, noPerson2, primaryFace, face] = [ + AssetFaceFactory.create({ assetId: asset.id }), + AssetFaceFactory.create(), + AssetFaceFactory.from().person().build(), + AssetFaceFactory.from().person().build(), + ]; const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.primaryFace1, distance: 0.2 }, - { ...faceStub.noPerson2, distance: 0.3 }, - { ...faceStub.face1, distance: 0.4 }, + { ...noPerson1, distance: 0 }, + { ...primaryFace, distance: 0.2 }, + { ...noPerson2, distance: 0.3 }, + { ...face, distance: 0.4 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(primaryFace.person!); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.person.create).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.arrayContaining([faceStub.noPerson1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.arrayContaining([noPerson1.id]), + newPersonId: primaryFace.person!.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.not.arrayContaining([faceStub.face1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.not.arrayContaining([face.id]), + newPersonId: primaryFace.person!.id, }); }); it('should match existing person if their birth date is unknown', async () => { - if (!faceStub.primaryFace1.person) { - throw new Error('faceStub.primaryFace1.person is null'); - } + const asset = AssetFactory.create(); + const [noPerson, face, faceWithBirthDate] = [ + AssetFaceFactory.create({ assetId: asset.id }), + AssetFaceFactory.from().person().build(), + AssetFaceFactory.from().person({ birthDate: newDate() }).build(), + ]; const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.primaryFace1, distance: 0.2 }, - { ...faceStub.withBirthDate, distance: 0.3 }, + { ...noPerson, distance: 0 }, + { ...face, distance: 0.2 }, + { ...faceWithBirthDate, distance: 0.3 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.person.create.mockResolvedValue(face.person!); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson.id }); expect(mocks.person.create).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.arrayContaining([faceStub.noPerson1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.arrayContaining([noPerson.id]), + newPersonId: face.person!.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.not.arrayContaining([faceStub.face1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.not.arrayContaining([face.id]), + newPersonId: face.person!.id, }); }); it('should match existing person if their birth date is before file creation', async () => { - if (!faceStub.primaryFace1.person) { - throw new Error('faceStub.primaryFace1.person is null'); - } + const asset = AssetFactory.create(); + const [noPerson, face, faceWithBirthDate] = [ + AssetFaceFactory.create({ assetId: asset.id }), + AssetFaceFactory.from().person().build(), + AssetFaceFactory.from().person({ birthDate: newDate() }).build(), + ]; const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.withBirthDate, distance: 0.2 }, - { ...faceStub.primaryFace1, distance: 0.3 }, + { ...noPerson, distance: 0 }, + { ...faceWithBirthDate, distance: 0.2 }, + { ...face, distance: 0.3 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.person.create.mockResolvedValue(face.person!); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson.id }); expect(mocks.person.create).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.arrayContaining([faceStub.noPerson1.id]), - newPersonId: faceStub.withBirthDate.person?.id, + faceIds: expect.arrayContaining([noPerson.id]), + newPersonId: faceWithBirthDate.person!.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.not.arrayContaining([faceStub.face1.id]), - newPersonId: faceStub.withBirthDate.person?.id, + faceIds: expect.not.arrayContaining([face.id]), + newPersonId: faceWithBirthDate.person!.id, }); }); it('should create a new person if the face is a core point with no person', async () => { + const asset = AssetFactory.create(); + const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; + const person = PersonFactory.create(); + const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.noPerson2, distance: 0.3 }, + { ...noPerson1, distance: 0 }, + { ...noPerson2, distance: 0.3 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(person); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.person.create).toHaveBeenCalledWith({ - ownerId: faceStub.noPerson1.asset.ownerId, - faceAssetId: faceStub.noPerson1.id, + ownerId: asset.ownerId, + faceAssetId: noPerson1.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: [faceStub.noPerson1.id], - newPersonId: personStub.withName.id, + faceIds: [noPerson1.id], + newPersonId: person.id, }); }); it('should not queue face with no matches', async () => { - const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; + const asset = AssetFactory.create(); + const face = AssetFaceFactory.create({ assetId: asset.id }); + const faces = [{ ...face, distance: 0 }] as FaceSearchResult[]; mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset)); + mocks.person.create.mockResolvedValue(PersonFactory.create()); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: face.id }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); @@ -995,21 +1010,24 @@ describe(PersonService.name, () => { }); it('should defer non-core faces to end of queue', async () => { + const asset = AssetFactory.create(); + const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; + const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.noPerson2, distance: 0.4 }, + { ...noPerson1, distance: 0 }, + { ...noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(PersonFactory.create()); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognition, - data: { id: faceStub.noPerson1.id, deferred: true }, + data: { id: noPerson1.id, deferred: true }, }); expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); expect(mocks.person.create).not.toHaveBeenCalled(); @@ -1017,17 +1035,20 @@ describe(PersonService.name, () => { }); it('should not assign person to deferred non-core face with no matching person', async () => { + const asset = AssetFactory.create(); + const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; + const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.noPerson2, distance: 0.4 }, + { ...noPerson1, distance: 0 }, + { ...noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(PersonFactory.create()); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); + await sut.handleRecognizeFaces({ id: noPerson1.id, deferred: true }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.search.searchFaces).toHaveBeenCalledTimes(2); @@ -1152,26 +1173,30 @@ describe(PersonService.name, () => { describe('mapFace', () => { it('should map a face', () => { - const authDto = factory.auth({ user: { id: faceStub.face1.person.ownerId } }); - expect(mapFaces(faceStub.face1, authDto)).toEqual({ - boundingBoxX1: 0, - boundingBoxX2: 1, - boundingBoxY1: 0, - boundingBoxY2: 1, - id: faceStub.face1.id, - imageHeight: 1024, - imageWidth: 1024, + const user = UserFactory.create(); + const auth = AuthFactory.create({ id: user.id }); + const person = PersonFactory.create({ ownerId: user.id }); + const face = AssetFaceFactory.from().person(person).build(); + + expect(mapFaces(face, auth)).toEqual({ + boundingBoxX1: 100, + boundingBoxX2: 200, + boundingBoxY1: 100, + boundingBoxY2: 200, + id: face.id, + imageHeight: 500, + imageWidth: 400, sourceType: SourceType.MachineLearning, - person: mapPerson(personStub.withName), + person: mapPerson(person), }); }); it('should not map person if person is null', () => { - expect(mapFaces({ ...faceStub.face1, person: null }, authStub.user1).person).toBeNull(); + expect(mapFaces(AssetFaceFactory.create(), AuthFactory.create()).person).toBeNull(); }); it('should not map person if person does not match auth user id', () => { - expect(mapFaces(faceStub.face1, authStub.user1).person).toBeNull(); + expect(mapFaces(AssetFaceFactory.from().person().build(), AuthFactory.create()).person).toBeNull(); }); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e63dcedb7d..ea7b3f9e78 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -197,13 +197,9 @@ export class PersonService extends BaseService { let faceId: string | undefined = undefined; if (assetId) { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] }); - const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]); + const face = await this.personRepository.getForFeatureFaceUpdate({ personId: id, assetId }); if (!face) { - throw new BadRequestException('Invalid assetId for feature face'); - } - - if (face.asset.isOffline) { - throw new BadRequestException('An offline asset cannot be used for feature face'); + throw new BadRequestException('Invalid assetId for feature face or asset is offline'); } faceId = face.id; diff --git a/server/test/factories/asset-face.factory.ts b/server/test/factories/asset-face.factory.ts index 899b529766..b2286cad54 100644 --- a/server/test/factories/asset-face.factory.ts +++ b/server/test/factories/asset-face.factory.ts @@ -18,14 +18,14 @@ export class AssetFaceFactory { static from(dto: AssetFaceLike = {}) { return new AssetFaceFactory({ assetId: newUuid(), - boundingBoxX1: 11, - boundingBoxX2: 12, - boundingBoxY1: 21, - boundingBoxY2: 22, + boundingBoxX1: 100, + boundingBoxX2: 200, + boundingBoxY1: 100, + boundingBoxY2: 200, deletedAt: null, id: newUuid(), - imageHeight: 42, - imageWidth: 420, + imageHeight: 500, + imageWidth: 400, isVisible: true, personId: null, sourceType: SourceType.MachineLearning, diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 258e2aff38..4d54ba820b 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -96,7 +96,7 @@ export class AssetFactory { } face(dto: AssetFaceLike = {}, builder?: FactoryBuilder) { - this.#faces.push(build(AssetFaceFactory.from(dto), builder)); + this.#faces.push(build(AssetFaceFactory.from({ assetId: this.value?.id, ...dto }), builder)); return this; } diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts deleted file mode 100644 index e01394e84f..0000000000 --- a/server/test/fixtures/face.stub.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { SourceType } from 'src/enum'; -import { AssetFactory } from 'test/factories/asset.factory'; -import { personStub } from 'test/fixtures/person.stub'; - -export const faceStub = { - face1: Object.freeze({ - id: 'assetFaceId1', - assetId: 'asset-id', - asset: { - ...AssetFactory.create({ id: 'asset-id' }), - libraryId: null, - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - stackId: null, - }, - personId: personStub.withName.id, - person: personStub.withName, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, - deletedAt: new Date(), - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - primaryFace1: Object.freeze({ - id: 'assetFaceId2', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: personStub.primaryPerson.id, - person: personStub.primaryPerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - mergeFace1: Object.freeze({ - id: 'assetFaceId3', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: personStub.mergePerson.id, - person: personStub.mergePerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - noPerson1: Object.freeze({ - id: 'assetFaceId8', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: null, - person: null, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - noPerson2: Object.freeze({ - id: 'assetFaceId9', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: null, - person: null, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - fromExif1: Object.freeze({ - id: 'assetFaceId9', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: personStub.randomPerson.id, - person: personStub.randomPerson, - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageHeight: 500, - imageWidth: 400, - sourceType: SourceType.Exif, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - fromExif2: Object.freeze({ - id: 'assetFaceId9', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: personStub.randomPerson.id, - person: personStub.randomPerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.Exif, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - withBirthDate: Object.freeze({ - id: 'assetFaceId10', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: personStub.withBirthDate.id, - person: personStub.withBirthDate, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), -}; diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 89ca79d864..eb57c10e2e 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -1,3 +1,6 @@ +import { Selectable } from 'kysely'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; export const getForStorageTemplate = (asset: ReturnType) => { @@ -20,3 +23,29 @@ export const getForStorageTemplate = (asset: ReturnType) isEdited: asset.isEdited, }; }; + +export const getAsDetectedFace = (face: ReturnType) => ({ + faces: [ + { + boundingBox: { + x1: face.boundingBoxX1, + y1: face.boundingBoxY1, + x2: face.boundingBoxX2, + y2: face.boundingBoxY2, + }, + embedding: '[1, 2, 3, 4]', + score: 0.2, + }, + ], + imageHeight: face.imageHeight, + imageWidth: face.imageWidth, +}); + +export const getForFacialRecognitionJob = ( + face: ReturnType, + asset: Pick, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null, +) => ({ + ...face, + asset, + faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' }, +}); From fd0338f89c4e9f993d207fa54336df597bf22bc3 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:54:28 +0100 Subject: [PATCH 058/143] refactor: asset service queries (#25535) --- server/src/queries/asset.repository.sql | 41 +++++++++ server/src/repositories/asset.repository.ts | 27 ++++++ server/src/services/asset.service.spec.ts | 4 +- server/src/services/asset.service.ts | 16 ++-- server/src/utils/asset.util.ts | 21 +++-- .../specs/services/asset.service.spec.ts | 91 ++++++++++++++++++- .../repositories/asset.repository.mock.ts | 2 + 7 files changed, 185 insertions(+), 17 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index e8cdd335e2..01c580fb13 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -636,3 +636,44 @@ from where "asset"."id" = $1 and "asset"."type" = $2 + +-- AssetRepository.getForOcr +select + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_edit"."action", + "asset_edit"."parameters" + from + "asset_edit" + where + "asset_edit"."assetId" = "asset"."id" + ) as agg + ) as "edits", + "asset_exif"."exifImageWidth", + "asset_exif"."exifImageHeight", + "asset_exif"."orientation" +from + "asset" + inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "asset"."id" = $1 + +-- AssetRepository.getForEdit +select + "asset"."type", + "asset"."livePhotoVideoId", + "asset"."originalPath", + "asset"."originalFileName", + "asset_exif"."exifImageWidth", + "asset_exif"."exifImageHeight", + "asset_exif"."orientation", + "asset_exif"."projectionType" +from + "asset" + inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "asset"."id" = $1 diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index d99d8cbab2..3165aed023 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1059,4 +1059,31 @@ export class AssetRepository { .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForOcr(id: string) { + return this.db + .selectFrom('asset') + .where('asset.id', '=', id) + .select(withEdits) + .innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id')) + .select(['asset_exif.exifImageWidth', 'asset_exif.exifImageHeight', 'asset_exif.orientation']) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForEdit(id: string) { + return this.db + .selectFrom('asset') + .select(['asset.type', 'asset.livePhotoVideoId', 'asset.originalPath', 'asset.originalFileName']) + .where('asset.id', '=', id) + .innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id')) + .select([ + 'asset_exif.exifImageWidth', + 'asset_exif.exifImageHeight', + 'asset_exif.orientation', + 'asset_exif.projectionType', + ]) + .executeTakeFirst(); + } } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index b677881cfe..db895f8321 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -660,7 +660,7 @@ describe(AssetService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...asset.exifInfo }); await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([ocr1, ocr2]); @@ -676,7 +676,7 @@ describe(AssetService.name, () => { const asset = AssetFactory.from().exif().build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([]); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...asset.exifInfo }); await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([]); expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith(asset.id); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index ed427684f1..bda2d122fe 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -404,15 +404,19 @@ export class AssetService extends BaseService { async getOcr(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const ocr = await this.ocrRepository.getByAssetId(id); - const asset = await this.assetRepository.getById(id, { exifInfo: true, edits: true }); + const asset = await this.assetRepository.getForOcr(id); - if (!asset || !asset.exifInfo || !asset.edits) { + if (!asset) { throw new BadRequestException('Asset not found'); } - const dimensions = getDimensions(asset.exifInfo); + const dimensions = getDimensions({ + exifImageHeight: asset.exifImageHeight, + exifImageWidth: asset.exifImageWidth, + orientation: asset.orientation, + }); - return ocr.map((item) => transformOcrBoundingBox(item, asset.edits!, dimensions)); + return ocr.map((item) => transformOcrBoundingBox(item, asset.edits, dimensions)); } async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise { @@ -551,7 +555,7 @@ export class AssetService extends BaseService { async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] }); - const asset = await this.assetRepository.getById(id, { exifInfo: true }); + const asset = await this.assetRepository.getForEdit(id); if (!asset) { throw new BadRequestException('Asset not found'); } @@ -584,7 +588,7 @@ export class AssetService extends BaseService { const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop); if (crop) { // check that crop parameters will not go out of bounds - const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!); + const { width: assetWidth, height: assetHeight } = getDimensions(asset); if (!assetWidth || !assetHeight) { throw new BadRequestException('Asset dimensions are not available for editing'); diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f8fb3d215d..c5d1476f65 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,10 +1,9 @@ import { BadRequestException } from '@nestjs/common'; import { StorageCore } from 'src/cores/storage.core'; -import { AssetFile, Exif } from 'src/database'; +import { AssetFile } from 'src/database'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ExifResponseDto } from 'src/dtos/exif.dto'; import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -210,20 +209,26 @@ const isFlipped = (orientation?: string | null) => { return value && [5, 6, 7, 8, -90, 90].includes(value); }; -export const getDimensions = (exifInfo: ExifResponseDto | Exif) => { - const { exifImageWidth: width, exifImageHeight: height } = exifInfo; - +export const getDimensions = ({ + exifImageHeight: height, + exifImageWidth: width, + orientation, +}: { + exifImageHeight: number | null; + exifImageWidth: number | null; + orientation: string | null; +}) => { if (!width || !height) { return { width: 0, height: 0 }; } - if (isFlipped(exifInfo.orientation)) { + if (isFlipped(orientation)) { return { width: height, height: width }; } return { width, height }; }; -export const isPanorama = (asset: { exifInfo?: Exif | null; originalFileName: string }) => { - return asset.exifInfo?.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); +export const isPanorama = (asset: { projectionType: string | null; originalFileName: string }) => { + return asset.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); }; diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index db1b944e1f..d97551d154 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,12 +1,15 @@ import { Kysely } from 'kysely'; +import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFileType, AssetMetadataKey, AssetStatus, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { OcrRepository } from 'src/repositories/ocr.repository'; import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; @@ -25,6 +28,7 @@ const setup = (db?: Kysely) => { database: db || defaultDatabase, real: [ AssetRepository, + AssetEditRepository, AssetJobRepository, AlbumRepository, AccessRepository, @@ -32,7 +36,7 @@ const setup = (db?: Kysely) => { StackRepository, UserRepository, ], - mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository], + mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository, OcrRepository], }); }; @@ -586,6 +590,57 @@ describe(AssetService.name, () => { }); }); + describe('getOcr', () => { + it('should require access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + + await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access'); + }); + + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ x1: 0.1, x2: 0.3, x3: 0.3, x4: 0.1, y1: 0.2, y2: 0.2, y3: 0.4, y4: 0.4 }), + ]); + }); + + it('should apply rotation', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + await ctx.database + .insertInto('asset_edit') + .values({ assetId: asset.id, action: AssetEditAction.Rotate, parameters: { angle: 90 }, sequence: 1 }) + .execute(); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ + x1: 0.6, + x2: 0.8, + x3: 0.8, + x4: 0.6, + y1: expect.any(Number), + y2: expect.any(Number), + y3: 0.3, + y4: 0.3, + }), + ]); + }); + }); + describe('upsertBulkMetadata', () => { it('should work', async () => { const { sut, ctx } = setup(); @@ -758,4 +813,38 @@ describe(AssetService.name, () => { expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]); }); }); + + describe('editAsset', () => { + it('should require access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + + await expect( + sut.editAsset(auth, asset.id, { edits: [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }] }), + ).rejects.toThrow('Not found or no asset.edit.create access'); + }); + + it('should work', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + + const editAction = { action: AssetEditAction.Rotate, parameters: { angle: 90 } } as const; + await expect(sut.editAsset(auth, asset.id, { edits: [editAction] })).resolves.toEqual({ + assetId: asset.id, + edits: [editAction], + }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual( + expect.objectContaining({ isEdited: true }), + ); + await expect(ctx.get(AssetEditRepository).getAll(asset.id)).resolves.toEqual([editAction]); + }); + }); }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index ea7162c77a..b1ced1f874 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -54,5 +54,7 @@ export const newAssetRepositoryMock = (): Mocked Date: Thu, 19 Feb 2026 09:15:56 -0500 Subject: [PATCH 059/143] feat: editing descriminator (#26336) --- open-api/immich-openapi-specs.json | 20 ++++++++++++++++++-- server/src/dtos/editing.dto.ts | 10 +++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 420c7e1015..0e57fc4819 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16117,7 +16117,15 @@ { "$ref": "#/components/schemas/AssetEditActionMirror" } - ] + ], + "discriminator": { + "mapping": { + "crop": "#/components/schemas/AssetEditActionCrop", + "mirror": "#/components/schemas/AssetEditActionMirror", + "rotate": "#/components/schemas/AssetEditActionRotate" + }, + "propertyName": "action" + } }, "minItems": 1, "type": "array" @@ -16188,7 +16196,15 @@ { "$ref": "#/components/schemas/AssetEditActionMirror" } - ] + ], + "discriminator": { + "mapping": { + "crop": "#/components/schemas/AssetEditActionCrop", + "mirror": "#/components/schemas/AssetEditActionMirror", + "rotate": "#/components/schemas/AssetEditActionRotate" + }, + "propertyName": "action" + } }, "minItems": 1, "type": "array" diff --git a/server/src/dtos/editing.dto.ts b/server/src/dtos/editing.dto.ts index 8bb1eef47b..3c4c063b10 100644 --- a/server/src/dtos/editing.dto.ts +++ b/server/src/dtos/editing.dto.ts @@ -118,7 +118,15 @@ export class AssetEditActionListDto { Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits, ) @ApiProperty({ - anyOf: Object.values(actionToClass).map((target) => ({ $ref: getSchemaPath(target) })), + items: { + anyOf: Object.values(actionToClass).map((type) => ({ $ref: getSchemaPath(type) })), + discriminator: { + propertyName: 'action', + mapping: Object.fromEntries( + Object.entries(actionToClass).map(([action, type]) => [action, getSchemaPath(type)]), + ), + }, + }, description: 'List of edit actions to apply (crop, rotate, or mirror)', }) edits!: AssetEditActionItem[]; From 208c07af1f9f5683e4238062aca71f5e77251e0a Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 16:15:26 +0100 Subject: [PATCH 060/143] chore(web): merge "Add to album" and "Add to shared album" actions into a single action (#24669) * refactor: simplify album selection actions by removing shared option * Removed the shared option from AddToAlbumAction and related components. * Updated AlbumPickerModal and other components to reflect this change. * Cleaned up related tests and documentation for consistency. * fix lint --- .../actions/add-to-album-action.svelte | 15 ++---- .../asset-viewer/asset-viewer-nav-bar.svelte | 1 - .../memory-page/memory-viewer.svelte | 6 +-- .../album-selection-utils.spec.ts | 47 +++++-------------- .../album-selection/album-selection-utils.ts | 18 +++---- .../timeline/actions/AddToAlbumAction.svelte | 30 +++++++----- .../workflows/WorkflowPickerField.svelte | 2 +- web/src/lib/modals/AlbumPickerModal.svelte | 9 ++-- web/src/lib/modals/ShortcutsModal.svelte | 1 - .../[[assetId=id]]/+page.svelte | 5 +- .../[[assetId=id]]/+page.svelte | 7 +-- .../[[assetId=id]]/+page.svelte | 7 +-- .../[[assetId=id]]/+page.svelte | 7 +-- .../[[assetId=id]]/+page.svelte | 9 +--- .../[[assetId=id]]/+page.svelte | 13 +---- .../(user)/photos/[[assetId=id]]/+page.svelte | 7 +-- .../[[assetId=id]]/+page.svelte | 8 ++-- .../[[assetId=id]]/+page.svelte | 7 +-- 18 files changed, 67 insertions(+), 132 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index 2c6ac54ef7..cf8ba15024 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -8,19 +8,18 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import { modalManager } from '@immich/ui'; - import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; + import { mdiImageAlbum } from '@mdi/js'; import { t } from 'svelte-i18n'; interface Props { asset: AssetResponseDto; onAction: OnAction; - shared?: boolean; } - let { asset, onAction, shared = false }: Props = $props(); + let { asset, onAction }: Props = $props(); const onClick = async () => { - const albums = await modalManager.show(AlbumPickerModal, { shared }); + const albums = await modalManager.show(AlbumPickerModal, {}); if (!albums || albums.length === 0) { return; @@ -40,10 +39,6 @@ }; - + - + diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 6754ad70cf..93ce2f01e3 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -186,7 +186,6 @@ {:else} - {/if} {/if} diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index efa425dd30..20d43f1974 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -48,7 +48,6 @@ mdiImageSearch, mdiPause, mdiPlay, - mdiPlus, mdiSelectAll, mdiVolumeHigh, mdiVolumeOff, @@ -339,10 +338,7 @@ onclick={handleSelectAll} /> - - - - + diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts index a078e55762..9257c4585a 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts @@ -28,15 +28,15 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({ }); describe('Album Modal', () => { - it('non-shared with no albums configured yet shows message and new', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('no albums configured yet shows message and new', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const modalRows = converter.toModalRows('', [], [], -1, []); expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]); }); - it('non-shared with no matching albums shows message and new', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('no matching albums shows message and new', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const modalRows = converter.toModalRows( 'matches_nothing', [], @@ -48,8 +48,8 @@ describe('Album Modal', () => { expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]); }); - it('non-shared displays single albums', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('displays single albums', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []); @@ -60,8 +60,8 @@ describe('Album Modal', () => { ]); }); - it('non-shared displays multiple albums and recents', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('displays multiple albums and recents', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); @@ -87,31 +87,8 @@ describe('Album Modal', () => { ]); }); - it('shared only displays albums and no recents', () => { - const converter = new AlbumModalRowConverter(true, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); - const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); - const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); - const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); - const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); - const modalRows = converter.toModalRows( - '', - [holidayAlbum, constructionAlbum], - [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], - -1, - [], - ); - - expect(modalRows).toStrictEqual([ - createNewAlbumRow(false), - createAlbumRow(holidayAlbum, false), - createAlbumRow(constructionAlbum, false), - createAlbumRow(birthdayAlbum, false), - createAlbumRow(christmasAlbum, false), - ]); - }); - it('search changes messaging and removes recent and non-matching albums', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); @@ -132,7 +109,7 @@ describe('Album Modal', () => { }); it('selection can select new album row', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []); @@ -148,7 +125,7 @@ describe('Album Modal', () => { }); it('selection can select recent row', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []); @@ -164,7 +141,7 @@ describe('Album Modal', () => { }); it('selection can select last row', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []); diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts index e65d42b183..56246ac6c4 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts @@ -27,12 +27,10 @@ export const isSelectableRowType = (type: AlbumModalRowType) => const $t = get(t); export class AlbumModalRowConverter { - private readonly shared: boolean; private readonly sortBy: string; private readonly orderBy: string; - constructor(shared: boolean, sortBy: string, orderBy: string) { - this.shared = shared; + constructor(sortBy: string, orderBy: string) { this.sortBy = sortBy; this.orderBy = orderBy; } @@ -44,8 +42,8 @@ export class AlbumModalRowConverter { selectedRowIndex: number, multiSelectedAlbumIds: string[], ): AlbumModalRow[] { - // only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal. - const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : []; + // only show recent albums if no search was entered + const recentAlbumsToShow = search.length === 0 ? recentAlbums : []; const rows: AlbumModalRow[] = [{ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }]; const filteredAlbums = sortAlbums( @@ -71,12 +69,10 @@ export class AlbumModalRowConverter { } } - if (!this.shared) { - rows.push({ - type: AlbumModalRowType.SECTION, - text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(), - }); - } + rows.push({ + type: AlbumModalRowType.SECTION, + text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(), + }); const selectedOffsetDueToNewAndRecents = 1 + recentAlbumsToShow.length; for (const [i, album] of filteredAlbums.entries()) { diff --git a/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte b/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte index 97b75b070d..6dce0ce084 100644 --- a/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte +++ b/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte @@ -4,21 +4,21 @@ import type { OnAddToAlbum } from '$lib/utils/actions'; import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils'; import { getAssetControlContext } from '$lib/utils/context'; - import { modalManager } from '@immich/ui'; - import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; + import { IconButton, modalManager } from '@immich/ui'; + import { mdiImageAlbum, mdiPlus } from '@mdi/js'; import { t } from 'svelte-i18n'; interface Props { - shared?: boolean; onAddToAlbum?: OnAddToAlbum; + menuItem?: boolean; } - let { shared = false, onAddToAlbum = () => {} }: Props = $props(); + let { onAddToAlbum = () => {}, menuItem = false }: Props = $props(); const { getAssets } = getAssetControlContext(); const onClick = async () => { - const albums = await modalManager.show(AlbumPickerModal, { shared }); + const albums = await modalManager.show(AlbumPickerModal, {}); if (!albums || albums.length === 0) { return; } @@ -38,9 +38,17 @@ }; - +{#if menuItem} + +{/if} + +{#if !menuItem} + +{/if} diff --git a/web/src/lib/components/workflows/WorkflowPickerField.svelte b/web/src/lib/components/workflows/WorkflowPickerField.svelte index 6ad4fdbeb2..0ba85904a2 100644 --- a/web/src/lib/components/workflows/WorkflowPickerField.svelte +++ b/web/src/lib/components/workflows/WorkflowPickerField.svelte @@ -42,7 +42,7 @@ const handlePicker = async () => { if (isAlbum) { - const albums = await modalManager.show(AlbumPickerModal, { shared: false }); + const albums = await modalManager.show(AlbumPickerModal); if (albums && albums.length > 0) { const newValue = multiple ? albums.map((album) => album.id) : albums[0].id; onchange(newValue); diff --git a/web/src/lib/modals/AlbumPickerModal.svelte b/web/src/lib/modals/AlbumPickerModal.svelte index 72f80043f5..b2420215bc 100644 --- a/web/src/lib/modals/AlbumPickerModal.svelte +++ b/web/src/lib/modals/AlbumPickerModal.svelte @@ -21,14 +21,13 @@ let selectedRowIndex: number = $state(-1); interface Props { - shared: boolean; onClose: (albums?: AlbumResponseDto[]) => void; } - let { shared, onClose }: Props = $props(); + let { onClose }: Props = $props(); onMount(async () => { - albums = await getAllAlbums({ shared: shared || undefined }); + albums = await getAllAlbums({}); recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3); loading = false; }); @@ -36,7 +35,7 @@ const multiSelectedAlbumIds: string[] = $state([]); const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0); - const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder); + const rowConverter = new AlbumModalRowConverter($albumViewSettings.sortBy, $albumViewSettings.sortOrder); const albumModalRows = $derived( rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex, multiSelectedAlbumIds), ); @@ -146,7 +145,7 @@ }; - +
{#if loading} diff --git a/web/src/lib/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte index c5b09ffa1a..c233548878 100644 --- a/web/src/lib/modals/ShortcutsModal.svelte +++ b/web/src/lib/modals/ShortcutsModal.svelte @@ -40,7 +40,6 @@ { key: ['s'], action: $t('stack_selected_photos') }, { key: ['l'], action: $t('add_to_album') }, { key: ['t'], action: $t('tag_assets') }, - { key: ['⇧', 'l'], action: $t('add_to_shared_album') }, { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, { key: ['⇧', 'd'], action: $t('download') }, { key: ['Space'], action: $t('play_or_pause_video') }, 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 88baa416b8..38817650c1 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 @@ -440,10 +440,7 @@ > - - - - + {#if assetInteraction.isAllUserOwned} - - - - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index d33c5e7474..74993cb64b 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -19,7 +19,7 @@ import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; - import { mdiDotsVertical, mdiPlus } from '@mdi/js'; + import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -71,10 +71,7 @@ timelineManager.removeAssets(assetIds)} /> - - - - + diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9bca4a9094..c9ac99d10f 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -31,7 +31,7 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import { joinPaths } from '$lib/utils/tree-utils'; import { IconButton, Text } from '@immich/ui'; - import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; + import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiSelectAll } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -130,10 +130,7 @@ icon={mdiSelectAll} onclick={handleSelectAllAssets} /> - - cancelMultiselect(assetInteraction)} /> - cancelMultiselect(assetInteraction)} shared /> - + cancelMultiselect(assetInteraction)} /> import { goto } from '$app/navigation'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte'; @@ -10,8 +9,7 @@ import { Route } from '$lib/route'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetVisibility } from '@immich/sdk'; - import { mdiArrowLeft, mdiPlus } from '@mdi/js'; - import { t } from 'svelte-i18n'; + import { mdiArrowLeft } from '@mdi/js'; import type { PageData } from './$types'; interface Props { @@ -46,10 +44,7 @@ clearSelect={() => assetInteraction.clearMultiselect()} > - - - - + {:else} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 57c5730b45..3c18b866c1 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -41,13 +41,7 @@ import { isExternalUrl } from '$lib/utils/navigation'; import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; import { ContextMenuButton, LoadingSpinner, modalManager, toastManager, type ActionItem } from '@immich/ui'; - import { - mdiAccountBoxOutline, - mdiAccountMultipleCheckOutline, - mdiArrowLeft, - mdiDotsVertical, - mdiPlus, - } from '@mdi/js'; + import { mdiAccountBoxOutline, mdiAccountMultipleCheckOutline, mdiArrowLeft, mdiDotsVertical } from '@mdi/js'; import { DateTime } from 'luxon'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -463,10 +457,7 @@ > - - - - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index bea77bb443..bef36d5602 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -42,7 +42,7 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility } from '@immich/sdk'; import { ImageCarousel } from '@immich/ui'; - import { mdiDotsVertical, mdiPlus } from '@mdi/js'; + import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; let { isViewing: showAssetViewer } = assetViewingStore; @@ -134,10 +134,7 @@ - - - - + {#if isAllUserOwned} - - - - + {#if isAllUserOwned} + diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2b498e56ea..868f23bf55 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -31,7 +31,7 @@ import { joinPaths, TreeNode } from '$lib/utils/tree-utils'; import { getAllTags, type TagResponseDto } from '@immich/sdk'; import { Text } from '@immich/ui'; - import { mdiDotsVertical, mdiPlus, mdiTag, mdiTagMultiple } from '@mdi/js'; + import { mdiDotsVertical, mdiTag, mdiTagMultiple } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -122,10 +122,7 @@ > - - - - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} From f04efbb714685e352a1dc044f52dededf8ca25a3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 19 Feb 2026 10:40:13 -0500 Subject: [PATCH 061/143] fix: safari address bar color (#26346) --- web/src/app.css | 4 ++++ web/src/lib/components/asset-viewer/asset-viewer.svelte | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/web/src/app.css b/web/src/app.css index dc2d3bf3c3..3a4d29b466 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -148,6 +148,10 @@ color: #3a3a3a; } + body.asset-viewer-open { + background-color: black; + } + input:focus-visible { outline-offset: 0px !important; outline: none !important; diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 7decdac835..ad34e68fb8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,4 +1,5 @@
- {#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])} + {#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])} {:then [data, { default: PhotoSphereViewer }]} From 4f2e6e3f15de5c94451843eb63b8c0b111d43eb8 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:32:25 +0100 Subject: [PATCH 066/143] fix(web): favoriting assets opened via GalleryViewer (#26350) fix(web): favoriting assets through GalleryViewer --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index ad34e68fb8..8c9bb4156b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -398,7 +398,7 @@ const onAssetUpdate = (update: AssetResponseDto) => { if (asset.id === update.id) { - cursor.current = update; + cursor = { ...cursor, current: update }; } }; From 7005e9fc50c7cb78927c5b66b1019a5c629a1733 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:33:06 +0100 Subject: [PATCH 067/143] fix(web): update @immich/ui to v0.64.0 (#26351) --- pnpm-lock.yaml | 18 +++++++++--------- web/package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 007acaa828..09b24cb3df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,8 +741,8 @@ importers: specifier: workspace:* version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.63.0 - version: 0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + specifier: ^0.64.0 + version: 0.64.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) '@mapbox/mapbox-gl-rtl-text': specifier: 0.3.0 version: 0.3.0 @@ -3018,8 +3018,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.63.0': - resolution: {integrity: sha512-WTdEZi1XEvhcdQymFCIb8Us2DJv+Vp4wTytYwIgQUeXMFSQ8aUT7m76Wsa6uphmuFqyyJioFU+g4rIfJ+w2R5w==} + '@immich/ui@0.64.0': + resolution: {integrity: sha512-jbPN1x9KAAcW18h4RO7skbFYjkR4Lg+mEVjSDzsPC2NBNzSi4IA0PIHhFEwnD5dk4OS7+UjRG8m5/QTyotrm4A==} peerDependencies: svelte: ^5.0.0 @@ -5643,8 +5643,8 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - bits-ui@2.14.4: - resolution: {integrity: sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==} + bits-ui@2.16.0: + resolution: {integrity: sha512-utsUZE7W7MxOQF1jmSYfzUrt2nZxgkq0yPqQcBQ0WQDMq8ETd1yEiHlPpqhMrpKU7IivjSf4XVysDDy+UVkMUw==} engines: {node: '>=20'} peerDependencies: '@internationalized/date': ^3.8.1 @@ -14819,12 +14819,12 @@ snapshots: node-emoji: 2.2.0 svelte: 5.50.2 - '@immich/ui@0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)': + '@immich/ui@0.64.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)': dependencies: '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.2) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) luxon: 3.7.2 simple-icons: 16.9.0 svelte: 5.50.2 @@ -17701,7 +17701,7 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): + bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 diff --git a/web/package.json b/web/package.json index 53b8ae77db..6d1a8ce933 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", - "@immich/ui": "^0.63.0", + "@immich/ui": "^0.64.0", "@mapbox/mapbox-gl-rtl-text": "0.3.0", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", From ffd54d043161a788a980bd3ebd79eccc3c71d7f3 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 17:53:19 +0100 Subject: [PATCH 068/143] fix(i18n): add translation key for partner's photos (#26348) * fix(i18n): add translation key for partner's photos * reuse existing key --- .../[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5c597a4b3b..2a077697f8 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,6 +11,7 @@ import { AssetVisibility } from '@immich/sdk'; import { mdiArrowLeft } from '@mdi/js'; import type { PageData } from './$types'; + import { t } from 'svelte-i18n'; interface Props { data: PageData; @@ -51,7 +52,7 @@ goto(Route.sharing())}> {#snippet leading()}

- {data.partner.name}'s photos + {$t('partner_list_user_photos', { values: { user: data.partner.name } })}

{/snippet}
From 99f7eb4ce6e4006aa6755c27408cb0a99c0d324f Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:09:12 +0100 Subject: [PATCH 069/143] chore(server): remove redundant nullish checks (#26354) --- server/src/services/metadata.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 3937237ff6..983d905aad 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -289,8 +289,8 @@ export class MetadataService extends BaseService { colorspace: exifTags.ColorSpace ?? null, // camera - make: exifTags.Make ?? exifTags?.Device?.Manufacturer ?? exifTags.AndroidMake ?? null, - model: exifTags.Model ?? exifTags?.Device?.ModelName ?? exifTags.AndroidModel ?? null, + make: exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? null, + model: exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? null, fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), iso: validate(exifTags.ISO) as number, exposureTime: exifTags.ExposureTime ?? null, From 7394fa1491b883b9f4198ac734e47017e58c2793 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:11:56 +0000 Subject: [PATCH 070/143] chore(deps): update dependency svelte to v5.51.5 [security] (#26352) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 174 +++++++++++++++++++++++------------------------ web/package.json | 2 +- 2 files changed, 88 insertions(+), 88 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09b24cb3df..33c0815f60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -742,7 +742,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.64.0 - version: 0.64.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + version: 0.64.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) '@mapbox/mapbox-gl-rtl-text': specifier: 0.3.0 version: 0.3.0 @@ -775,7 +775,7 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.50.2) + version: 0.3.9(svelte@5.51.5) dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -826,16 +826,16 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.50.2) + version: 4.0.1(svelte@5.51.5) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.50.2) + version: 3.11.0(svelte@5.51.5) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.6(svelte@5.50.2) + version: 1.2.6(svelte@5.51.5) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.50.2) + version: 0.12.0(svelte@5.51.5) tabbable: specifier: ^6.2.0 version: 6.4.0 @@ -863,16 +863,16 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.10.0 - version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.4 - version: 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.7 version: 4.1.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -881,7 +881,7 @@ importers: version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.3.1(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -920,7 +920,7 @@ importers: version: 6.1.0(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.2) + version: 3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.51.5) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -941,19 +941,19 @@ importers: version: 4.2.0(prettier@3.8.1) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.4.1(prettier@3.8.1)(svelte@5.50.2) + version: 3.4.1(prettier@3.8.1)(svelte@5.51.5) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.55.1) svelte: - specifier: 5.50.2 - version: 5.50.2 + specifier: 5.51.5 + version: 5.51.5 svelte-check: specifier: ^4.1.5 - version: 4.3.6(picomatch@4.0.3)(svelte@5.50.2)(typescript@5.9.3) + version: 4.3.6(picomatch@4.0.3)(svelte@5.51.5)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.1(svelte@5.50.2) + version: 1.4.1(svelte@5.51.5) tailwindcss: specifier: ^4.1.7 version: 4.1.18 @@ -6672,8 +6672,8 @@ packages: engines: {node: '>= 4.0.0'} hasBin: true - devalue@5.6.2: - resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + devalue@5.6.3: + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -11122,8 +11122,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.50.2: - resolution: {integrity: sha512-WCxzm3BBf+Ase6RwiDPR4G36cM4Kb0NuhmLK6x44I+D6reaxizDDg8kBkk4jT/19+Rgmc44eZkOvMO6daoSFIw==} + svelte@5.51.5: + resolution: {integrity: sha512-/4tR5cLsWOgH3wnNRXnFoWaJlwPGbJanZPSKSD6nHM2y01dvXeEF4Nx7jevoZ+UpJpkIHh6mY2tqDncuI4GHng==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -14812,22 +14812,22 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.2)': + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.51.5)': dependencies: front-matter: 4.0.2 marked: 17.0.3 node-emoji: 2.2.0 - svelte: 5.50.2 + svelte: 5.51.5 - '@immich/ui@0.64.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)': + '@immich/ui@0.64.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)': dependencies: - '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.2) + '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.51.5) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) luxon: 3.7.2 simple-icons: 16.9.0 - svelte: 5.50.2 + svelte: 5.51.5 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) @@ -16160,17 +16160,17 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@sveltejs/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.50.2 - svelte-parse-markup: 0.1.5(svelte@5.50.2) + svelte: 5.51.5 + svelte-parse-markup: 0.1.5(svelte@5.51.5) vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-imagetools: 9.0.2(rollup@4.55.1) zimmerframe: 1.1.4 @@ -16178,15 +16178,15 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 - devalue: 5.6.2 + devalue: 5.6.3 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 @@ -16194,28 +16194,28 @@ snapshots: sade: 1.8.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.50.2 + svelte: 5.51.5 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@opentelemetry/api': 1.9.0 typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 - svelte: 5.50.2 + svelte: 5.51.5 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.50.2 + svelte: 5.51.5 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitefu: 1.1.1(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: @@ -16463,15 +16463,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.50.2)': + '@testing-library/svelte-core@1.0.0(svelte@5.51.5)': dependencies: - svelte: 5.50.2 + svelte: 5.51.5 - '@testing-library/svelte@5.3.1(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.0.0(svelte@5.50.2) - svelte: 5.50.2 + '@testing-library/svelte-core': 1.0.0(svelte@5.51.5) + svelte: 5.51.5 optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -17049,8 +17049,7 @@ snapshots: dependencies: '@types/node': 24.10.13 - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} '@types/ua-parser-js@0.7.39': {} @@ -17339,10 +17338,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.50.2)': + '@zoom-image/svelte@0.3.9(svelte@5.51.5)': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.50.2 + svelte: 5.51.5 abbrev@1.1.1: {} @@ -17701,15 +17700,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): + bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) - svelte: 5.50.2 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) + svelte: 5.51.5 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -18792,7 +18791,7 @@ snapshots: transitivePeerDependencies: - supports-color - devalue@5.6.2: {} + devalue@5.6.3: {} devlop@1.1.0: dependencies: @@ -19201,7 +19200,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-svelte@3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.2): + eslint-plugin-svelte@3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.51.5): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -19213,9 +19212,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.4 - svelte-eslint-parser: 1.4.1(svelte@5.50.2) + svelte-eslint-parser: 1.4.1(svelte@5.51.5) optionalDependencies: - svelte: 5.50.2 + svelte: 5.51.5 transitivePeerDependencies: - ts-node @@ -22922,10 +22921,10 @@ snapshots: dependencies: prettier: 3.8.1 - prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.50.2): + prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.51.5): dependencies: prettier: 3.8.1 - svelte: 5.50.2 + svelte: 5.51.5 prettier@3.8.1: {} @@ -23542,14 +23541,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): + runed@0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.50.2 + svelte: 5.51.5 optionalDependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -24175,23 +24174,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.50.2): + svelte-awesome@3.3.5(svelte@5.51.5): dependencies: - svelte: 5.50.2 + svelte: 5.51.5 - svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.2)(typescript@5.9.3): + svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.51.5)(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.50.2 + svelte: 5.51.5 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.1(svelte@5.50.2): + svelte-eslint-parser@1.4.1(svelte@5.51.5): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -24200,7 +24199,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.1 optionalDependencies: - svelte: 5.50.2 + svelte: 5.51.5 svelte-floating-ui@1.5.8: dependencies: @@ -24213,7 +24212,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.50.2): + svelte-i18n@4.0.1(svelte@5.51.5): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -24221,10 +24220,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.50.2 + svelte: 5.51.5 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.50.2): + svelte-jsoneditor@3.11.0(svelte@5.51.5): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -24251,52 +24250,53 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.50.2 - svelte-awesome: 3.3.5(svelte@5.50.2) + svelte: 5.51.5 + svelte-awesome: 3.3.5(svelte@5.51.5) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.2.6(svelte@5.50.2): + svelte-maplibre@1.2.6(svelte@5.51.5): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 maplibre-gl: 5.18.0 pmtiles: 3.2.1 - svelte: 5.50.2 + svelte: 5.51.5 - svelte-parse-markup@0.1.5(svelte@5.50.2): + svelte-parse-markup@0.1.5(svelte@5.51.5): dependencies: - svelte: 5.50.2 + svelte: 5.51.5 - svelte-persisted-store@0.12.0(svelte@5.50.2): + svelte-persisted-store@0.12.0(svelte@5.51.5): dependencies: - svelte: 5.50.2 + svelte: 5.51.5 svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) style-to-object: 1.0.14 - svelte: 5.50.2 + svelte: 5.51.5 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.50.2: + svelte@5.51.5: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 acorn: 8.15.0 aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.6.2 + devalue: 5.6.3 esm-env: 1.2.2 esrap: 2.2.3 is-reference: 3.0.3 diff --git a/web/package.json b/web/package.json index 6d1a8ce933..b45e89fc97 100644 --- a/web/package.json +++ b/web/package.json @@ -98,7 +98,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.50.2", + "svelte": "5.51.5", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", From aa02310d631117bf6e205ed7d451f419990f4a5e Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:26:21 +0000 Subject: [PATCH 071/143] chore(mobile): cleanup asset viewer state (#26300) initState was quite noisy, so I've moved some things around and made the intention a bit clearer. Co-authored-by: Alex --- .../asset_viewer/asset_viewer.page.dart | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) 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 13311fc4b2..515f635493 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -87,39 +87,37 @@ class AssetViewer extends ConsumerStatefulWidget { } class _AssetViewerState extends ConsumerState { - late PageController pageController; + late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; + late final _pageController = PageController(initialPage: widget.initialIndex); + late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); StreamSubscription? _reloadSubscription; - - late final int heroOffset; - bool _assetReloadRequested = false; - int _totalAssets = 0; - - late final AssetPreloader _preloader; KeepAliveLink? _stackChildrenKeepAlive; + bool _assetReloadRequested = false; + @override void initState() { super.initState(); - assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); - pageController = PageController(initialPage: widget.initialIndex); - final timelineService = ref.read(timelineServiceProvider); - _totalAssets = timelineService.totalAssets; - _preloader = AssetPreloader(timelineService: timelineService, mounted: () => mounted); - WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); - _reloadSubscription = EventStream.shared.listen(_onEvent); - heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; + final asset = ref.read(currentAssetNotifier); + assert(asset != null, "Current asset should not be null when opening the AssetViewer"); if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); + + _reloadSubscription = EventStream.shared.listen(_onEvent); + + WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); } @override void dispose() { - pageController.dispose(); + _pageController.dispose(); _preloader.dispose(); _reloadSubscription?.cancel(); _stackChildrenKeepAlive?.close(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); } @@ -176,26 +174,26 @@ class _AssetViewerState extends ConsumerState { void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); - _totalAssets = timelineService.totalAssets; + final totalAssets = timelineService.totalAssets; - if (_totalAssets == 0) { + if (totalAssets == 0) { context.maybePop(); return; } - var index = pageController.page?.round() ?? 0; + var index = _pageController.page?.round() ?? 0; final currentAsset = ref.read(currentAssetNotifier); if (currentAsset != null) { final newIndex = timelineService.getIndex(currentAsset.heroTag); if (newIndex != null && newIndex != index) { index = newIndex; - pageController.jumpToPage(index); + _pageController.jumpToPage(index); } } - if (index >= _totalAssets) { - index = _totalAssets - 1; - pageController.jumpToPage(index); + if (index >= totalAssets) { + index = totalAssets - 1; + _pageController.jumpToPage(index); } if (_assetReloadRequested) { @@ -264,15 +262,15 @@ class _AssetViewerState extends ConsumerState { PhotoViewGestureDetectorScope( axis: Axis.horizontal, child: PageView.builder( - controller: pageController, + controller: _pageController, physics: isZoomed ? const NeverScrollableScrollPhysics() : CurrentPlatform.isIOS ? const FastScrollPhysics() : const FastClampingScrollPhysics(), - itemCount: _totalAssets, + itemCount: ref.read(timelineServiceProvider).totalAssets, onPageChanged: (index) => _onAssetChanged(index), - itemBuilder: (context, index) => AssetPage(index: index, heroOffset: heroOffset), + itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset), ), ), if (!CurrentPlatform.isIOS) From a0077a0f514a619d8c1a5289c926ff5876bddfbb Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:57:16 +0530 Subject: [PATCH 072/143] feat(mobile): html text (#25739) * feat: html text * feat: mobile ui showcase (#25827) * feat: mobile ui showcase * remove showcase from main app * update fonts * update code to be loaded from asset * fix ci --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> # Conflicts: # mobile/lib/widgets/common/immich_sliver_app_bar.dart --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .github/workflows/static_analysis.yml | 8 + .../pages/dev/ui_showcase.page.dart | 100 ----- mobile/lib/routing/router.dart | 4 +- mobile/lib/routing/router.gr.dart | 16 - mobile/lib/widgets/common/immich_app_bar.dart | 6 - .../widgets/common/immich_sliver_app_bar.dart | 5 - mobile/packages/ui/.gitignore | 15 + mobile/packages/ui/lib/immich_ui.dart | 1 + .../ui/lib/src/components/html_text.dart | 189 +++++++++ mobile/packages/ui/pubspec.lock | 154 ++++++- mobile/packages/ui/pubspec.yaml | 5 + mobile/packages/ui/showcase/.gitignore | 11 + mobile/packages/ui/showcase/.metadata | 30 ++ .../ui/showcase/analysis_options.yaml | 1 + .../ui/showcase/assets/immich-text-dark.png | Bin 0 -> 36878 bytes .../ui/showcase/assets/immich-text-light.png | Bin 0 -> 36839 bytes .../ui/showcase/assets/immich_logo.png | Bin 0 -> 5198 bytes .../showcase/assets/themes/github_dark.json | 339 +++++++++++++++ .../packages/ui/showcase/lib/app_theme.dart | 96 +++++ .../packages/ui/showcase/lib/constants.dart | 16 + mobile/packages/ui/showcase/lib/main.dart | 55 +++ .../pages/components/close_button_page.dart | 41 ++ .../examples/html_text_bold_text.dart | 13 + .../components/examples/html_text_links.dart | 25 ++ .../examples/html_text_nested_tags.dart | 20 + .../lib/pages/components/form_page.dart | 79 ++++ .../lib/pages/components/html_text_page.dart | 40 ++ .../pages/components/icon_button_page.dart | 52 +++ .../pages/components/password_input_page.dart | 39 ++ .../pages/components/text_button_page.dart | 140 +++++++ .../lib/pages/components/text_input_page.dart | 65 +++ .../pages/design_system/constants_page.dart | 396 ++++++++++++++++++ .../ui/showcase/lib/pages/home_page.dart | 118 ++++++ mobile/packages/ui/showcase/lib/router.dart | 48 +++ mobile/packages/ui/showcase/lib/routes.dart | 97 +++++ .../lib/widgets/component_examples.dart | 85 ++++ .../ui/showcase/lib/widgets/example_card.dart | 237 +++++++++++ .../ui/showcase/lib/widgets/page_title.dart | 17 + .../ui/showcase/lib/widgets/shell_layout.dart | 59 +++ .../lib/widgets/sidebar_navigation.dart | 117 ++++++ mobile/packages/ui/showcase/pubspec.lock | 393 +++++++++++++++++ mobile/packages/ui/showcase/pubspec.yaml | 47 +++ mobile/packages/ui/showcase/web/favicon.ico | Bin 0 -> 15086 bytes .../showcase/web/icons/Icon-maskable-192.png | Bin 0 -> 5198 bytes .../showcase/web/icons/Icon-maskable-512.png | Bin 0 -> 13544 bytes .../ui/showcase/web/icons/apple-icon-180.png | Bin 0 -> 6358 bytes mobile/packages/ui/showcase/web/index.html | 38 ++ mobile/packages/ui/showcase/web/manifest.json | 37 ++ mobile/packages/ui/test/html_test.dart | 266 ++++++++++++ mobile/packages/ui/test/test_utils.dart | 9 + 50 files changed, 3397 insertions(+), 132 deletions(-) delete mode 100644 mobile/lib/presentation/pages/dev/ui_showcase.page.dart create mode 100644 mobile/packages/ui/.gitignore create mode 100644 mobile/packages/ui/lib/src/components/html_text.dart create mode 100644 mobile/packages/ui/showcase/.gitignore create mode 100644 mobile/packages/ui/showcase/.metadata create mode 100644 mobile/packages/ui/showcase/analysis_options.yaml create mode 100644 mobile/packages/ui/showcase/assets/immich-text-dark.png create mode 100644 mobile/packages/ui/showcase/assets/immich-text-light.png create mode 100644 mobile/packages/ui/showcase/assets/immich_logo.png create mode 100644 mobile/packages/ui/showcase/assets/themes/github_dark.json create mode 100644 mobile/packages/ui/showcase/lib/app_theme.dart create mode 100644 mobile/packages/ui/showcase/lib/constants.dart create mode 100644 mobile/packages/ui/showcase/lib/main.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/form_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/home_page.dart create mode 100644 mobile/packages/ui/showcase/lib/router.dart create mode 100644 mobile/packages/ui/showcase/lib/routes.dart create mode 100644 mobile/packages/ui/showcase/lib/widgets/component_examples.dart create mode 100644 mobile/packages/ui/showcase/lib/widgets/example_card.dart create mode 100644 mobile/packages/ui/showcase/lib/widgets/page_title.dart create mode 100644 mobile/packages/ui/showcase/lib/widgets/shell_layout.dart create mode 100644 mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart create mode 100644 mobile/packages/ui/showcase/pubspec.lock create mode 100644 mobile/packages/ui/showcase/pubspec.yaml create mode 100644 mobile/packages/ui/showcase/web/favicon.ico create mode 100644 mobile/packages/ui/showcase/web/icons/Icon-maskable-192.png create mode 100644 mobile/packages/ui/showcase/web/icons/Icon-maskable-512.png create mode 100644 mobile/packages/ui/showcase/web/icons/apple-icon-180.png create mode 100644 mobile/packages/ui/showcase/web/index.html create mode 100644 mobile/packages/ui/showcase/web/manifest.json create mode 100644 mobile/packages/ui/test/html_test.dart create mode 100644 mobile/packages/ui/test/test_utils.dart diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index e355803f17..d100dd281f 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -69,6 +69,14 @@ jobs: - name: Install dependencies run: dart pub get + - name: Install dependencies for UI package + run: dart pub get + working-directory: ./mobile/packages/ui + + - name: Install dependencies for UI Showcase + run: dart pub get + working-directory: ./mobile/packages/ui/showcase + - name: Install DCM uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1 with: diff --git a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart deleted file mode 100644 index 37c412a0e9..0000000000 --- a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_ui/immich_ui.dart'; - -List _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) { - final children = []; - - final items = [ - (variant: ImmichVariant.filled, title: "Filled Variant"), - (variant: ImmichVariant.ghost, title: "Ghost Variant"), - ]; - - for (final (:variant, :title) in items) { - children.add(Text(title)); - children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)])); - } - - return children; -} - -class _ComponentTitle extends StatelessWidget { - final String title; - - const _ComponentTitle(this.title); - - @override - Widget build(BuildContext context) { - return Text(title, style: context.textTheme.titleLarge); - } -} - -@RoutePage() -class ImmichUIShowcasePage extends StatelessWidget { - const ImmichUIShowcasePage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Immich UI Showcase')), - body: Padding( - padding: const EdgeInsets.all(20), - child: SingleChildScrollView( - child: Column( - spacing: 10, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _ComponentTitle("IconButton"), - ..._showcaseBuilder( - (variant, color) => - ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}), - ), - const _ComponentTitle("CloseButton"), - ..._showcaseBuilder( - (variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}), - ), - const _ComponentTitle("TextButton"), - - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.filled, - color: ImmichColor.primary, - ), - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.filled, - color: ImmichColor.primary, - loading: true, - ), - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.ghost, - color: ImmichColor.primary, - ), - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.ghost, - color: ImmichColor.primary, - loading: true, - ), - const _ComponentTitle("Form"), - ImmichForm( - onSubmit: () {}, - child: const Column( - spacing: 10, - children: [ImmichTextInput(label: "Title", hintText: "Enter a title")], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 2bc000db45..81616f8880 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/settings/sync_status.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; +import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; -import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart'; import 'package:immich_mobile/presentation/pages/download_info.page.dart'; import 'package:immich_mobile/presentation/pages/drift_activities.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; @@ -88,7 +88,6 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart'; -import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; @@ -338,7 +337,6 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 5fd8d2be85..86c52d90dc 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1873,22 +1873,6 @@ class HeaderSettingsRoute extends PageRouteInfo { ); } -/// generated route for -/// [ImmichUIShowcasePage] -class ImmichUIShowcaseRoute extends PageRouteInfo { - const ImmichUIShowcaseRoute({List? children}) - : super(ImmichUIShowcaseRoute.name, initialChildren: children); - - static const String name = 'ImmichUIShowcaseRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - return const ImmichUIShowcasePage(); - }, - ); -} - /// generated route for /// [LibraryPage] class LibraryRoute extends PageRouteInfo { diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index ebd8ed8b36..56b7e91eec 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -1,6 +1,5 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -153,11 +152,6 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { actions: [ if (actions != null) ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), - if (kDebugMode || kProfileMode) - IconButton( - icon: const Icon(Icons.palette_rounded), - onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), - ), if (isCasting) Padding( padding: const EdgeInsets.only(right: 12), diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 141f7e5e8b..541b7c28c3 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -74,11 +74,6 @@ class ImmichSliverAppBar extends ConsumerWidget { icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded), ), if (actions != null) ...actions!, - if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) - IconButton( - onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), - icon: const Icon(Icons.palette_rounded), - ), if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(), const _ProfileIndicator(), const SizedBox(width: 8), diff --git a/mobile/packages/ui/.gitignore b/mobile/packages/ui/.gitignore new file mode 100644 index 0000000000..b84f47ac2c --- /dev/null +++ b/mobile/packages/ui/.gitignore @@ -0,0 +1,15 @@ +# Build artifacts +build/ + +# Platform-specific files are not needed as this is a Flutter UI package +android/ +ios/ + +# Test cache and generated files +.dart_tool/ +.packages +.flutter-plugins +.flutter-plugins-dependencies + +# Fonts copied by build process +fonts/ \ No newline at end of file diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 9f2a886ab3..909ab65bce 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -1,5 +1,6 @@ export 'src/components/close_button.dart'; export 'src/components/form.dart'; +export 'src/components/html_text.dart'; export 'src/components/icon_button.dart'; export 'src/components/password_input.dart'; export 'src/components/text_button.dart'; diff --git a/mobile/packages/ui/lib/src/components/html_text.dart b/mobile/packages/ui/lib/src/components/html_text.dart new file mode 100644 index 0000000000..72b54b8da5 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/html_text.dart @@ -0,0 +1,189 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +enum _HtmlTagType { + bold, + link, + unsupported, +} + +class _HtmlTag { + final _HtmlTagType type; + final String tagName; + + const _HtmlTag._({required this.type, required this.tagName}); + + static const unsupported = _HtmlTag._(type: _HtmlTagType.unsupported, tagName: 'unsupported'); + + static _HtmlTag? fromString(dom.Node node) { + final tagName = (node is dom.Element) ? node.localName : null; + if (tagName == null) { + return null; + } + + final tag = tagName.toLowerCase(); + return switch (tag) { + 'b' || 'strong' => _HtmlTag._(type: _HtmlTagType.bold, tagName: tag), + // Convert back to 'link' for handler lookup + 'a' => const _HtmlTag._(type: _HtmlTagType.link, tagName: 'link'), + _ when tag.endsWith('-link') => _HtmlTag._(type: _HtmlTagType.link, tagName: tag), + _ => _HtmlTag.unsupported, + }; + } +} + +/// A widget that renders text with optional HTML-style formatting. +/// +/// Supports the following tags: +/// - `` or `` for bold text +/// - `` or any tag ending with `-link` for tappable links +/// +/// Example: +/// ```dart +/// ImmichHtmlText( +/// 'Refer to docs and other', +/// linkHandlers: { +/// 'link': () => launchUrl(docsUrl), +/// 'other-link': () => launchUrl(otherUrl), +/// }, +/// ) +/// ``` +class ImmichHtmlText extends StatefulWidget { + final String text; + final TextStyle? style; + final TextAlign? textAlign; + final TextOverflow? overflow; + final int? maxLines; + final bool? softWrap; + final Map? linkHandlers; + final TextStyle? linkStyle; + + const ImmichHtmlText( + this.text, { + super.key, + this.style, + this.textAlign, + this.overflow, + this.maxLines, + this.softWrap, + this.linkHandlers, + this.linkStyle, + }); + + @override + State createState() => _ImmichHtmlTextState(); +} + +class _ImmichHtmlTextState extends State { + final _recognizers = []; + dom.DocumentFragment _document = dom.DocumentFragment(); + + @override + void initState() { + super.initState(); + _document = html_parser.parseFragment(_preprocessHtml(widget.text)); + } + + @override + void didUpdateWidget(covariant ImmichHtmlText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.text != widget.text) { + _document = html_parser.parseFragment(_preprocessHtml(widget.text)); + } + } + + /// `` tags are preprocessed to `` tags because `` is a + /// void element in HTML5 and cannot have children. The linkHandlers still use + /// 'link' as the key. + String _preprocessHtml(String html) { + return html + .replaceAllMapped( + RegExp(r'<(link)>(.*?)', caseSensitive: false), + (match) => '${match.group(2)}', + ) + .replaceAllMapped( + RegExp(r'<(link)\s*/>', caseSensitive: false), + (match) => '', + ); + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + List _buildSpans() { + _disposeRecognizers(); + + return _document.nodes.expand((node) => _buildNode(node, null, null)).toList(); + } + + Iterable _buildNode( + dom.Node node, + TextStyle? style, + _HtmlTag? parentTag, + ) sync* { + if (node is dom.Text) { + if (node.text.isEmpty) { + return; + } + + GestureRecognizer? recognizer; + if (parentTag?.type == _HtmlTagType.link) { + final handler = widget.linkHandlers?[parentTag?.tagName]; + if (handler != null) { + recognizer = TapGestureRecognizer()..onTap = handler; + _recognizers.add(recognizer); + } + } + + yield TextSpan(text: node.text, style: style, recognizer: recognizer); + } else if (node is dom.Element) { + final htmlTag = _HtmlTag.fromString(node); + final tagStyle = _styleForTag(htmlTag); + final mergedStyle = style?.merge(tagStyle) ?? tagStyle; + final newParentTag = htmlTag?.type == _HtmlTagType.link ? htmlTag : parentTag; + + for (final child in node.nodes) { + yield* _buildNode(child, mergedStyle, newParentTag); + } + } + } + + TextStyle? _styleForTag(_HtmlTag? tag) { + if (tag == null) { + return null; + } + + return switch (tag.type) { + _HtmlTagType.bold => const TextStyle(fontWeight: FontWeight.bold), + _HtmlTagType.link => widget.linkStyle ?? + TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + _HtmlTagType.unsupported => null, + }; + } + + @override + Widget build(BuildContext context) { + return Text.rich( + TextSpan(style: widget.style, children: _buildSpans()), + textAlign: widget.textAlign, + overflow: widget.overflow, + maxLines: widget.maxLines, + softWrap: widget.softWrap, + ); + } +} diff --git a/mobile/packages/ui/pubspec.lock b/mobile/packages/ui/pubspec.lock index fa0b425230..c74422dd97 100644 --- a/mobile/packages/ui/pubspec.lock +++ b/mobile/packages/ui/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" characters: dependency: transitive description: @@ -9,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" collection: dependency: transitive description: @@ -17,11 +41,72 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + html: + dependency: "direct main" + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -34,15 +119,71 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" vector_math: dependency: transitive description: @@ -51,5 +192,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" sdks: dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/mobile/packages/ui/pubspec.yaml b/mobile/packages/ui/pubspec.yaml index 47b9a9dd8a..d23f34f1a7 100644 --- a/mobile/packages/ui/pubspec.yaml +++ b/mobile/packages/ui/pubspec.yaml @@ -7,6 +7,11 @@ environment: dependencies: flutter: sdk: flutter + html: ^0.15.6 + +dev_dependencies: + flutter_test: + sdk: flutter flutter: uses-material-design: true \ No newline at end of file diff --git a/mobile/packages/ui/showcase/.gitignore b/mobile/packages/ui/showcase/.gitignore new file mode 100644 index 0000000000..b285cd608b --- /dev/null +++ b/mobile/packages/ui/showcase/.gitignore @@ -0,0 +1,11 @@ +# Build artifacts +build/ + +# Test cache and generated files +.dart_tool/ +.packages +.flutter-plugins +.flutter-plugins-dependencies + +# IDE-specific files +.vscode/ \ No newline at end of file diff --git a/mobile/packages/ui/showcase/.metadata b/mobile/packages/ui/showcase/.metadata new file mode 100644 index 0000000000..b95fa4d74e --- /dev/null +++ b/mobile/packages/ui/showcase/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: web + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/mobile/packages/ui/showcase/analysis_options.yaml b/mobile/packages/ui/showcase/analysis_options.yaml new file mode 100644 index 0000000000..f9b303465f --- /dev/null +++ b/mobile/packages/ui/showcase/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/mobile/packages/ui/showcase/assets/immich-text-dark.png b/mobile/packages/ui/showcase/assets/immich-text-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..215687af8f9aa89413a7a88935cbb2e82be731bf GIT binary patch literal 36878 zcmeFZ_dnI||2Y0Y_Q;9s5lWPmIQA$cg-DX@WN!|~cC3Shib8f~0x~}{Ey6^Q6rlGD(NybD5K@g?NUB!nGM8*t3_`xJZ z;G2oiws!CzspDN;7YGvI!u`W@&y;h6pi7X7;;l#Si7R6hPRuH0S0`_>hFi1|IPs^# z@zXPwaVt+@j=MODseg9FLP&}f~bcSub_Y6q@o7h&WaimA&%lz9IvWk~$v>_eyB z;CP{coQ`dpn-in3PrY!-MB$&`AtDY!3Na()`1U_^+8d7C?j#8RLXLlbtQ9rFL~%`X za&~h0HEi%2aZO+2#AMI>*c<%c(0 zu@FHkT=O!tx8`lli*61LmjA#@3L32R}~31sq4a87$mvhh39<5R@`hhvh65oSWEnU7OlIJW1Vpk^?YK|aOjwenHIe{a!{ z_D~F4|4jTAHZ*h(<|Ji=xeB15l`Z~91WD66{ra%b zYg%hflFao2euzA|emccJJLQYO1^GB-XEEsnYn;!7S)=S-=<&CsJJJ6hO{>zqaY;E1 z%hCP+VcH8W%Y9C8lIedW=4tb;tfRPvjlCd#j%hQh{`5o&N%`*~)G!#37->$~IZQfn z-JRy=l59R*1|;3*CI2T=8bN6sE4uELy>s!3Ai{yX`b{TXqT)+9ZDA?oyv<^=E; zLJSSkr2!pbPDhhAGuvT)t2Tjx+Lw64uqbnx5EOT|6Wb2xbeZO8aWrZNAOthBY z9L5x~&zkvh83~g|USx%PQww>gR3>J7hgrXoZPzD0JK$9-P}cC!Egq!YjE$mx@EtNp z-XEZM?KfKw!-YX!0>Q`6hlH*S2!0@A{Iz84o}<=+y`N2RrkJUxJr`T)HG?Uti&J2* zO$knO%u^!h-sHjTK&3jPd(np`G>%`nq<^f&?-{Og=XOtVBL8Zod@X=+Iro*xM+1tz z9Z^oq==eEk_6?Q8QT?bZMR!W?<^m@`dyf_dSLMLAFBsj|Px~w93t>1VToY4sU(kDW z$XN2-6@mzE8et034~fOAmi($KX~Z856q>w`>H6Tufeo?eoPGwZ&o6<$+9>|ryV92) zrt_oz)szBIMSU7VD3y};=I;prcW8dm9gz&=Mz1)CQj2+1ciZpbNq3575(Q#i=8Rml zXHsz-+Nj+2 zsmuQoTCpmA0dDKBh<~LEeg}p<SW*ypz0+`_59RxisAKcod1^|RFi7es73u2RxMsFsZH*pX4!jZZ3T z_^9GqaoafVU-g4JWKt|j%{xGxqpSbAu!T3YzSV68EvSkjQpo)~4#*dAs-Z9AUD(ptP!CyTX@@n$Pyu+GSWQIr?1)I( zYQ4~8DQnNmCDRdAKK0L@eI?0Ha_t7hFwLV}2h;zvv?w zPkM&yFR!rJ4PQcdN% zS-nhyXWV_ZbXEg(RfZX^8Z9??HFnYN(JhU$Lm5QMMJ_tLtHymWv`Fbhmt=)cY9|u? z#@48+(9_ybRm2?E$To*cL0dtC}CNcbOrv?eJ zOyIau2Z+EkXljDpDhp42>;TMX_1R?sXRw`ML0%G$N+_dGzyic9XX7-+jjWIz{B(AG zNDahpEik9|0J0h`-bqzpbAyX~Tqc%NK3j0`UD92y2Kg2QJMu^p=H2MLm@`}!O14G` zHJH7$pl-i#5Nv5gvE!_Z45<#!UMtX`v&|aieWdG`k*s~@K(!&rx46v!(!i{CqNDYd zx=F6${XHoi=uXbtV+sgWe1_%yp~8CgfAOP?n-xGkWd$Y>33MJ}a!mjyEfulCY1NnG z5u)%jINLaged+*zPusKDh5*{x=fBO9-=f<27e)e5QjKZ=2!i%+UA;G{lF*61ZFM|5 zk4pz2c?ZknZarW@003lkHME;Wb7dn#ZD>Q>0w(fH{NLMfg{&Mdiky9teqe$1y(w`P z3#2|62RNF4k6oS_6@{RP9{vKU$Kb5C%o2_>^tvws=-qL^0DxbsIt-x0_(Vzg!3aw&33*dWH*`*Kqt?Ee~~%ttMf* zqIz}>uyl4?23Rj89@?SB^7+%Nh(saYvja3FJ7}QP2^?CJJO2cCsOgSa8V+t&xivCglxD+a#|0^S;#m?KYi2>tU3EuUJaa) zdIKk&P{;d7KHl(xLeAEz{2xXDd^caljx{@&VAsRPThswDT{0os<3= zd>@?9c62Y2Ktu|A(kLq2-jc_+w9d8wI4Gv!u7#_By2j!n*lV#x@C`l_tgjw$V`q=` zXRtlu2~G(uBMMC_k8DvE;K~Up*Ux^gy#Zo=u4w_FBlfQW+fzlIDInHeJ;i2l$>3HG zb`O;^GutgTPcFe`m-jx!zyg(?!L^J~AeuX)drF~+ zAIO;gqC)uur%FCak;=ew;6b0idaXv*9=v5ohoj0R z&U3f?WsXRgv#ulS=dK68CyhGu+ub??4Tq*@ILjJfpj5N!Iz9V>s=1!;hJnTNW#J6 zd3|hcO9IJS^B!0ShOC59!j151uyJgou*5{MS$kPqBU`X{)AH?^T?G^#;zQ!qw2A5P z5J0pb(8H4#lcs|wmKvNZ6~6ITSNCw&8y)B~`-^~MoKX`R!?y6*0UY!0-($i?-dEY+ z{M4m{b)3eS0Xxh9a~d{>^_l;5UjWt*K0qb<#zxr(E$P}I_Cwj~50iImMgbplZar)4 zp?hYvV*m~A8Yn!Ka#3C8^<>>M`x9m#-C&r!WXq6NZ8E5zsdN#y24Z-V*F9EvAD4K5 z2p)ot6w6hN1#uor@c|NR-`#%}tDtW6j1{^F{O76wTH|+MHSaNhwUZ)#4Lz2c2kee~ z$SMW1IzUbFXjq-M=GDIoo=U#*dtFVH?E2U97_L=zqG8%nUfFo;=#MFhaVDVvsj>_P(_> z?zom!RyZQbS<}Af+24p{BX6r)KJDB@)A$AOvja=du%k=T?-a`ZCf~KDW|p&4l4CGh zAW|pBJODMFh3LR0Gz^Bzi1g)N^~NtppMf$iUvw7<5ABL?XDB`eb~yaZU23ERV$rRa za|iZ5xBqk4t_W*XrvgDR3y#5ge^(XI2UBXbAC}}`wli6|f1Lq!tWY6gDO3XjK!b_P8pyG_aSplO@FM(WD9D3LsKsqx(4fwMY7qEi?~EyxrZ^>>1+nk9 zmI_UR8cY<<{jL3^-oX!vpcVnZ8A6`|GRWbFoL*J!UcO}c*RoR%k+Jx;>)&JdCclLX zY#MX;Z5gH%jn!mBQaR2bh;Xl3)Pyf>$O&$Va*mTS0U@AJ&Nsn^veRtO2;K4vlLI+V z<-d!s=YM(d!5Y88q%PF@auxJ)GWsZEd5h5H+tA< zBCbk<3;8ca-N6Dr^D6EMaN55=F=XAgcytTI{lRCD8eV~;WGX4@-)Yulo+vj4sqJXSUukN4jUkc+QMm=|2KgZ)66O>yDF>(^ z&k+TMw%fh9L)olL!RAHx&w%^u zoYO{{2G>V*#9*6UK!jocfVIcP65w-KsNbE^UlTgu0!F=?-&R$@e(7s^Lyo4BY#l%^ zowfxL6|BPgvjRXjz~f%or)YBFn4H!AHo668{3_|dw`2ncqHPaUXohsF4F7O)j?aIZ zzbLER+z)uy@el7b3i^zVg>h9kAboT&W!-SkctOoo3%;}a$cxd^z(?;!{{%_1Y@~I7 zFB|MCH)9r!!P)#Q8H_l)r^nx zS(*usBd&{zLyjZ6q>UYNfcw!o0U<4V^>sc|<@C& z0Hoso;XzV@@GVtP^a$rb10#92_Pgic_ZI_<)S&ola5z+^~Pl6juD$9Rn^HPd6itbQ{c*Q2l zW6|kI@~h8xtkd$>YtroyRO(U4#<;mH+r+W_1{Sq`Q}IZ$^H7#Gij8>>Pu>QA!S0P} ztz%o0>*utVnMRi1jr-RO$(@`Wu7ku-qawd!u%p5Ja4_9ASlhm*AffR`lu>ZppdG3qpDLy1`WEU|~7DzLa7B;$7m~9)i_;_(y`3bgyY6-M_K(k^GRL!FO zfb(u2RodD+LwU>R>i)~e0OkMT@l!>Mq{V}Twod(IE$KYb=3DaACyURK$)Pz>L z0QXMIm)Tw2n{nEnudUEcKeCz_t9GaW*)0(t;TwR}{$e|7USeWew(v7aAcv&w^(Pgi z+7xpV;}v0tVI8+2n6Y>d=lJEd34b4w!S!Vu+X#!=(}i4O;&JX9pXSA=@vX+xqGJF0P;O7`#-OvXQ3$dhr7u$wWH0)&0n`Dx@q*LnmS@BF)3rs$JGw6%W2&c=iLR7 zPVBPV(gaYxN@LTipiR^cbu&u&48jI%@mM@;3Vtl!S6(sUQtx`VDh1rqlg~iTm9D>G z-e=%h)x7e)BM6Z!Fu{0REQ7b>7*f-Fo{j4sT(~FsT}@Fxq%-9CF9}Sr&<#i>VEAZoha8sf>6=FlHe8Q}$ z5&Sudy=BQWC62dqNt@^)Lf{`0Gi)(bb6S8C}2m>NN-6?cjNF<|%R%J_&be z=Pf2{F;Hkr_e8tS*bfd?_$9Mu;6c83w?3(m(7U}Q=-qx_X!4qf29C&kqWMY7?NuR% zymF=%-TY$v?OU>9PVCYm(DWrMVHX%aI9uHol(*k3uXlh;{SW&*FZ`bu4kbgXKBNi}4mr(1Fb| z_2A|2Md;@)hi@<;h<5;WPkQ+1({Sl1&=&1?Oa%c{b(ip9BNrfxq;X#Ol+w`mMf?QG zBToJ!yDEmIgC725E0oT?sf_jwwv`tNIpbcSgQG!BJRu2sZ~DQVAe122!&4~Fq)w|A zWd%IV7!SfVQ!eKs=R#`UY6Pmeyt=+#=?vP)wBNIA1-+?~eh`5IQ%VSpme21ixfV}} zwNNq{!L_*ab3rfm9{wJ|+@#+6lT>0<J!S&4RRq3C@bs{5RHg1BD2+k(U6;6D*#L0R4Zmy4&)-EUF7h$cbL>X^93pjI(U#RytT)R3VZpx__?oKDj&6a~dpWy6Q>GVO zw_!4~Yjm|B!oa3n?rYtEeOi6bJl2CmZ9_O=a_a5Q{d;k32{YtG?T%aIiB#j66p8G6p$l zQhJ-mFQ?+^WoHRAWv#p-F_OP7S^yH-zq0^pJ@X6Zd{SZ7(t3mF7ZQf#K@`MO+_{@8 zpi9^1kXKztcrZ1D5UTCD2)&P`3V!f`X{#>WbMNM3r+T;Q4OIoPOpi_73f^E(pNPZ2 z-tqIaw}uMuCXq2$>Aq}!YDSbq&YY#xRkGc`8}e1{(c(~W$MbtdJF2Q5js@I}(w{a| z?M@v_-bM#rMhtP?MqlO@*}%8Ly54Nbvng>kN{|=_YFNPaT>S31mkj6gyT2t6gs7*P zU4ts41rueJ=7qY|u5Y;!^6#0A6n8vKd|q?=ge{FZCRJ0T)o^H#Fh32MCEXp@>kBKK zdK{OVkw;>Xqzu=)IwJ7lm~uFK^vm&0lTGUfi(7Vdx6umblf5Rl(bA1?5gRXW7X&!B zof6`jc8^$#?){PIkxj!=(Fo6yN+Pk>X{+3-EARFGx;+diTqU!F-`sk3B23sl89o^0&Q*Ob7B2boQCD9Q)&)Isvtqa z6rPhj^F}VJwLOT4?XF8rIFBFAoXew`VWqX(_cCvaMS)hmOTweES9FWOrJ4SE$de#+ zgXL00RA>6pPF;Z}D_y1AH=^G|lBjv;X6F8de#1S$K#C9`w+$?sDirSG2c!mxujQmnUm1b68(PI-F(ASdZ0Gk8Y2lBC-?3w(G zf#toZsv~S!mu9}1r+F$x6*CzaB2c%B6e8ZvU?2ZH45bTU)sKYhQnr8@!R&5p%k@bz~2Pq*|sR4Z{(a zGnI_eJbHD8nv!^E zahewiyK&>z)o0zzCCs2&F%WXzK9KY2?)+KZMKOg$M}tJ?dM~f66xWBRq2kL5j@Av} zY7+*J8iSWdgSW%f?8zpFvvhi%PPkT7Dk7h`-)8bNK+E8vS6v>ZVYSz8FVNxOUVy90 z9flVYsd%ttjB44KVEe8tXkDN9Bz0mIAZrU^Y&sa|r)h?&%bmx>bJ1kC+e|ZN2}#EF zkiw1pdj1fY5v&g>$kr}Xy$@GoBHOx>Yup3c+#3(6Or+Zyb?)|f?_WdvY*4XXket2r zt4PUO#5V4IbVrk?uvv+6c?d8XaDKM~X;7PUp}$rAkT_1s{k3dgWrN9qu3>ODkH>Kr zxNn<*BE1KiH<9KM?R9<%{-$5cW$Hv!!FPC3gf%)F6k3JVIbVx^eiWu%f3~-4Qm3DgdLETJWhe;~R}~d#>U!f`b3QGj)&srxsvNc% z3lU{fi+B%kJGv)UznafrCOM(;eHSx>Ov^Y1rc=zSV^zx?%{y_J15fAzm76Y=lzjND z6eO5XqWI=rnGDv92<-+auqc&IT+P5ED0A)pBZB1LxSgQTV{hl4T4WPLNNF^?jF7%? zVUhP1Ht;y*VL_^ks5EPL<@MH)>7UOf*K8CMK}`$@4g)t>;2p7aZ*Cg%L`X1=piXJ0 z?z*#VT!KyXqPPyVCf^~W;KP+T`XyslnDx#TPJvH0#T}w;O1Y~{W`~R0uVkwDBM@+r z8-7JJI@k8zcc8GegD`H_s3$@Gu(`uopWk`g@x>&jv{lQObE&ebn!2mHG6S<_5;gugr^8y*?N<%2YrB?${cTY1?KJGSzNW2JuTb4`y*_Y_K z$o{LnlyKs*3>I#_7?>WqAN6kaGjHvEj6(N@M)nn%JS*69m~SKk`>QSgS#sKFPu8d( z_NMBdU6aZ@uqdJIrk7b=4b>-K8LHrgDOCtFzIUXB`DNt2WtH`3bH4iquh2Zcj|r|y zo)UmA*lTQ!JlW8%X;UF(@3S)gOhUj_H7|mQ7_wzKx=2T|7#n3}(utrOkIlYQ46 zA>z7RqA6Gyroq4#bV zV1A(InwQqKllq|NAYI`N#TWCaHE|?I<>kb5>A>%VqD{Z-8O<9>W8#VP zAKUPx!$Qki0$*#qT3@ zo#ta~VhXU$HKfWMp9aiIHuU@EcsK<{j?lar&s~!0{)hxMMz&HC?C{oBr_$*$7xvBg zBn=#vjVH6X>N06~;bO-Ii;}2OEhyR_ zzmv7dKNM9aAbb%kOZ<4skuJu(cWy%%$A^+)YB+c;T8#EEtCGO?s0 zecT-b(LV_e2G!P$uj>wLqq46jeh3K7p_1Z53&*E1TCJH#KdLW>{jfweRlo9Iup|Dw zecJ1HyZe>NVS3b-cAN27nP_$ZHpbHCc*32$ZTAq&#i&IPqM9;F!|R)>Y1wYZ$y5!x z!4vy6&v{viJXgvWz_yq2URAT>sdRgbchH?pnV7HjNY*cnjB#wYa+xL~VNM{mMd1BY zKIDfu|6znYXj0uZ=s6Wk0m=}}@JI}LDoVagWLLROq4g&J&zl{;1$4*3FED|I@){5@ z77QFp_i_TG8>+qhX)+kCHPp8M<_#pP3{rQ`VIfz7=o7ua!Yu{&zLAALqR}E;9TGwP z?_Fp+URuWaGz25@BJ>nk-S`_E zvlgrPR6U0>RiN9{OMYBD{%{Bz-1uk{k!m|WA}JPqjxue;Utx-D$jYVTxx>|)eb*zx z^^pTL=Uv^zwgrOhiK%)`48wOeDx#^yGh{)dlcM{O_ID6p8Ak1d_SgMxGxiTmG;H$% z=g`*6K4dEegI);1RaH?O3`Ln8YGlz=U;V4+t>yErIbGf`>Xt>@u}TP=q=mRq*vm<0J}` zZZ-o-y}hYYgZBUqeM(iI46m!m=O^b6VdAr;z#a%0XaD-}nLmQm0sB*KyW`i>x%ScE z1`QIX+jgGi%6o4*(Xpn@!T@S$>A}h#7;^Dik zyQPq0P_Vl|1-jOep0XOhd&df;s~SG9&t{T;CsHod`a!@7q@nafiqr4zexZb|*rWT4 z^Q9yPl9M;SguL$Y+6N=Ssp&8LzVh~%t>K53V`?)ft;Qz&sTk1bH6zFgsnYQ5O;KOB zf!*!yM8i|toZ_pYYrOk^WRa`{t{WCTKp)5gx!gOmFVOoFQl`OcuAY78GM)UEBl` zyPJAfQ-4yZ775T`Q`YRz4fpuU`_9Q;LjC?ziw|-=emdnQ$>$VG;}3P`gFK&9qujjT zgVxHZQq5P6Budw6o_5Vgax}kzJ~p6Mk69Kj;SrCh_C&eRb6S!_@LqeQ%C=aKmmvJ7 zW+wR!XNd{w&_A|!}X?Ogke#T#8oUQ^D5!4ebfHiu9DZT@aCWlc&%vE6uuzy+o` zGD%LBMBYZ(nc;B9D-1QwR%A=&C8eZmlN5aqCSMtRN{Ut9I%geyTgfl@2A$)2FW|&? zFi^MhvDXU$n%i1BTnR^W61-_MeGR&oS{*FJ;7Ge+Po=YxyC@?enn`Kc_iFNeZxs?sCH3AFB z5!JzFFi)kqHZt7ZT%+#`_xQ6Vh)tIxW|q=O0Hd2#J8P+nUQ5&GI@-s-#7OsqNf=aZ zdHvpaNj0XCAP3xDMu$U1f7-}ApU8Txnps)qh~+2Zcdee11#e{62`T*zWEq(8yE&35 zSk{=~(hkKWhI3#dP2ny4gzxEFli$s9DZ!DJ8nID!$+8b7mQD30&gWoK!8Boh$q$`K zBVRhbTUT7Da#~#5le}FQ&~84Cc5HetUD|hKk$4Clvhxyg6e|jo)F=Uou-vf67Shac zM6mOwer95nQl7JPnS>W3T|uh74zJ}{&bCaV26{K?Nf0y3%&@a6+aC)M2nR`)4z!AO zDBA@o9Qq=au7^3t3iXj8W<-!uRMa6 z^VxXE>#JvK0ng099q&^g=YDkA-kxl70!GQdBv8`FJ{u zALAeI!kIk3KHQj-v?Y_b_gfITK?0v@|LxXWBun_#Hsh9ytVsVKz7Az(%A5FIk}pD) zm!MCt zI8Ds6EH2e`++78^7b_2As;L=JKc!M6#0iun$XK^RY{9X~uAJ20UCIK`17!Y^VE(rI z%9r&YU`!;d@o40o_0*g%qA^ueNp6Usbn%kOCf?6Y<)pwwT0_J=#x^I~{^wtbH2ls0 zbbcVahSy;;Eh9#5W2y0mhPf?JwABFeDyr)Q&dDE*JNhCxT4r##JE%bF=+n=+u4-uq z1r5@lZU!xvt_IVc=A^Nu8eW~c_D%L?GJn@0^`iiI|86p%uzh$O4JOgb z!APG*a-ZGzaq@2T1Dy0JzCL4>N#mz0bXW0wH$OxqATD6>IS~r{;FOoR5kT5m9z!;U z8mgpcY2+{JJVT$xmmoqqU5_qs6W)bXbTy$ukVdjIE}F&-ckNM;aNJzBF`bPM=6uhku#CNPi)zs zTJdr3rAzf3n0=g$rE(-;!mnicLhfLXk9v2pRC(|DQ1|^(uf!V52ssN>%c>M_{fnh@ zMBOrU69uFGzYM3SqMm?K1Fo=2Jjusa^&4S2K23tvOQB>IgI9;7S$GhV+8 z+;?QEi*eGa%YHP2hF<-;0+KZtMF<_czK7Q$D=COAQroHO;H;J}WpeQDn_?bRE<<<6 zv(@t;jpAoinzBgn7ES|4a*eu#tABi{9#uEQ{2AIF?$KsM-QGVx~Eb zQ~6B2Dh!DAHX7Ei7(5__9`HPi!cC3ljA$``F3C~XhsG=d=}>i^+d+lP(P=s_7SBOK zB3Aq+umKW(;fpRZSD=+wKL2bP3Ef{*RFZ`4%RZ2hC$R0t@3vUiqQ}~m6!KE}JXE|2 zEk%2!6S|KNOi`L%OMRp~^@IVGy@=+adzo|)z?Z><7 zP~Q@uc|!??YZ|n@bm=ZdcsMubF4p zuw{U+Bw3f3{B=NT@HUjoAYNg1R1~6`7}fK9B%KDlRQ2BZJ>3OYfBzF;Za`lTl2;J; zASu2DcVU%x8DAb4I}fHnURM)MI^kYE!M&73FeyJsrAweuVH?5|3}AEX-VHiAtf+*u zLY}{PSQ7m?DvDoIbie#;xWS8Lg#;aG`=@R~Ji{R@WA4F}Q;uNF5DaxP40ho`-P#W6 zOV7#VBObo;0@G(Goo+&AP)R+bB^44k(+i|H%jWe0(TpyHN-+RtZU&&Z{hc$n(-<;< z_k!h3NuO(R)I0LqT@$E`xPG60SCTctX6dDT4lMMdzgL>F@nd5sK-qYJgd!?+78b`O zf9F0I&xR})m1qEw@g_23y|M{7>8~!|y`KlJa;Vfpzy0i1de4a`b-7&$An7$bZLzHtYRI5RJ@}Ykinswb6o)UrNadUFiLuRT zAd1p617kWoLz-^to7eu51h-i!@eToLZj}cOjDA=Gik$V)DhbWc?K;QK!bP;w8iFOo3a4C*R}ae=C4`tA-MPgLZO8ROa7fZ-2vtlokReo zI?fN{x%XU?0>d{`s$xM{r-xeSAGzM7`eJKb(K%u|iRIs=N_CMx;6g6IPKfI=&$Ei#e1IqBn8Z|?VxS5 zul`0QpKZ``aI$u7pvzHE;DWs=oN=r+R$q44>*wuht554UvZu!1j15^*Vz-B?TeYRV zxfxrqNQRk0?iMHhXI)?R=iAT?G=TcWxd|%_8!;nIKZMs-h>jK;53~WWveWrhh5c26 z$`D8KL{slF2ju?vi4zl>w|lL}VNXZELtRyxwC6^qJ{{FB*aeq%%Sg2QJH~9rR*zT2 zSqO7q^n>YOj@y@ZW?gM;3|X|Fz2j!%%+eN;Yu~x& z!M^KvvgdcY*IGXmdHXcQ=eS)wZh82*NGH_|MeQ;D;Pm-#NSbBhoslGzPW<_m)zX=@ zz>3%h8^VK|GbKK6Wt3*rc}+d%qv&jhzsOlc|7cmEKAQB13W#y*sIHBztsVFH6&PD- z@TBh8Hv8SEOkOdu-_4>dZ|yPZ+R0lt0P#*+I~&%1+AM0jvp1U$g7*-~1nBD&4->^B zLQ5RkTNT7$H`dzs4vQYSmV|~*l(-1F8_g&eZR{Q^!BXbRLf!Xd(D!>GGv(V)E#b(Uojx>1+sc+#APvtiBM zcAvo+`AY(yTVF*BG?j+W>Ri0D5|@P5~z9^diq}NPWB754LG;I?&Y!Gr<55+%+?Cj?KE(+ zYK~J1;d z6r0F_sDoluSPDvV)f4{Aa~LX>iS-LodRjHJ7G4oP%ct~E5{%E5nI7@B46ZI!S*`K(4-v$w3M&ixC^YB%qprL2v9 zbzpZQf9oqZ%f(O_VUpS$ez?2GP)^O&JXlBN+S9={;ZnrPPEst5F5|G#Vbt+np-EI91f7(HR-#H(E`y+TbscWV&WTL8PhR7dV)#otUYH*DF73>cN+TnFY$){;Krl#VVU zWIA@RE^gkqCXYW6$unmoMx1OhKRLN(F9~kR_)}+XGio*p@(yh zVd=;{L(|Glr>`7Cm$T;)pvwTZ}t(}qtPjhI%`9cVF;g$ScK<~DL zmstrbUb^+Fy1lBi68jeKEY7lKL7wsLv2?Unl?iN_Yi6-P&eTWFvR6g83tcPPK?SkS zi4>jVUZWZ~67|~xtS5h9wzb0>SU@0G9kzp!H_ITXED1H8XKz6qa@xnUW_10ePnvPG z^_++&(h7CEHE`d~cUP421oILhj>I4hXPx-OijpaeJv;nM<)lup>l+=~Cxe?=0*-b3 zoZp@(c5xIcPsr(dThjhRmYE6DEx2AnAW!T=mK~OP!wLCn9Qdhem71=1ls>P*=a;q6 zKn!2}zQW0(75lkT;~`=I9RadXh`kA`(jbW~PI~Hp+-MssZ=;e|ROBlm(0J)r)=TmBb!3i#Py(8noodES ztY28_8}%43ZSMo1_}&j)zY7CvERs|T;~U93k1 zf*}49r#I`9)5N1gV6Wm>pXdB+m9qC9${wM2auJ+%E)9Ox(!JALooip?em$_5BTN6X zO##x4s^mek7Wogl7pLDk8*hCF>B`CvV4S@6L^-pv^pN2lmbEv491tEdE$>|fs7tSP zHX9J``+6PrsML(Sqtp@n6N1(FEN9D4LOHnF^hxR9Inv&nmdYc4d4m(fWB1352~+O# zoJp1O@i5+9Gf5=J&m?TGW9`h2bT(aUkcsQO0L zq(Bx@)06_Zv81G5Nh{>JNUzLGKO-pxDZ6?l4hTz;oUa_;yq&Bi)$qYdPz}(v2K^CT zJL6lZ3}C$9OWMGiDZZ=?SrHgaH}v`uMhR%Cw#yr>6jBU~yrYQNlzaa)FUxQI`k$Kp zlw=IGA{{``DpFYAjC@G5I1Z>BFXHO@$U1$+XMIRoD;cN(n}vVn6Oga0d{I+88ap;V z{cO}L_r?^e>0?KGZwDe7Yso#0JdO0fruJY~v!FW8#v2oxQ$h0WEX541N4jYrLv$cKO zI`Ikv6GHbxtg4Tv4emIoA&;43C{6qTdBrP6NQLrNF4-0s+?68_zRYHUuNuN@yxk@u z1;M6#-AH6RxYIq{NZfkWq2Cvxvfb5J3wI@EyJi_yLgp{HCnH_Vo`Q_D-&UG2bl2lV z7$k{w7}I0Sw2K?{?t`hqSl{6t2gjGruz{0^O!)|H|_zf^)jibAIuJC zxw8L!U9y(Qics+7cmQAVoE%+_>y-_qyTejUK}>!S=Wzm3GvhM^kzHSF})mqzJ0?s zc_n&Lpnmv9TcMFa;_-}st@6)=WWiyHyV;gQ3V{1U9aKUHZjSANf>jynV@JB>bV~+! z*GHLNU;eJ94^D$XzzToOb4eHECOE}e51FkI6Myn4@JUVzw8#fB#cLlBjkpYEnOKQc z=p`(N09d-i)VuecGL57Q_j;xIx~bwnnAGd0u9g`U6Hvo2uYI;Gq>kSvS7_aRRw13e z`$+E~1i1+5|fL|SopgY{(6Bv7E5^f&cwg09UKAtHfeR3mpuqRn_yQITv(H}h|~CA_;qwP zh9^F9b$QPkupT2gX$8OH8XeI$+AGWay~JfB!^{m54=_r~z1_T5x~JquWoTEm>VSAD zyb>FGGKM^CShE>+S;fyMOq|*;O}fw|#MjjHPzUfK)pm%$h?bJrl+BIeJkZ5N4|UcU z5HlLuzGp?P19eDMT>W9WwZc;KE&cNbBc5$w*i2Db;bA!9A&9YJS2P6h*WZy}x%hYQ`Dg6w2B}{sh`Y`@QrN z1^BB2Z&(A8j(@&uo4gTTxLGAGTe6+#DwZsKJ|Vq&#h_YZQMU~}mPM`2Ab2X()!E>0 zRX^@_yfc#R_TmbGsg8q%gr~p+a7BW@Li_QA^S!8jcM>I4SLa7QIPIwdcicFd{mukb zzLJhVb~kdbdO_b zLODsw|Lg3%n zTShrHk@>rBJ$atz^Lc$=zwhgN|546;pZj`W@9VnW*M7gx9ABIn36{#f?nhq9(06|F zW|(4LVMLZqeJ>lr0wI!?LOC!;6=KsT8(v8w;9x4%nL1Nd7W!Y+M6Fh5{|X*~V3UuI~)w15q7VHU%d6wf<<%Jixcu@)7A9r=Q%_{JwHwY3mn{PEYLc9&vw)u=cJ5)?lE zct#&|6C?<}S&C|H#wPk!-D#6)8nj zqK_0L=^*)efM3nEl?h-{7AB_@?E;w)kR6(GddNTpNQq z06$YcHFhA$p|rW4A<4MZu$Y^X@MJ9T(h?K3aCc_)v5`_3#B9&4F6wXgviOqcz5yN8 zmfpTaY$|VH0pu1xYEkw*IKZ_i4<3BsZL8_DN*K9xG)khio&h5S8@1~}->|c1l`;g3 z4U~0TVn_m%9V-|hCD5_V zNYgc&G&ntL?qdbWN;Ma<1QdOs5;)YIG&A)GoQd`3JG_a)gUfVLls;^0wNv)Dt6v8$ zo>BH0@k8fM8(cVF+bg}0`ElEjeqxB__J^ja^(J*ARW{TpkOfTospJd zR!^9dGZHQkTn}_KaNgkN@#q2_lDj~>Vy-#D)f%# zgHe`Yug&>|U=GVUW?9q8sn~ddu0saqGwWq5B{r`1M^H#s6J{B@V?eZ|c9&o1!f>x% z$Fu%>v1^I5 zSKA#*=5(MlVt)n6 zsQvO(a=WGNqn+r8`I%~&Te#Ch%b8W6wvcv*LiHC}HtD?#Q{|wUF~?8}NyM86nT;}R z47C2#=YK>=SZ|QMEvxTAVWzeS!{~2Di>bf+G$Z+>>0$# zG$0<6i5GeY|E9BKGafc@xyBGAcjIaJ;&>{{gIa}={t?n~`!nGCwf9Zdfw#fDkSTrt z7#*t-|=`{r8p zBa9jfPn-+vEykkL(hjgF6>sGTe>nZYPklDXx$e- zuwlLKivCD6nH;{x3igDPEP#>c7$K9IhjuL(cIOP>jgIZIZa&IVN~Ly!4wv0Yp>Q8V zh5)MshDFht)U_?KCPm~i>$ck2ePTg?#O|A4g(=ge(iQUfcku?gB_C8?Jo5$op@D8y z!nbX0@5Vrve#p^k<#X^+N+mt<(5tV0CTKy;v(HF-3` z)U9@9HZctyed)1QI%ww7_AAd*(P5WApa9F;spRgx#qFoh0jXeddcus{z6)z7UgZK* z&cdg5@|!?AgoDUV^f+M80xj?tdJiE*&qMZAxctppfCLP&6-d%-MzcH1gIWOTt@DZF zUIO4`HLMTxXME!hSma@^pV`47-xgG#>2@}3KL>SypMcQTR)EC}nJNQ?JuJ}2!<(c@ zK%AZx-??kmiBP^)zE-CKWb{AcjQL;_PZhW^{b%K|?6+{!^eACkcjwUv^Ki}ICzrcR z3*zBqaEcMJ(m%8MX$_t_aOIA|=COf+Z}vJu32`!HYE&8I&fzE~sGO3hf}AqK91+`n zUa<-v86ibiDE#{l(*SV(pr;^Ds0`4C4$J`4^e02jB>pH0f+my_Gd!>-zq$`_3BS%w z+`~_f8wXpGEb3X=Y;sK2_98fmQnot&Wrf$@9?Xew+{Nl@S_-1fD_tHoL+5m2Q9q2^n3inbJJ-Ftbd<=)rt2dVS(+`b|V*6v29wWBnBR^M3MPgCvb$J#7knb$gmvU zE?S+Z42!iI)aQp!EUfoGZU=!b%ik6k39Yqug$xa)Xmj8#@Kd^cwxtoKK_QAwo{ww4 zCAuu1k!szV7QimY3msnm3^`t*OhyB>bTD4Spfe*!okvGzc5E{8!;Ga@+?ZsW$lGbD z`M=8E4_;YsImLc6Op$nsd5*Vvc`7aw<$?i-UZE>dMG=|O6~c~tn0C3UfrlI-1pY>_ z#yoPEzmi?Glx5Y-mzkhPX7hatbhbI5YKzdAL@~pEx6d^a7$B&pW$$)}<^|lCg!I`QrH5|1J0rqRtBsuT8SR!2C1+O z=YS*6=qW(l;KJU&wfg|3`PMuZMouOHE!`6aGuXWx8^Cb)FNve5F$JUZf-EK?My~`l z>JC$M9NSwO1ImTMwnJ7z*D?DLSLwO466axEA=-tFAABhgdE4C=;?0ruIgq*wnj#3XA_@(?N=LvBRF)-yAVJ|Au~kNe zz;;Ll%Fi$poQ9KdYg@H6D)flg-^zA(BK>P-kBk>e;Icdy84 zpk1XfoW+Da2WETs9@akA>K4^51eHG!noMD?H&F&1kJx+H5)Idt;VpxXKTJcCP(xLn zyoXK+2pk;OD+=zkjx(H<^Wix>-@V2qK+7q_+=Y*O^hiY`T!uB9U2YZZ0f_~p_rGs~ z$2Tn504HM!xb6W+2hHTUdIn{%$)EWkJuN0EeXs6K21?S^eO5w3a0C19&)2X*AOe7D zH#*DYeSbhAS9iNVu!q4??cyzmViOohsstm;?cK}-vL9z>&q14BXw zyxn7Y(-LS4&<=Wg^MDT*_CAD{p}GM{`-2MnjbPB#`{&yGfL#nhr4H#w?e6Y{ngaOa zpRwRW&&5B){m+MCJI!5c()=^<##2lV{Er+WQZu`Hj~fczfEQdeW@z!mv8P+BIm50IBiMg) zI%$vkrloD@B1D-%y>~MWOa;iY>i>{s%!?oOj`IImNMM1gUGD9E?;o|;32wu0tu*|H zc!-r5ijyy@9Ck0$QuIw&tO)Y>A0@L43-^D#DHjV?V1e&H3mQw<%E{5dO$WJvPCNxb z*wdWDZfNZDuNzHH;LZI1>L^gvYc}A`BZ$x*#!_=&YW}0Iv_Y{#rwfEe|JI<@&Ybp{kbtUpIlg(!a$May!3aa`&JaI`iSmzvnRA^G9%#J)=?U4{-s4{0g(w2NZXI zBOvS_)}VzQX>%0&f4>H6q<;w_a#7T({L6HEHiLr*8F_%$u(!C!%K!#Z*mYvWKy7bi zThuTp^qWlnJzFs}JlMUaU}sVDViC$?)YS9-Q6U|ayTIFbfv5f<*nmNSq1!HXhi~lx zM?DA>Jv)@RVta&`f~fU%P#|=zNZIacqp&?{mexAfF1DZytrpzV?JnGmPaCc^fjO>0 z9opYlQBer=5XsM1yKRv7T++7&a>GvAH%Js6St^1eTUW6N@=xXPwEK%afoLBSKeqoFYH@ZA4#n@ky+{7*`R zJH?U(24>j|)~yTMu~6T0=u}XsI5ok8R_N9M`h5;{DqB5G$oWvRG=m`$FA%;4S*+xXb=k4GUpc?uSO) zGS}l_CahlCV~jk6J;i}*af!Z4iRL6&3XOr5XF4ZqaRaBBIn4>MMt@eJ+$y*{^DkU9 z1`^Byu*R7`%yhe2h1j=)Y@Skh!m;u)Z|cBsd#wc z%RpCL;y41km8)Ks{+hBdg#&Z<5C)GI%jKn}n%WMHoSmw|o0`kUaU+~nigi(s(vUnf zy|Q~TgX;vlG`m4$8Xe``GtUgHIK_>q1JE4^@{^eNA^&(QqF&%$L_|`4Pb{)34srTr z6S$rAV*(;*#N>~K#oD0CXrIf6Aa-}pPfg5|Gehxx225M1A z?x6Koyu4)VTrQqz|F1*TSN!A=|2?Byx*LC2hzvZ=u?+Ty(m2hO-r6tnYGjX)E+|qL zg7L`w^&tOt_wH>`LhQRgHUT>e=ooNxQht99?H(x>Z$l4~1t1iU{umZVRHg0C-U<@r zNSOtBL6!OB)Z>Gf-y;-zcGoffS!AG~~c|%KK95kv@?J1H{mEJs0 z6pe%;-pR?vX0M#zx=-xH-n!326IS4ciT6x_q)+y;4f9xEngx6G*E7f7uWG!+$&-76 zw4uy#dZ^~JFxgME={I1?XQ=Pl3xee6f4%_O(fL;q0xKbe5FLaE9_$r&{|5cSh#1Y@ zfMQBKF7@}&_*lL=`{%|rahs|^aSSuIa*tj2Z6obP(d4cqpOLgv#Fo941X8i5*tQ@P z#%7o2LWi8>zSo_rgFu_ih(LjXL)bxx*LDR6%K{)WbYt8vr2ky|rf45hDY+_6l6-Fu zQCX(6eoC&CDaD-dk+pB~l2FT2CYYk#LoytQW;Iic>TWjrq@NqJ(Re1Jd~~gD>>(mO z!ad$*mmtJ&w3mSRTlRH9#A{DMZY>HzM%aTfNS_I`)e^cWj14A0p&RJ$;h-;r)_ua% zCt^?lQ;;}D@Oz=c@`qg^K$B66y>?AZ+LB%K*3MxB5}7|Pj2yz=-V>(n63EA@s@5o6 zrn>ip*`ON0Pwn5EM=Wp`;(*E`yZY_3kR0&7bt{yC^{(V7-GfOcD@y03U91=N9(-ZRM3P-Ej`AB7w^Ke|q# z&f%!(!L-UQNirnfbPU_J7e(Ah6Vt(4FB6qX`0ZHIj~z`rgC&$R-&=@P;+IRlf zce6Q&+S&KdY$Ol($rnKnq1&w?{w@fxp^u``AB3*oJpv56YZU4pHv#%j;Q(AolH63= z4~2bm;oi_9kF0~|^s}Alwjs>g%p9S*3lZI5{#da<&Nu+b6NPT|p-T#$IX~+C&#_ok z@k1E{LAKJMZ0UT4cKQVBm31(Zb#!=x|51I;Er6WH6w<;9bx!sax96-CUcI7S3=F3R z_iZ2iGzL^u%^D8NQOSA}K+^r>P3%~{Aj6mPtEZbGUGuZ%NmD-*Hr*6FKeQ1zKAk?0 zhsu6WV!SmBg7hS<-Z6z;3*cz_}_ zcaF^P2^j|Zn}>|_#GbR-kO!Fep8N3esj!UsB$l~EpXh%2ymtCRySLHHf)V30ib2D^ z#9!?S$+jkh+Xj0?_LGsF1U412F2}cvZ9vF~_}O}jNVPlG-WPuqxRZmb{fA?ByBDHI z`jl2(j}lM4u;yY6JIL^Wu8{sV{eC~A5-rmq&#O`Iuh9H#oGeqG%?~qA0E7CAB2P3) zC-|Esp|trJqVRBXW;Dj8M+bD}*tEo<8R`1T`7kC#j2#;L@4Fci*3N_XTV4IOfv|^F zR-GX1Y9_rW)n!O-Vtr8yoL1mb^+tcl^j2SN{atdx~8KamFl7zC$>J{*NaZE=+S^!jcjD2SStPbUyxhJ zDoMN64;EQXjg%_`dpoFE(o=RIvYMjortoho)Q<&kYYG)E7FQfI_O{&_l6I%Omx|!a z1pwp=b!|Ps>SK+f(g- z^*wIUR*S8d-8o=dYg2gSP|*@b5~&#F%-BuL*F$opnqzO0i$XZ*f;Eb!#P>oP*}ACL z8e=22?;gkKkj8-LvKu~Z*F#zoXWx!W^s87ybL<95;}SJ!!wO4V@{aBig0Ob!FAHzm z&h1XO+Q;oacl2avL9C4!QEhm|ABd zYuiy>Q9G}uAqu;Ty+@XFDz9O5msl#%N|T%&%PU6l3nd%6frgSnCHQ8Tboe5Jxjs!c z_ORA11KglF$?!EasFD^!y_#3%G|(mD6@og?OkMo>N%l~DtI-DcH_HZgPpsm3nLux* zL0!$Dqr8~+bGz?+(HX|DRo4CPmwMB5D=~jYnCwyx;fv30ABi}3;ONR;uSS@bfe&h z1vvEL?v;Q}yny_5R0q9cA1(gI&GZzPE&G;>J35zgo)_A}aI8O(22)RAfufSA38h|6cytx-GsotS@-Yg!p_Y6K{d_YwGvwoFw=@Cux7cgsMi4I}is6O`&84h^#PC|?9e<0x@g zxw3}ny}q6MFV%#YQeiKLV-z!CkwM}<*OoR@d8`a@ESav~Ale#GZd+o2->qtnbZwe& z8zZGL2$~FpmMKua?4!ocR6+LOtSi;++;Cbd$%-$;%jto%zs`yWiQF;T8oJtO8^e^+ zFI1i3ny&hnDJY*%XUPlVy#3L$UXY($s>!or8SEj!Efv6e?{GV6-aKJGKLUT}!PX3< zVpv;KfSeESbNJxfj5A5+Es7z;f)lXKCzCH$Db#rvcT&m54SVQA*firE4H1kayUYnC zb{rV^iUpj`X3|26yxdjS#}Brb#7RDk{f3N?WW1%6hQuipcU3uzVwnla1i}I}?1`O) z!=1FqSU^8PlmK3%ZbPZsiW_8J)YVT~kViuY2RV_z+RoRq_-~jn5xwf63Tk@Rx#}re zCZ~N9{+b;UKKsvBJh+mZJVJZ+%>!b(YSKF-!wE!oB*Rn>1xdU}xUc8cv2Nbziq=|@ zqNey*qoo!5%XDH$IwW7E9Ien55A;i`LfwW_l0+@Cius|-#e6ouvJ#KYycm^A zvq|hHc-~ggQk&cgZMV`~7l=-(+g@R$;fPXUJ3QHOHL_j!Ei34BfwRHACSDG!uFR@t zV_sGDt2MRO_(5w~r8@UO#nY{qvX>^Njm1pky2ZQZ(bR=&_gO2d6z?TyNuPaeB!5I> zT=<#>F^-4=E#OxNx^@3Z%GdAZ)$Z_zIC)rQT>`tL0?RGOjXNjzqbYa#sMt>6fJXBU zHTA~?_qSnzx%sDMUdW`7DT%a&uUKlcHG^iq9*`XKC6VT}?PAd)o)dq<^_*L`Fe%bszS} z_hv)L*ua#%yJy&)74;`BegAY%c5_`+4C4xU5FcJ?MMH`i` zI!R5J5S|~y4x>y-DYOD~4GjHn2_$Xc{B_;BoX5o2XuUIL5%+r8$WUCWe+^3Zb|^S9 zD<^*|q(^E+AvuI~$)RG{=z2F0i+Svk#9Q_Vhs~$p zx4E(qCD;I=d_mMY;=)HHOtD_7BKr*H;pd@d^3Vyu;J+t8AXfnkPb_9ts|VW0l9^i@ z_2^)?DOrJI)<}H$LCu=Xk?Jc+Hr=+Ni?&lSBLYA>xr~IK+K;-DB%i-c%zo?%e+J6n z6n9Plrj2J8)TCW8{BIC)w6Jk95NiJfYS@5@Ah+9-UT;|d=a42?%nfe2J+^&Hq}nOQ z#8Xe%F^E6fo9WB2VMn&#YNex|KowA|emBueU@>5-&<~N>MQ5l7BNCWUH7inU=s6oU z09bOxOfzig?O)kxd331H#r8@tr7 zRrgrcE`^We+nmRta}5I#b;Z%$MUcA~baZKGY#cV&X`zx6h1tVl{2 zpuVr|z&7g1YZ>e(z9$&(85|g%Uv`tm;6o8Lw9*htpkI%?lFx>e--BHs2-1coRpk`8 zDg4ZXrUSA|RF~PsiRHTa^l&X$7(;*xaKnsunI22|{ac^Om*EB^$<+=O@5r6o<@g!| zO>#<=uts5JHnpJ2Zv7-Q<^K{H4MlbON=b>wXVMYGxwU4v70_lTzaE123n+p7Arh!g z(m?q)ECOs}K+`mc_RmAA_x2mq4NCOd_Gyx8kGX!_7v{7dnTm4B{fTlb5;l3OoosI_ z1%e`S2bq!tKs)bJO_3F>STQ7cAHNrAvXzNo7w}vHXH#lvcE4@8H?(vaG=wtFx0nS! zu^_Xbpw3qbBkb^(g?GB~hdO*w)+oaStGNGEt|U^1577rszqhiFVoo)}Jn4QvbE?`> z=I`e(BKAunSse-coS!5T4J)kXmmN*&6z`&UZA3+0{dclYjf>nFxnT^<2n&>jc$eu8 zBQ( zOOaE@JV)F4;i`@S;s>kQl71cue{WUL2K@^suUV0Xe1ry)j?4`ZwQ+PqHE_EK zO65!$JYt)F0lt*DxXxPySm_`*xgg~i=n;2bvm>(n2=dtq0{zRwcjaGvQS1?;i2Uv@ z7_Vy4I1Vd`O77d(JiA|l~r+zM*&`Z2dmvU z0mAcExFerl8si&y041@6{d}s)$BCTbxmZFzbM7@;(q-ioLvQq1kc-Irx8vpRp24nF zJ!%=+;VRD9CpxI|fdM}^yt&aOh>YGs2X8qq@3PtZ#TcqXr(%aP0~$3{^I>l904i~t zr<}AE>7)wGIf?r${t0D!*GImoPN?jXI24Rj17kw^(GS3|WbLC{rwom-N8qMy9*jl+ zH-`<@#eYKsoL>A@nZg1i5@nqEjE=U*BeqW>y+a_t++F%L<@#%2(mSeCm}MlhK|-?n z14x2xo)Z|tw5zXteNHJkxZlx`3N9OKFFMTmPTUU{pY;k|)Yj$yhAF`fFjG4O!ey5v ztm0+p*9*|84Dw@M&uh~%JFr`)o$4dYSS(a0!5zsEx}z811c;(!Ii3?g#&rMwk72K; z>T4yL55??z!4WW_}9=ISmWRG7*$3y{&Tt&YIa%HNhDDXfer@9Xq9Apdqt_-$K9X zMRrg8ixK8mQH%=T`FCu#NdamWG1j|ZI^pe9=x5EjrzU;83XTZ9{?c--?UK)90sJyo8S6I zi0lu*fpXC(g_JZIr2`LlRz1x08OnvSFIR`SF--o@`yEJXokon#W9|w7FtK~;07Re9 z0jg1>i0i?l&&?CDr%+bB`Og^W#7n_NY7Sh3T_b8y8$F2@790)?D4LyLm=0e;|L9)- z&iLNH7U(GlJCCm|YgM8QN0)0nJbn?aK9B{^1PD>2zkhiyb_SXjttM#20&uuRm|TdD z<@=YzZtr_{ZRs|xY6Ib*OGS)|OpH2Dkc&?Nd__PziPF@gN0xbQz^FETOoTy?k6hLC z5!t7hB#`exqvH zW!(NcRra0wyd^aQit<@?hb;+HEuezRVA>_av0DqRnbhL8PX>(^@&va()0JUt z`%?Z*?AI%09j=y7gtGF`ZTppVCK(>|4Ntz%U6<%f4vhv<8JOGzazstiWZzZQ{B>x; z$_KagQMXLh@YNgI_Kjn)o10OVZ+7T{D5od?K<+DmYG&|jyEe1Go>}y{dT(k)e=l;o z@J_4$_O-L^`h9SkN%hOu3oIhRZ(H5KL7>iG*$fefJgsUoA2`mnE8W zo$m{T4!)zY+DX}d0(}Nl#kVGG9bQ4jBZ8#(dPmws)@Rv52PAWrW3(2GlR3$V9t!3| z*|E&H2bh2dk>)F7Lt@J&wRAhCpWJ$)?*M!pye_~L6coH{u!ud%^ZHb~c|5T@TK*m4 zKA)=5+8$Av!#m+(fmZotKi2;q4RH4d_eEE+Zf?Yju6{~_eFEe5`Q3#~_E_KNG*tU} z+J7`u+1chB7LK)a5skaf=kcqn&!=Wb%2OZKgZ{JOd*zOw;>`Z4H8RiwPF(ol@P-#K zrTEMF_EYF#^{-{JvE%JId54UW*B~0*yliLjg(!XG1`~Y*>J0c%Hg^e%UV92?ScuU) z=j9*#V%yT_Pt7855?Td^Rhpq14gIPHu5kxWM`)P#_{%)`c?K2|elXoI4DN2fN9+_p8f>@Vz>#W8BzIN_Fd6;lTU6 zwVWlqs&1+36KS8c^3YNOmU<(#>e&eupHEc*>?SYZ_xn6=L3B{)+rbA%Jp*~FuUJK@ z7JE*!CfOGlK1|HlVmWW9sByILQPE#xH+=}DQJi?*uR7*1eCSz7s%^|4ha9KCb=A2v zy4LnsH-Dye35o8O(0;B4`e)V_V~Y8YIHQW>?BdR+9i(31ktDZ$Y^|_q39I4O5&GA` zc!Xo6k?DkA83kjSux?fH9sG09gdCz5@k43RFHY%!+r-w+<=*M|+F4_HdQjxR@`rk# z?Ewr|!*#4#K_j7Zm6g!5d+Vj!UiQaGq=l3|$;OX0!Nv@Ohua0NDw)5Piai5$Re*~j zcW-oHOx$UvO0(>lE6!n0pqTOA!BRfR7o(QsUNF+x^@L?@D}PFxhhM3p&_bTL?9{qx ze#l^tNyI3Wmu0p5-$%>zW?s#W!xcmFm}Bt}=cVu0g!xU!K#Eb74`byzylTwIxozn}-FKOlx9@Qc zp>?iIg^QK&>sHSA+v2}c<$`QH(r_GpM$d1Tym>&;oM)HJKNw5)h30|w^B)24Ewe@< z<+$9~Ka}22G>fw^;MjKa67}8>+>pf_|J8)p1vv{&KC|-{C4I1bkhxU{$?!dy8>y z(?YqgC9--rWdgohLhEQ{p~vaJdN*PcjjNSyKaU6{?CU(qXnrv*cRC?d`1xHjZsiu2 zhWgmcx1Rq<*bd7Q$zbAx+QIpJ@?}%TG8IPk_dm|c_%jlfnPi8a5}C6YY%g`NYhiOW zr(?wMN;r+N@s?7B7JZ^rETJ*H=kw+G3ombOZcd#g;WcyTFrU)T_07dDr~bO1EAOM~*Bd_WB#mWSzAh0dXMsG$cq5L0ciLcj^TtdLsGgN`*R-cudcv$xqc1 zJD)zSfVujQ?fy4|D9=bZ88NCB(KA`69a$ami+FIB{B?A%IPpZO_$7u;3xqLrg(y>S^sx~Om zX3%~En+Nn_TiD{vb6X$Jygj&bfI>n2-jWiKOYB!~hjzYUor7P=!4*&Mm_U}IiT&C- z;_Vpri6DC(4bTzCr=(U5UC8$LeHBk59h2&FeZTCG-9gO#v@%DeJBR3*LaT0*6pdEm z6~^5D+d9KieT2{QT;43sjYum_!v@KU$%sub0np)FHjM^K^7*Dcwd zjjElm_DO+|%`;Wl)STX_9kZP=30`p@>}Y0YidQVMaD z*-2Jwbx(@8gaue*(?{2HztM}V%cro>{-Sr*W1eQCF;MfVP{$>ZIwWMAYvBYf-qFr{ zHHEFqcD=W;Wlqh{>~>QtV?$YZU@-pBYb71A-t6r#L7f)Pmv8_~=)h9#hR`Nnf_>W> zRAdavHzpf>vDowS#JLRNFH|H|Pq+K4s$x7-gO<5DQq<9*U2OhhIw3)hw%ZZsYUP!gyFiJ2b2Svxhx*O;qRjtjG0k2faw5OLWnCoubVt zL1oWMO}D-{@~OSzimXys+=E(;sp-Mg`3HA-PRV?5!eie3XeN0T`>cY@dgRQyq^n=5 z`~}q~Y>C1Ly$`_|et)eza*`~(@%{S}AzRI^9Z?Bwm zR=jxB`o&Xq<)P;v?w(k7$oV1kKS{Lic}-Ba#7m=FOuZ=WFh+uu&r~Pj z`i>}0a&cxcM1s`4geqXKIN9jFm?t0Gw-jU>hPipS3;$}Y+)3>|oD6O}-e)fw_6+ORdiu(v*^$1?53BVG_uh%eBNr2Jk6_R)#i1!KI8 z`L+0>&ctwAp_*os9&ogY!?+bC+4(f!8 zitXFr$TZ^*sRfyN8PQ_Gor*4WTjq~MJkgBx=y1rRE~bqr2{|u(rvilgV%T3J7-#ar zc>M8>s3*9GhJs{U3HQUO>D>(TTkR+YKhVu4kku4ExyRXgG}*LOa@myIB%QCA+Rjw| zc>3~dnn{hWTblmG@v`Fb|qgt-k z(P>kPjg+~w@@<$0MMm%ZuUT?t=Nk2pi|HGQ&i9&-_*90P82*rPw)RY9ez+L&jZ7kg zLFg_=?Awjg&g9mdiwucoOVt__Wuc6Pi#bmc>@7x)xqn$Mq!YxK82+$hq=sc%aJh{@ ziLp_tT*OK2(?!e`m-}%emCHu8fs+B-LjJ|`CJ%ycQoha>+uHw=!tu#Xg2QKtOD|3@ z(@}j{c4(&m@^DBVzb>9pDM#V_tltU0UaI1J=)-Fl!p@Gqw}PjY80OD+S>xBsoSYBQ zzcizqVoz|sxWi&p%$F}TYe-5gb?}}FbDKcNgKyUOiL*Dowv~dpF`h!neq6rx5?#94 zp0rNIQP%ivSut9|+a35w{R;y`DFHnz0WZHXsU`Tlw(}C_V!X)M(f;l<@r}nO_epLG zBp(T08BZ{kW307K@*xzlOsAt>5d3jJBV88`L1iiKye%w8MJ8+mZit3?mAwrV%I#hPX@k_KrQ97lr zf}XFt-vQg?pkS(8qq`x(;DcjuPV`uf(q@O^8i#7T(qH63s1oa?a0_dQ^j=hhW= z-losdN@oVu+`EJm%yeWKjFWJD^(nQM{mWDkFUIqFqut2Ywe52@_Rj=vM!klxzT%MA z&-42lEWP^cV#g4l3OZqT)B|Zo9%< zSc;)g{rq|%Jl}E*&hJ=yI&EuzkU8slm9RGv?bm4sS{D0|4D+4Iz*{=cj9cPrQk%Iq z816dIEWgSuE(sZM&H23YC>1c`!}NFaR?B#qdcSSwH$581tzu|B&Q{6#&SsHJyQ3U0 zMSIeef7FV@h`DUrHY#o(acUlhB|pCB*87~qdoTs!{d@@)L>&b+LBXL~DW)onRe^ep z{3$&@u6W`a-sGv?d%Zu|(qnAWEpov|MT=#0qrc*f)!Ms3&L_d##w^=+?$;1#K7N%~ zdoD;1_aeQ=R$tw2dzo;cyLLy$ncvis-}EzkW_Ex5>cF+5mdWxSikFtA=KPerNu}@f ztw~iDFUfF*;(`LZuMH3T+1=sXnpmiyT+Y9^p?i_FtkR~l*89i>%iKf8EL&k(-TBj# zAzCT&DvaJg8a7J*nsPIgPUx3s0|XDY)~rc2@z|$(uP#*N_1_|)T9#kAOws9Es zdA|906O(UwT3=6|s3Z=vo$@R0f`_%|k#+ft-MngBYfh>Jqw=2jhT?nP*d2L7^OC39 zYrPlOV1svQL%bPVJ*+&vab3j68_Aa>J1ufU@72W6esU5ps|c@f%T!ZSJc&|O*6KPg zK>c9AWkYetVL$N?cM>$QYh#-ai9C|eOSkCVT(Npl*)uQV!MYsr>vWY<#cBmR*@ev5 zVRs?#@#1V;WxgEI%36x-X8t|T#Ty2!)O})~(|OKsru}N(np^b>)zb+N?1?OABn#_O zMpWagq@L$)-0VzduAdFSRPJn5sw~l_gvvXGB}S7uyruYj(0b%?eqs^AWHNU=$ZgrB z=G2VGDL&nRuk^_-ByCq?d>=j$&%;>Tw)?+3`u9E@WPafG!bsul$9Y<5=2y)?tc(x_WP%fp@y5SCpjz*AW@j=M6|DEQCPC5kz(55o9EVJI?1o_PH zi=L0>-uvmJ1&PlJ2%-dqtlyBTU+#KdbAADx75d(-S2SK(l{lA@|Y J&Uu3e{~y~#t)&0} literal 0 HcmV?d00001 diff --git a/mobile/packages/ui/showcase/assets/immich-text-light.png b/mobile/packages/ui/showcase/assets/immich-text-light.png new file mode 100644 index 0000000000000000000000000000000000000000..478158d39c354a06182383e0e098d8583f470494 GIT binary patch literal 36839 zcmeEu_dnJB`}oTU*-G{ylu=pX*qc;#DI>dNkL>M84kc6+GL98eAtRh)|rHmR9h;v#vLd+#yJi8~-1H&j$rB2)YQ}QdQFTNnRdXd&1iEPGZvC^f0AGF@QS_ zPH0I%fQTfnk5U{Zd&wHIKt_BSp+Ss5TM+Yb8}bo}5Y}*Hb1KuHCH&Rq(b=}C|Mii7 zK|^r{X-{xa-~mVQpJQ);^Y2H2RVO}&?#cTTuiXOgz?g`U74rgy*b&|n2`FWk#!}^9 zIHaSjX?0|To@&5Na{#}CocJxD2TeJtI&xVFJR|c+DdhYYG*mQIurrgKDSFE3d)>oP z)p}1d4no)^N3wqaYAn4P7LnCVa+uj!7~u!H|DGdpr`eT( zj$`xwZyx1v#V7ysW~GPe?`vH0Ghedq@9LeQwyB^X_#gZ|0ROeTtFb!QTCeAIrGI)G(trF8^A7+7N#0+4yk|hkYs*9@ z@;qV$klJEOCM+w>O85^*7xVPOZ&Oth_4=mK(nmX;eOA|7O^Jos>X84Q;h~I%`@-mW z6E*l7v{aKZ_nB=&9pe6FpKsg;T^X@B{74B)%E4`wqMQIlo%=8Iw$lr440Psh1A4i| zw35WelPuQ%p8G;PRd9}OgWnqlbLF7R?6Y!9!qBFnJpTLg&8J9psk1QZ5R$Y1p;wnp z32Q(nNzB6iA2llK;Uz^(Wv($vYg?~ya&ovfD*i7PMOD?8IjxZqdv7q&?B$UWKkY^K zQvn{1|G`7{krkC@Dc8X@CGAb87)ApHK`X`vEGxAU)-Je)Sw!#GN~oa3fDZpp?7cqfLRaj19Rle=eDXc)^uh)kUu?5 zu@kV-v*)Xj`LTU-C8*I!|B!hJSxo~ea^tz@3hFdfj~P?1|EO}M_JIeFV}kKaYEFTk zYVxBiGxB+d%L8;zR#ccyj~cvv$eigj*lC+*RLA*lqY(A=hao9cYxWV>YhJO$gaAxj zZ!snAGF1zw;y6Reo7n2xaXs(Wfvopm&r)OuCL_xwm@#_%5))BQ9z_o!2%~aZwSLKE ziCoIROJeq=xzO_VXY(5uuDA7lUpE|y4j0WKI<=UzKiwC8#OsAoVv107bQ|Jav-!p( z(Cx{CU;P!@%s!|^%aQr2G|4=$T(?A^AO9C*tl6({*A52G8O;Cn6hE#oN}Hd@*=J({BkdI)0`)@{ zhCphb9)BQpgJT5=Z^EV)7Pw~YZ{IDUM|*=>swVj$^o)j`;)R-+@m@A{<$&>e!14oC z6Wa_3D&e%gagu_GCOAYJYYMtC5_o95E3~u^m5DjgY2i2&&5I3Lts=$0kEjD=djpOY zX7WE+OX!7BO8_s14jg1n^B?+DyzZWNCS#i^`&NVVK_{!#|7j35XF_pX;X{`@<6ke8 zb&)kSQ?2JgGK#&--Enp9Nu`DqSMC^KIcIO&7Hu_!x&*#>!Zi&j+qkDbzjZOJw${%f z^prY{WC&%-J+JJ!QMFRk6X0J^nu3S;wHK?%+V92-jtSb;#}}R=F+6&nQfxDVe?woy zO^Nek$fz|_8@jLTI`Q$R)N;aBg~zXiKVf$*ydlFH4LG8E0y8@&v_EGrk~kyymrf+# zij7HwaDk?L<3L2Yhr@YjodNhid!y;)3UdLTHN2Qq9$I2OoPJAlX|wE;9J`;Vo8y^3 z6?0c=uhE?JXop8GCU-{^j>n3&lQj*ZF?!Q|)CsW62@>*|a}V&v0C%PplkIQH&F7SC zH~kyTDV6M#U0I(Zn-v%Kq9vA}tGyjG@2-9NPged=d7V>EUWU$psQV%ZOv&4$s z@*pe(CLq#j`qwNx)THZcq8D`)RwSQCRFIHEwcMGbXIx*MqJY8RqlpPU)wj@SmeZ4c zhNOw9+`AMBAfIJ1aO|)8bUF*vfCs#i`ut{c5Qk2D=BU~!q~Z_YiTCu=C4MJ>2-k{# z+OU^^fv9J%SWgL!C@I@gQqRdBf^KAu#-z1QCKS94Z_p`5Ikn zi>awuvz`5FBn-&aeK_(tPTxV8XJHREM?Goj`m~HT@d$=eW(5(Mwn1lPm(mK;pZmV% zS$M>A3cGH>ey=&|dh&;)VhDkgZa0P0e-6?5^rxp#31u#3s~hE;{jOPA`3+{D{_#S~_gjIEIrQTe|7P^&1_7-Upb`jr6}E0% zCRs})yYvB^2Y)fyF2CS>YBjZpb~naarE$EUoXU7zx)Vv&^Jp#5|HJx?;?XmwG%1dB zTOR3DEKOSceX=fnD9ze2eC4Szh(~ycT3Y-udL9yh%K2PgEIuyFdWbJ)7_I*G_A~)B zi&ww(>E9&B-BYNvexnz9POa;&_1Zo^e(5X?wDVWhuF2+g$OZbzrX7uR9>xlaQ!)_Q zAF#WOfQ&v6L_t`cg?ke~z0)5%klF-ZNvOXHRB>ge>KSThOirQz@BCv0Lylh zy8y4W#gN%=2Z2D*{EwlEEpvGjR1N{hhoHR%9&ZB0iXd_jvNUp_Lmc?(6mM4?$iXah z9PR_(lTM2ZkLKwPB#-CuJ6;dexa4~Kf?$`v{{{$&dNLEFcsVkr#{JOqN)W3}+au6V)OX0ANFA$j&M2978< zM#V47HV_nN9gBg|OzM!w+amwchX8(++xNo0o=J3cMPAy$=QutHDqgV_{n#o8Z z?(p-yu7uNi^pqEq7x9`Co!h zZ=8+&Yl;vg6$qSyl#cw&&cVvepR+JtO?;aJM610?49%AT1wcMB|1=S4KGO?#n7&Mi z=|Bkf2Le1vqvEcw@G2_$x6CxX_!9I)?ZdwWA-<&l_7hr|AHefm{#$uMj=*T+wEii` z=bx_O4Pa8s8N`A}kino9o_~r|dY96K!Q|CbIuMOgvV>hvfVStXss0k_4D=6FkTiN` zJ>!&+V}?50z_~2m)31TEj{vzYmlyo7R!?}H_@#UzW7!Z0yLrlOnod7JaBHIvL<0w25aAF-Q?UpS7~01wi?azN$yeDTW6s;Jbd zV$;gHSLDs{p=qcSLRg{!=QZWv#V)EC8P*%BCZ%R)JdHtxsdRCg{Ca@fy36pe6 zNcZIk5Wv{F{-Eo)7hP{l{og<^4(xo%^}*fv_EWE}eG+t*744VSQ~iY@*7x6IE;~Cc z=Hufg!m%13+Xj5|iY-;^Keuz`w#)t-m@<5U+613w2AbkMM-3#?@*>8x%v4I&?6c!q z7?5Iiam0TOm*|bNLlJyj4p0Z8;Q2A0kWkryAz@{2Y<|V<$SbF`3tuuw$5_UXerAaE z#M80&+&Q*ZDV2&f`+4draxCo==P6id7RGX}4WA;Q+NI?sM<%)_KX1@gCgvkgzotz5 z7gjNRg^Cp~8`YyBZT`wP{{k3GzY0#oP@8$YK25`BYangX-IMXpA!zmd_zw~l;9;|L zPvSvMw{2tZ26UsSj!)-G-y&xYvrx#?-_;YxJ5d^zdcl$DQ^PN5E@zhf zuxXGTPC=mCpj~(7onVw2jbLxb!Z*(hi|!d#ic(UV}GHJ97_}0>O5O68)auR zfJYvq-z`-J_!(z-1m69Hs6mra(cfMdSQY@}Y&D9cP?Zhv{~6L1V4IcKYy;0gz%g2o zo@pKRoIH30BDB2J6(AjeT@brXB$a}ZXFfe>2N=L+#NK;+%Q6AF)Q;0^xh(~1!ThUnVhyXcOj@nG&<`N0 zwuU>9c#bpQr}b1~JPyvCB9&COPjjVX;VZ!ja@|HG&54O(>%NYyP z{c9?f!`eh=La?)=>h)o1K;??TRs4V1nB{aGr00cBIZY!W439#`*eguKPkT|Zxpqq| zSqK(M#&Vhn6r+f#SvhS%eif!IeMZF{L`?lhRI_X8BK{HhPz|1C3u-mF%@IJM1d4RT z_gn!-CY-8;Ry7ij6nw;ZR%`+y7S|OD+!^UOi1D{P$jtsi%leqJ2X+Z*HH|1B9_6mT z<@5Wu{Zs0C;sleDtzD7;(!%v5^ZA6BmY@QtWPMcw?5F&<+5%NgvOndXl8<1>I(`|6 zZ~p0mf92A& za2vQ&co?xKbV@$XUJS_SwXz53PO}%*`jmoiTo9}LPfIjpFJh!#iu&4Sa{IY6*Ob^X zGoV6PH_9aQXwY6{NVVP;w3RpNuW@M)?^T~UWT0c@`5V1-qdmU--uFSv#zgoIc9zFP z7%d(F0oK-vj}N&Tynji3tUrmO+fe#P3@s(bn)%#QVtL}n>Bcwu+I8{I5fQc@i*fnA zGwT7Mum#;SSI2NZXKYd~n%UXypT2fE9?fP3ilxTvK;?@D|7JAm>>A zmgge}=nd5L4X)O7-qXN3XP~VtQvQ+2q~yovwS3}w32MPA;S(9doe6XJ{uOcEnu68& z!>y&t@N^Z3$64dJh@Hc}d ze~a3#!d{O_EBrMju+-}<6P6Pdl^+29F7bvis}hSbkM2$c5YLo4c|rJsn3z z)jG~J&k}a8R+scIx0B)OQS{?#rSV^n?Hv@RAL#otFGUzSBmmrsrqXo4%KVjG4?BTi z`SK=l3mpY(=KBKGYV|yM7C0U*=bpX}00};%w+@*H7OrvyE!!k#gNvlf$boR$vP0&P z0#AK^miLzdNLNHh7!~SG9bzfx#AShe)?4U^VZs!4`Z^?LXYL+Kv@5!E!j1>cGJ8g~ zOUH#*E(G1SQ2<3BqWgbuDxoyT|K2%*+o=Eh%m2v$#GL=Lga5A#{@}}*14|HMy-s#i zR4e_r6nqy^a@MicUicYGN=*$Y(GiSzXVZt?W8E^Z3=W$ng8!s| zHj%uncpnWLTt4x8^QEvw>%db+@?hf=?RnY3#N1^013yME%V-(i-ZL7{66(ATxqK@Q z5x@P%_He0*ZF{*qCOoWWyQ6^NaPqa>W_M4759(9?ua7qS@H`vecsMAjn({z$v319-RbBirZ}@| z#*h0BV{*U*U>&Q?WJga!lyZmnBz#bi+OfN!jI+ER%8C{ovA~7%aWfs=m)IyQ_hzMI z<)nnzHIj@+yeIft$T+JSywY7#g2oIdGuOP}IqDn2*#g!Z$E99#*-1A$w?O87^ob0b zq+*B756k@;o!K8q{}!G-@U_^?Zh|11+tKinCDveg=;1`5Lh6m+@yHSJ7SzsI)ggZH zV^MqOBA_Z1RZZz*_FI6RP-97$DFf^EG;Bn-K{s{!OOj8*;O8^61W0aysi-L&Y%tvQ zIDOU1Mt12~-9S!y9sa^nu8EF@v))toTCa;612I&1HH=(m+VXfq3>{3H#Xs^j{JygbP3Jk46Q=lGpTyVU8>(I%C>u1!j)gy#%}cg<(Heid4x)QhKl@@DN$ z^C3sJrVS^iEIW_;ITwP!@M!+4$8oQ(J`w6xzZH5;eb2Qt!Nev%j9`;(KskXaEwkHd z-1p$zZRrkhQ6JtB{%BSzKTKEz(*49M-9W+c&4E?XBwUJ>=<+FwJ${q&W0r0zO07Zm zywUu-&PDfC1i+Pv9}g8mP&qyVEs)7VY)WzEXGzN3o^0|f`={$?6u*P?2#@n%XV_dU zlG6)Vxk%--&L+{qB?q~R!Lnt(6Pzbe10o8gWL>S~E{jrFZWUj8Ap6|x`K`jpBqwQfhNPnI$6S8 zEe)iEwF=I%GS~@Qs+7IB&>(G13hpF4^rfwzyso}R|I7V6&36@|i_vd*MB_Ov3|}1j zBy^Fn6N)+V+6L1RA}f4$H+Y`bH4J%?CXBoyLEz|;=Sx>conSj$^JguwT-(x)<(sLM zWd2JZ&dDy50ZOB9B}p+_eoa#~$KDVSx~YWQ%vDD6h|UZPPPUsqAk^hp`<^kT?in$A zd)ImAR^%q*LTgB(TVUj3Vyb^OL0XL$^^>SNHiw(v%7hmDZrT9SKYCkYQ%ak`#4VwW zCA#FL{X`(BV}mX`l64Wtm{JO1A8RuX_Tm`ra4sVax&!gf8=6FwdgQ$Ab^e7{oiqH_ zK!>U<#TKwsf}bs=5I|{+UW785u8I=IeW2={K=74ch71i;j_e-f%Rn>O53EikO<%Ya zwqk>m8{{=tQM428{$931SQYAW?7`S4D%WXBMrnG`Hg-HbT;_H7aM@vn;oxeZyvegJ z;(}r-b~B@zEY&)s+Tx=@T}G_ZFne~gZ_~`r{X)xwNhUS%4f&{u;`c#IE_z!1pel5# z(>BPmnNrd3Y_RLy)f`p#V+V0dzI-WXv-M&?r2V8QK({?2CGN=#w+>g`BeZ`buU__W zNKb!y2o2Y7+c3WPNRmuKFUn@1f+9UgW^7xai>B#Y=)&84CWR2t+Wy@IfVwI@_yXwr z5_VO3F{rQ4!%Ig>ZCKF!?U9{WE&kpmL3V0we+6A)U~-;U9Ooa4oO5`~>>=owz#!Mz zV=%(OBBA%;;Q&0AzoA1^r zOX=U-D;-Gf#&0yczN*~yQ*D0r%VC}zD!q*=$n-I+adL0Bz<4cP(V9nyNmRdF+^jp` z(lWlW?OfhrN`~dZYxfz|7nR?X{%Zn@s)Me)n)kZzJfae^aJla8%@` zm@%^tb}J0znODy<4=wRr1zcZXg^w~n?5vZ%bU5ViqV&@eTVnTwZpGU{re8~+!c`m5 zl}nxChW|=10f*^=DwhzhK%816Z!-&6WS^eE9o%dWd>A+Zt#fRQFl>e7sUH zukI|gwVwyQTpiCni7d4PBh`Qo6LRbGJV@@Wjh||9HRIlleU`5s!W}Qhq!9T~3*UWV zt?xg!G#Z8<=&N11?wA|PS@0+(Ur7x~C39qnQ@GFa6Bi}hSTK_Eka?!GDtD!BZb|w>?G_P}0!n|6@~cCE`b(?^<0Dmw)*PEo=Svhc{y-@Vo5=(H#)sfG=u-DFl z{dk4pdhU8J2tO|u>#fX#Q%j}=^R$dZQ{OF|C}U5U;dTp&^j)cH^>b|>#O#FEX4pBN zW(2n8rn0V8z8k7cS5m%3*`e5orXylmQ@-ohJHz1>7-cWea~NWrP{6>iG3cr|?btH) z?z==Y1DwqQve6~D8Xa$zu{j^S=k;kINo(QRrYz`69zF0BzOE4?OgXki6`1Jf2&_Q3o9-FMp90>F}rG-4A zmHwsmj4HC%c(Cj35Bf%>7Vc6Oz+g_~9Jmo`CQgY^a%po0>kYmf^5s-7Y=`jIFxS25 z0=Z0-I8DB2=IkxMjIigNs-*e;kpM4JuusF7@6OoG3$Nfu*d&?VatIv2&7K>3j$^hv z5~uJdq#PN@RSr)Y4eJsBo?(C4F(GIt-jeFgZ^@g!Y5jG`j~ZR^_p6P(kHE!GAt)We zbxw<^I;ZrpZ29A8&1AuX=Qas-^A@*x2Lq08&C$eBRX(KI-^!P<6@=UIwO!A%=;ra} zTsNk+l>PDD;AVHg-W6ovIyL)w>0f-aU)8|HO~U)wcC6oZ>tc0p#Qi!XTwv$m?<#>3 z<_>9sr_XEo{)$@510%DrEK`l|wzU$C=F`PwAZVK?{@XDTJu zDeo2@<~A11=6OUnp7odFt4;e*T5mMC`3y*~bbNJ|0o>0UW;NC!o&B>ymw&EoJm{IY z=;RiRKNUC5#&m0BmZ^|A56!#STxQx7kRxF_Z zmg;BBR$F#Qb(y8Jve_E<)P!AZ2zSa05o& zb7|p(RPi_Qc zt&qTF8?eiemp?u}v}HDIQB8g*z0LyG6+Xtw>HHijyji-ck`>yqS@=$K_ZVEj3A!`t zy#Zq3nJsVw_rKCTXF{jRSlCw-G^jL(5Upjn=^diX9H6M03q@&`yKmu!9FtR&3a zHRd5w+Z|V@>##X;t{2t{)AwG$HkDzYxs|`X`@7|ljt{zd0*ckgzmU9s+@hqq5obrrjL_*Z@zDH7e7u8ul%l&n|Ps`1?jj-P@L}-j26F z;xOW zvJ(E?wROs#z!BGOwN>SdQ%y{_)Kdn|OJ*6|QM7{5nb(z5EP9=z3kLV155jfQP;74= zL7$CyO2J&ZE+-8)66jWxsUXYf;|)RTqj3$`|5b)N}+!%>3zgq-5pR_k1eu#HMM ze7@`052Z1G<2`zN@LFc1JeYCscTIRNK+_x*O9&RDzecH;e$$1ND1!VCa^UCC8V|OBW;5X}s=leAd`|X1VYkgLC zG;JiJ=PGjBgTZ{nGukCt`8ES=0eu+31pW>dgGvOP%}UI%u0w#Ft?UBG0j!*Jan6PpXqH-0-Fwp7@qt^k&c z((;Fg&weLk>GlS@JtJl|fNza3CDD1x$ z6KKfsIGN>CZVHxI-au8xL~PR%-g=6p?7F4jxeF|W>E)+&=8?E-;f**$negHhvO`JT zr=5B*VKAyDmee%Hlv7RHM=(vYk0oJ|A2;J^Ud<+dPsLu^>v}1+E6koZaTsAMrie3bR^0kV zWPI>v!JN~!x$yXB)BY9fLg$h9F;B5^wl>ETJ`^oGhoDN(iaLX+%PNhmsl(8xUQdui zmwCaH2lR+Z?Zh6beR+(V8ICK?hR2y;TA;wi9+Hj@{wt^&-LRL=hojri8$qYk^)Oyb z;GK+KTdku^8W=uh2`>DJ7XrSZOqs_DeR&`58TzeF7901-ruxw_2ig)-?F+iG$&gYC z$>xhY^JXx(CU1|2hHcDJoXo{Qi+~PO_0IOi6Dm{jCd4+EE1xZyjvt3y&Eq$Zv!ucc zjoq~~5;s13bE@=3)}}Z3`E*;L3A&gR^3_3`JEO2-EdB3i*SzwzPR`A0bXlV^Sk9-; z3KApj=K{&u@(27q3RWB?DTp@Nw3QRS7~0`}Jp>DaR`;7+xNON3Jg&R)nGlfT7r)SK zb!qqFaiXOH#nM6N*Od3tV?(fFB!9TQVY=l`lZEk-n8wH zBr8Ar3O!Nd!ykTtW_aU+##rnfb(P3%SEdk^ar<`|1i_)L;O&v_gmw7mW0eWVf_tgJ z6OdriWZ#34LxaVs zf|5$EX~E8XG&yT_w4c20@1C(jS#)j5+HxsH8;N?M!A~NXl8R4p2I|T0u0G+U$bln` zg}G8{Q|U?mhVa+j{(B(kre(eeZshO2=1oxVKVa64ELmMsB&EdW%F z_BQV3+-1RJnqIY4LXzd&xr>3e!Orih1tc^ut?Anp^4zYm4x2SpvHPX9G-(n;73^>rL zN(3)a>2d?64!K!kIu1N@7V4BQqm6Yd<6Lioy7WyS|7=!NZdm0K1J5bqh3-G%)bIvG7S~0nV1o= zi`#1qBK(EQaVA%)14v^P?u zz0>F!?P_Tc6^wiZJOuFE!h;y)K(GGdYWx`L^n;x@n9 z-iywG+t^rckS}_@ecB&&djfmb+IAvQt^B)4szB5H^WbkFKIGnYND1f(=)ECXvgU12 z@`h=S;hJaavrI6}F1YAQrWhZv-0i;v_60M+zF<4pE@R3CTVZ>+M-tFmhR;ErhBRCE zrddn)6>FRqcS+5*z+JxCtl-nx51nq|EiEF$$4Mo*lXFDdGLgsv9vvm#O}*xvw<>ON ztxjSo!eHHW4Al4&XIThcPLX*$j?o@0d}J_NQ)i?!zPlAFr#nJ@j)5Z$7Thb!2QF)WJ&i=GVmPnSifhkk1+>z*CfAMtkv$uGkX8B!$X@#*>I*GZF zNAP}q)}e>L#SUy9k#BAcUcCkQy!N?4F}`wt*hBg|+9@2kde5l1E{T%L#mrKm5t!~M z(1@i88HQE>4hdxY#D4-!c>rRUveGf=V3NoE=6iva{B#tj|%dmoQ2=cR#1d zd7F;WR;~qmq6Z1^s@=q}P|+@Hb@L=@u}1b@#0Qaq_)$;jQf#l?1?cwuHqMs#d_59K zvaxu8KSxk_)2o$$#UhjeA&>FPk6b(bxyc;~SvD1#ur}9bCJ#nUdCq z$SeF@VTW$VI{ii3(p2&)mx6L@0N4@Oh=-^uUM^0y9kzKw?TPdKV>DpT6bu_D?eMw+ zKd{Otn!GtcZA7G7?ikJ&Mn!<&F}nHe+uaF!RnrtErIVzz$S8wM4) zp6kPjLVQCxY-2uQR5%~}f+VENG|))^b=_ObTzo{X7$tsJrbHv^wdxJ4xt_MDd13;jD!CvBT#PW(i~bP#~DJP zQdH}&dD96CgKfpGWc>ZuA5;~l%6Yn_8(D=iI!5_%)AA60<)-z!2U!>pKIImMh2Y5E zN7w2T+TMc?-l$*909+J}JUKU{I4fvsdA4XbtvQ_hrS=Z6QjDA9@rD*UPD1u;Es2q#_XNC~p8>@_r zXkBK5)PH{!FeFm^D1YBmRQ^{@tw_r;POat1-&Z>XCyIs)NuYJv9xBWaIcIMZYf_7X zYSsE%04?|L@$-o8cItf^1NbtH(4FYkU>69{F8|)yu;dh59Uv8o%g;*!YkLWRyykHO zPtjIt0*IB(1Dy_pc!6Q_2e>+D?;wD5%Qu%HS%*_mx@NQ1;UqtPB+WxJhgrdeP3Bfa zenYx>L+K~qh2B!ht$Q|Ik*S6FrwJj)osXsgX~L2Fj}n3Y(uf`p5ez=xEgi!-^wGrQ z_8wW3Yu8nALDn*Z9x6?IyQlB@auP!8MMhpw>bAHH*eSwkN-Q&A&rjYQo>qaOG{232 zsok1H^X8|heu#ecBJZo?m9Ux`@Y@l3Kjk4>Xwn*$hfWlj1rnuYH&Uw)Uk1x!VKOn> zTY?tBl@HbmzZq`&dr|w%IegZb9$4Wp*m#)Yw>`Xj8))@L{g_^v_n+PQ@q*og+Y}0I zTvqRsfhI*iE3KNu;ov|W$NoQN?$+c8TuN;`V0UL* z6&Q+&>KE43sY)_wn6`PNCmR$lLhj(kHgIjn5%mBB*Ph&_ALHIxDWS2~3YW z*P_@zPh{mKJvj^>mwPrL_sX9v94pmmbLbqtHpbC}`yO7F^i62&8sFOnF}mA3;{5}a zOM4p`&MuL<_r^wR`p3pPqwDlsU2P3ErscgZ_@6kc%lh1hHN|w6+uR#pluhO5(~SmR z^Sk<+s4T0O{ym8~Tx#N2K-9Ll2Qyvkxe2P$wfh{E+!Y3jY-Kf-tk&mQzoeTxPpgZZ zeD56#WQ+_pb}8d&N)O0eoDD$jTK<^}{<9m5I!;>Q+xjy!Q1ddh!l;cmbD35}zpvd> z(l0uo>}!y2&B$4wl>ORqGnrL|2>*TJ0{8O@@*@L7cg0rUUT@qzvHkwe^ypiHj$o0> zdgz=S`JG|$wNeVAO6d1P7KImK-fKcH z_O6ww)gP{j(9`-xDgfmWb`_y^rDw z4yb!gUr!vbGHkx-1g`cT_Ov|@Z4~CM({LBQ%(KMl=xQKbxm@Sh@LY^aa4!tK+HCP#%3*Ab&s-(ht?vf zfFIPG3@cGxMyi@vE9$LTwNV|lmpT=8e1i&@AlOn;p7T3Pb@`^BIS%7?pVzE&a39!B z8e(kv?zz2_*!bMSp!sL|B(s8f3%M9S6^9+b<^U$aQ*_^Az%qG&p?p0>@7CF3DQoTR;n zp5-VbcN-0Ve}<84$8t&uwGVgUn%&g_C#Yq%E&R?%{Q*g?1m>#QoxXOlewrzsn_UK| zOwyiP-hF0QZ7j276pp;jBhQ-lng#!9=}=lTX*e+3xh~WEb@VDvO864&*u#LmnrYb6 z@-Um(E#I)qw5*$9AC}m)0(wu z(G15y8n0WIfb%>s)U;B_=duD;|87X>dg*x;8{Dvp!f0d6@AWHE8hKufN4|WP7MYrQ znv0KPoF5)5yUe~H(0mxE;P9Iwa9zAVvL${#V9$I}+a!SX^QPbL~u6Uq-M2RKu|##IFs65Q8cT>$U z>jP_L#K@al;h{Fyy_NFN710qiLsIi>m$3q+oiFpfJ;L7L-VL86V8r>%aH69kUN4y^ z4)5Ol)6wz$#vj*|1rk(_5;|&Ibo`pkO%9_ark9UJP|F{he|q;1`krxcb?M4)ZReUA z_{I0@aOMjt^Uv^I+H?F@K0L4{O>@t;GCh}ia8&IGoD3h{BKElZg=ErHCB9aRu=+L+ zby&&o_g9z8Mr|kfBO55niX=&1nI6+Ey=l<=Ic2@F?M%PaenU6!lVQ9$4{`i3qE{)#hw^6e71M}&osrD-}nggR(FPmOg4><$|<2K(*)Uk9_ za??LHB|LM#cQqrE@)Jl~H_k6@tZWN% z-ETa{TT#ZTWvkS(oHcqApBBOlPQX zQA5C#L#+n**+W@FfSdistkZB3>WS(RLrecJ2P@O-TU>w*R7?)thK(D2bdyGz+qGgM z-O40Gm-kY|cC$_P8R{v0jO>yeNuZ5p6f>&JS7AOwg3R{AmDSzSBqv`dk+D@W5 z(M^(V)rKOg`MzxIU9=^`68H2%fdX(r+n!t3JCRl59n=tPM(isw&nk8QmcXBBrxqkb zYRl}>T@rKBATQP>(~y>%q;4*BO&to+3#)3Q*JW;^N`<7=af4gcqEy+mD^EYi4<9W% zs2-UzFSxFnu2nWq8#+IfW3JtLPvj8nb;508e&~$n%=Rq|(LOx4WnS#=D(sz<+xfP* z^`|^5OMaISM=_BiX&`mZ^A93VykE)ddu}e?suX%h75FN0Zs{%()I%@2Ri9<%MrV!f z?`jy<;|ePyWQx2Ndn9ECw^HncX9|6hhbM+@yg8Bk^}7urr308ALojAgK_@--NrXM( z8>YIQS!g?I!{M09|E?2O2|NfQb@wLkxh_K%Q<@ag`1M5kCy@)kYi;f5ml`dnmu)sv ztCO1MhYB7q-F87SOiM=x>LvQ=GOzdP*SqOTDIQO|PGc=cSF(*ClQL)chgluiy@w&z z;#wm1Hr!m^!5-OyR3c`wCYd~;DcY8I;Mzp2=DZIE%T2ngZ`R0TKsCt9qgR?{jEQX0QmW0M9>C?v~)9JuNRLbf}9)CkSS zMlN4e>C+y6GL4lVtBOeSql^C}Kre&LM*;awH#}gDqY|0nGV-q~c25ZWoopQOP4`G0 z*qs&7g+&a$P{tI{iNMFu4;*hSMLqF?_yz|IYYGyqNpW_&JgZk~cSjBfw5P_tIhOmi zE3hFmjx8OOY|B>}9t(8yV!Wp_U7H__J!5>LX4fh-?OL_M-DA-LYD>i{Jrxu`SJ~J> zo^hFzDivPgdZ9Lb@+8hJ@o4$#z!e!t$03I$sIB9>8OWzK3pQ4yrE4F?WC#Pd=B(s5 zsxyd&pJRF=_C#eUNVoIErJMM?lRutUd!odat#GaF-G^i2mnyhd0~m6OVm133o@w3M zc~)yIp-(>3=Mh7WB*#S~C6l#5qRx+DMYf|4R&83R=gcea4-xqE1C{&F1Qn8w%6N@` z;!#tI8LJAH?WC@A9xg~+(hG)_fITmIvu~h0)*FKrNVihgJtPmW{Eh+@&$60H1--DJ z-4x%9)#xMA$=ucG9-w!e$bgL54|jV~L$TElK!S)e;l4ueQJS3y|0eCt>hnS6FCt#= zz8ahH>vbrQ{%Sc=l>S5Bx@cba9nfU-ES#;tST;<>496&whgKrUjBgwwh#=c7R&g^B z+xl!SJRsxsOR7|*GHVT%WYba%2*ZSzx4DJ{F^* zC{f9_9du!^#W=lzAtGF(mXj1l77DiNtCY`SXQWDVhP(fWNs+jS0#SuMcuYDO0y<=~ z_T!7*yj2N2_r3ugOJ;Ox-(wa_$~?rW+>==QcH9o+CD0simk2|s%Z_e`lkTJzDazQs zK+4T+js@Z`UqmO>Y-0kPbE>;VSE|}LhNN!dq)O+2JmT>lPFes5*b1Y(@^CrJY?Uoe zstGI&DVw^h(C_06TS-T4hxxY~-)f$GDoRhfKlq+dL&30iL5iXzRQ#UUat&w!DJe=! z!4ZfeOPxWjQFq{}4AFed?~~S(rOBN1jvTzG&1OI+3hW{vCxD={nEzvvW7cfQ&XC3X zlu#QGiEHVOrt~jxRTors7P+rKo^}e(#4D(BYpohX>Sf-V>cD6#FiF`Yv=T>)v}9m- zhqh0KLDpDyTY4(ehb&%{G!0Iw$-dgKI6ELMr(}=;K&(kqUHNI-gXbtQgHxe+gXLzH zZ8K5i{x2@B_u%OdpOJ}NN3z42hTt-O7V1th8AyF2oHW()>!-3F&8Hz{`z%2xRE^+% zO08bai#ocFI4MLVs-_2ohct_PYisZ?gv2h>E-%M_(Md>I~=Tj#Ss;p;_0t$DcpZK+^FzU=NbW+sfEOzThg{dd6K#MM7jzp@(( zU5}jWwX|25V2Hxl0RG5ldhkNotBPT-1{&XL*TY}^@ zB(%hu*}6>ot8l5{|CRRL@mT%u!v~>kC6QH>Q8Mc$TSUW_O2}T>dt@dfvqG|0QHrc& zySYg=$p6ej=RW6L@9TYC*ZbP%eLh6-EN;y6M3U>f zsK<8%yz%tyF`Cj_;N~7*9@K$Vg0ww1A=cv%(ifmATiRBkSTLJTKYLlm*_JuL*Rjhr zez@>@U%L>#Wj(df;Mm6?v!;xX{cBBqKyKr}6Ul8Z99#@F)?OAIbbOYy(VG((Zti8j zHkY;HWWwN1C7nbVivy!p5b1m*?W&sbZ4+W2@vY-S>bV3sDG{rMNb(Dd`l7HPEn8Zp z<{faIEZ)MjiOE2crob%gA>%+gK2rL@&)lf_rut?fq`4iN=vR@K+wAgVYAa%naZa|6 zit~vJ+u+amnbq>oV6*JTm#?>dm8 z&|3wnFAsuSlPez_P~HV&y%X@75#nUPmoYXyp&V;W3_AhfLe!IlnkcYJsPx%>pyppq zi`eR4AP#vtXfjTw{BvVIDa+b22`^X`Vo_G5e23iV_(iYC4-Ui6XRkXWz3C3KGv$}d zQhxz2oiXlzz#9R0ft|O@89!PTU2CVx9OO{og1noq0aFR{`E~&@&#imUH3mps%>X|o zPSLnrb4&b1?~`$7{$x&u&&Rxr>PKp{AibDduynmjx{r$PxF~_!Bf@^`dt$W({-C*v zOzdcrOvlOE@?U*1$F$}?nF&Y)=|iZf0Z&fGg`uO~Me)Y^EGB(;M2*J@J(tjofoWh0 zOaGeA&@Jbt5DUk~0Bn0N{U z(F1e7k;i&PJIO`O$M2IH%jj{2-Zwd>Yfv#QzoFo(&jB|HDDeIWRt2vvG)!==CQZ%d z_HC0}62pUwW3auB3>Xnc>V*P_Mb|YUzCigwLj+?f>TU{9RvqiyoG#Z`z(U5{g+?NXxw`?V~ zDnXBm%nE>f(nB7eXaR`KD=-n~XW!V=0mc<^e7F`ps1Fph1ZDp$(Rbe5<<@gr)xBh9 z3V7Vx1MeufO8n7sz+pyqFYSl>I&zU2u>_exYJC*o}CnZt&uD7%3<;A8rn zNWC1GDx#cP?2(S*l%cJQ{!#iy=Sw;IDEo7KO+aeEiqp%^=tsr))EuL@14G7f{JFO} z-XA1-$lI*bP)IFb=b|j4n0#87H+Y6QL3$~RhoYN|WQH22%4~l)^7|O7W4H(2>KFwY zic-dR{4zJW#D#t1mfS%;KD8T=e-g2rG|AMaHFZ1EnIxhg#e}2~e{FlV2N9iIh}Z{w z`H6!QV}pM*9hth8eWJ~7SBhiRBc0LI4B5cHTK~z34 zjLDxO_ZZ4)abp?8wGZUamXFr7$>cB@)(Z&J@WT3+ek>EboGpB*#1&-5woUW-4C+u{nk@StgW?!I(c9`6R($d)`96IpfSLIyh;IV;Ytlm zrA=_@ke&@{?>_^lD{_H91)y+!q*XE8k*@=2-s}u*pB9xLm4KiUttTZxXrosEA*%9@ z6Of~~6(t89thd-i-!KIa{9Z~3N6tg2#|zny#kZyrjQG8o&gnx}ohgzWpD-a9RpGB! zI;ci``a6$+aO$*L z2e1KgH-f~7JQ(H_p|5TDe*l#N)8B!2R0$a^NSc|h{JuRg5VQ^v@LgwrciBg185Lp?72;G+M2Mul88-zf;t7T7FjywqgF9DojoVn~a+onVF zgof-k6n+#G1rKmOSS<-xg^3Nmh2ZB?83u%+b;FX(gDqL~IYPNH63(&Pzx|1GLt zxT!LQj3t7=^Q{zEHb7;B4pjrP+EDlm4}xi4)~qibmaUBmD(0JDNjY+pdWm23z`)+j zxY@?m=BTi{Xw^?csE^?PPA+}|G2yHmti_c{+GOU18;GyE(MUFu@*bsr*DvW6vbx!PhV|wTNfw{o z#nyZU<($|vzyS)NicpA9yTu#Z!c)kO$uil>fR(V?5j{>6w~>!|*^A}BmhG5k4a4z9 z@EADDCaTafvy?OhKCqPwc@!f;lmd2+GbTpPmjoHHvWA7sgsOnS9$37{;x$-52vXh$ z3nl`54%k;ln1NFiEv-7QokJBbXn(}^3h0~YL_FbF;6Fef10;e{_!2$rc%lNG7jR4m z?p-i|kc${(20`5Qz~KJ?8A7ijQZ62!D6j;&C~DX|RI=vbE+0R;Z?I#P&Mz1T3DoFn z5f&w&JU4j+u=Dw_td!0MWJFS6-HCld_zv7cAINhfO8Kn>#FT<1f~0VicTOKfd50d; zM8>#$&{Hr3KiA)Z!GC^MN8N}ZWWk_o_6|oJ_zxZ~1$)W?X!IN%W+Zh0r2}+WfHMXr z>(4AU4WOJ_Ah<^`#C2K(@C4{b7*n;;d=6}fi}^FSkJtJ$SXX}F(~S83_Z4PX?HP^) zEAb5h6UAtr9xYJYT7rSxJ5(f$acJ5aXhigPBAmA&o@X69B}zODLGgcGiGLknTmn%# zflb#a^I+o9MXr0i#Gjj0BAf-1`U=fnBZ?1b+Ke zgF@T`341)YQD9y606zN*kR~gOL7p=Pb^71t1IeXm_rdmnn(+X?4MF@eKn?C469Rp? zjC&B`{z8aqGQ!!za)W2t|9KV!Z#Wj7!lwLX8d9!+q9d*T7aed`h)=MxqQ%M&DES^= zh3k7qqZ67w;p>9aeR{}3sv}=;5mlWCVzduLAPf}Z|Mq7E1XC{I&?=?3xDP-fvkz~5 zeot|I+MiEuYy(Q)r2Wa51AX7uc^II7_+|c`wlRnRit64h5B&v+Bz*vjh9IZ^vs}P+ z0c;?P9j-!z`0W3w24sf~hPA<5AU=oxE*^BE4EwYZvq8HukZUP_NA!no1rS&?(*eS8 z;4fd{9<1E{N!u34=f9s?3hIBe z<^Sho#2oeC5fzfZa_N%^3<5#@XVCwe`v2QW3T#gX6h`Lvh480G(|=DJ+ll4?5FfSw zGq>U4e^~$T6DhY!2n(~Jufv%L>?>5N;VK+aQ+rAZ>$%VR|CR&MJr?iFA>uj&YZoSt z|99e?&EP4Rn2w-sBC@YreH0nf$q?ky%dx%M91OIe8R{#%wU zRl34v{>MTBhid_ixnQ5TzgVkefIaz-R{Bdkq_^1!a3cCgh8z>&Nw9-ZJC5Hczzi(h z|M8@c02jlvaT!#W|5?z4cbYlERe7#x2xa!co@OT^|3bhvq2AlS{)n0dRq}t;QD`M; z5IjlHIe+yi#4c#4`fq*pzL^3`xtELmw+8k3k?=pX@~c0MXap!h$o6$`)DH|`0zTKj zALblzf*Ie}?P@98Q;v{;fidA|M5`wRsE>|3{vUsWhJzVG?qlypB+3e`HSr%x{_9U* z4DlcFB->|9E+E#}ik11#9I$zRHIvjv4k%bGsB;8i{D&Q&u6&4F{}}#%ZUtrKI)bLH zQA_eKbG`A*JUaL`ptkpo0JzBr5}@F5{G;$)famgzuzM;t?ekC=j!1|7U+ErJ?dd`k zdQnkm&~Smw;wQH)(q2^!q46N$upme~SpWW6fA06xtsgA!*v7QB>LHt`+Dzm_un#R9 zyo#nQ>wLq-UQJGhnw$Ohql53PuKTxbtaq|3>@rc$qWh8MfWeFr+P(iT6O*$6yVOEc z6m~1iT1ipeFY`Si&irji35WbMD3+TSMp=wcTitG2)tpT4eNSKWRlh3FU<($0R010213_tEX_o>7ti$ zTl3cf-#DLPLlzJGRdCZu=m0Cwe+PvKTW*kJ=$?|R)$M3`I@+ehI<}T&eG?UK93o}D zFD~~{ad|cqKz0fG+hD75YLT9<2bhN@Gx=zz>o7k9P?~$>2rD z&p%nw`RvEDJf30l4qc5wZL}@*A5m8XPhD)bmgfBCiyg1svzy+QKd(zbBkRm7MR42F4#)RA zDCvzycVeHAIe=8J88{YquXik7X6gSehw#zuuRI+<$I__uWFqwKanq20Sh_k&<#Tdh zoWeoJ2Q};3b0NNdD{K(zU&nC9T+$wmXofd6u4czxdm>@cZEd?QQ%uw;}`=prY0kO<; z4LWkQSM8&C?z05fAu9=gb)jRjxzR*?th-z_59^qWa}e|Td-34oi2J?@DqVnxkXPi8 zdK;&Th7c6TqY~5Qc|gNqQ}>0S{^GR~L9guZCQ(fI^M%6^jdokB)p;|>O7!1hR{YYV z%0>awR|opp*YR^PhMjoRr}t?ah*EFDP(uh>@=_8j*Z4){l@nzjg6Uid-v{B79wj>Q z`HP^y`+APHr-mzoC2bcGPc##;ckGOEPFM>cy3A{@^Kd+p^2aGw1%ASmTl)eiiKcts z>%J+aHx1Nt?O!On>KyJ19S4Yy=U)rvlVlrZKP9Xbix!{=e%=M5B9o+n+}aDkG1^qb_D>I{V;386QEmAXD=PLGgTu!Km#Zt#rX7InmHZDXUaf_(00BeN4SkOd})to;=0H8xzy<0eJU)i{=h}SMXUCE2wDv z%!BskIjR-Nqs^e!Opj$BM6yETY?3$VpH>QKUPY+xegzFE2UcMu`=5J&i;HJ;vr64! zm>%q!k-lXqaL_9~i45+$_u_?nf&{w88QwpK64**3XcJc04HHa2`9I8!nF$y~RSewW zsEZ1TYjUDr5QudO@&0I;u>Or@znV;qXrRk9iRr=oy@>H7DCddFYZA1(?XM&oNRPO_ z_kJHaGjgF7cLSGJNT`BL??-jhLh@WL1E3FtA$MCiELdeaHA0A8a$U-z$R(M%34s$c+;ezc z|6!4q9W{rxd-1@CBI+M;_JE%#SK9*>)JHWZdGA$8?nWmPAk!Ssf*?uh`X?%ZgaKq; z-Rr}LYl@$tW_1W^Hl7^ROE0q+7>L|n>{J#vL%aD(Oul|#-}@VHBT-*P%7#-)<~Qo( z5Kl1is6aZ_o_^2H7OYX{<=^~tWN%%y~`; z8LXZ;f3|2hNPR%UE-H~=n!D$S-dP}NuBgxeDt)=)3!RPj)YKpPPWF=e%dnz04SLJY ziW0Y*V*(#H?kL29&e_@S`47J~(X={={u2@mnBHLn9pM)&^CW?$ebS#go(WbOP5CgD z=n{BkEZ)iJX!F*8lRv6{?sD*mAJKqa0m;s!VCzut1ws;%(^=m?7OdXw#s^!s9@1dd;dBFW_g$*SLIlJSNmiK7MtTtSGPGr1{&~SSslX1Jl9rwApo>1cDywwKi7Dt8^Z0dF zc}QD7*(;{=`RcDfhNdIEuZDPY5y`KS`pg?-62vbd$%&QTzSGsB)>kpy|87Cx?X7kl zFS+RYo#Ts9@{$ZuP%W}8AQA6jK8m+B5(k@s$7nJdEJj)A-QV+slA)1|iiK^g-)PzE zUR6$$%K5ph**V02aYu_;U9ABc1F(_I;)XU_m=)W*86XzZ2Pr$-y&>*e39ulNnd*DD zmm+Tj7dpbDnL_Ga@iV_Ek=`zDR_g*8TdD;%1I?}$H)pC}@*TOyAozhCQE!pu^eAJ| zcmclnt-b6K7AdJ0Vg8++`WqEdqAQ1JqGm(7l!VGZwN_Wd1_MBm4TGN+Yp~pCXVX$= z3eKxaA( z5-mO);9j<$MIlcbOWZeFqK^^{G0wVEg8Bk{gSH1@zXWe+MO}283*U2>mQS<7y7I4B z2Uj&kFKI%ghQBdam$^0NxAqad@HX-8rQ%lU-)O9g^>y>&jF}3{kF^$NtP0=?OjJSh z#oG1m0;hLu6PZ$|=6jIR@eYTtMaD9cbEL*os&?hvTsO9)h*n}i9>?y#R)A_MS&;b^ zG>L&wSVMwiUY(A!A6$Cu_5PWFf;mRUmcb|-*w@f}+#+PD3cyzxUCF6b#b_@xS8Btw zr0kfpp%z}Ur5&U%8eB5h1ngA)NpGr4lkIa@^R`|x#1kV|;YLJU07SRottpK)sBhU? zs2qgfJ3Px3STJ|uE$E`0?~@|%6{6M}X-)>UQXm~s%l)QHJ7NcI2J%PEeygU$MCmk6 zSl}u{L1&*zwi{}$ozCCbWK6nBgtWWC04hQ7P|`$64ueS_o^WYo(0WByJmiZz=lEnG z*H{K^)~sWaqfrq<;E6QNk@@H1;iYIXZ+{&svNQ|6RH7Z!G&WhKEpfKp#%nj9GUurA z^6Evjx|ejTE36H%$+)3?KH2UUs0AO>jZj=%zCJNj*P4Qgp~pLTje6;pmI$M1ApKM5 zuV6DcN?pY+KbpI3xl=7JUE*Qob8H8ao;H(#;#fQSVKT&Majsqhoe6ic-Sh`gg9PKI z_eyAN$_!~|XYP@8{_!+#QQP&=s$BGOe)^4@Y?nS5Y%9r_SGM+i&lUZ2nI$DQ1GhB} zUL{Q7Y=tXOpq<(}G-XP`!+_F*lHU>jutaqZ;}>8_1=@q?IVCKwZce*jf8aFgX~e}}AkOJ2t5+ds5# zLXyW~MXo*r{4nl|t>*FhqtkpBdm|$t=hsS#%Xf1%WbtVIGni(=x%lMjKp_xRxpB}_ z4=i-5xr=j&uZfK$6}gh64ubz;LTwkB=*OzaIyS5^=m9U1)V!9W1pU&#>jDi!-mGKl z_NRV=tJ=;Gt{Skjbb@ea%7Nl>ecK3*0bOKDb`0)W;?E-T6Xj|JL=VvD(NMqnMmA@1 zd^Vp(Y~mEFS6C1@XeE!FQolEW!Oh6Hw)p#l%UZn^<4mQ`@ckpVP4yBE-sHf?)gdXy z_<`XgpQ#yzAFy4v#U4<(guKERq!L5~saSirhy!T2mk3w{mz~?hl#gC7JonD~Y;I;| z+3>tI36G6|_f9bZpYV$CN6b>dRcB<#*{LG#ZA<|;RVkd_q6ZhA8_1BjI8|)s-fgWk znezFpqw_A~?xcQuMjzg*q$BCRPq~U;k_SI&%%FO7eVU_jivDW^fT4?9wJ$0uy*ub1 z+2=a^ZmD9?_Slxo055eOy+Ow{2esM4jZHeG*hwd)3)|p*B10Dr#mh{iHT-(fjNW!x zjSYnx(bZE3P6*Ogd)ewJO0Q0Yj zW%}sqG0TmF!+5yKi_Lv;7D}A)__6bv=g%aZRnS{xhgtRmRto}?NY;BG0Zf0AdP%ZR z^Huk|JV~bIDpuB*Ro>@lwmRv)Js~siQV#MP==!W@Z@5bwva3k2Qre-szz{~cHHNE7 zV%`>aXfCvZDsU@b0|l1Tt)6@MYFz-4mo4LwfQfjh^1HJiCz-0{;7MV5hwWPZ9&^~a znDR-dI9`hTrhPBmFrxEu*s1K1XGe^iTItFFW!0{t`s0HaT(x;kp=F9tP33?}=9J&! z?gDjHpHHes=NuyYknM{Fj3!k96k)$Ki~fAi-P&C}^@V9in+X+|I8(C??qZm%+eV?? z-Y6a|?89#MWIlJe^9iuAD24Kw)FDi`oj{1BSd%=_>xCNyz11sq3kYmuSM7gsRq8sB zVw>Ql2QE=ra~LUdj*QV5iUD>YZi)qhwox;kLN>GvrPtmZm&i0y5_=QC^o zY8~PuoEzLk*hL5*kdeeUG9SI17BAWzYHJrUo8r?`vhWeNO7(&d*11Nilk1u`YY{tK zS!pRqrHIV5(M}hmQtI{_7fxazvH>*jp>P?iizx$?MW>8{o%PzvSyq~y8j*MQS);Qw zd0o}|xAvZ`lJH!rc5k4~(IQ$}=e0x{r=h@EKpy)_0azHf7EI3J`7q_E zh>4FimjQv^Cv-}2c`Q>Z*qfAAj3XX^I-?bEs(VnbRtDN2jD*0WOEHW*SQpU#%p6e0 zG*BRpO&#+I)oEVhG+r)KXgw3#M?=#CXdo%1#64UKPty@2O;|LF&Os#4hqD8nj*Og+ zJlMlY*#PsnVF2iE7CYyNz*slx$GBCX-vY@x4>)#km)lYpcDpM;ciBm?4@Br(!gdY? zB8CN|ZIS`?rH4G@fWGI0$?--!@50v@UiAStkQF^tv~~oco!~aCkD%hFR=oCt4fl%! zCV`xZe&Rkt#cr>eSy+d7F@^ER!v)ciP+4yUVNgj#`7o8W@(We1kdgKY={-R#brs%z zIVpNTN_oDgsV{SmDw@w4@6|9bwGxK$uzzaFjXOO!Q$m~&2!#_$!3IF%R6H~E)QT$e2>zV!>WPr9DUrqqv~^V;4(N|2d6*5?J7KV(5!=YdO>{#i%x?&m5(Ng zD~F|MQlumP8y%uk=Hw|r0QANRYwNtTY<$yn>Qdfb=2$BF&&pGw`tVyyy1S_l8i^r- zl9InUJ7M{RDNUdgJi;el@Z?pmFxPZcqkF;=$4@d==V;QEqvSS}4$ebm^%Nj1H3f5c zg$g!OZc>rZ!ZyYOz?ztGGucRJuNDV`(k6+t1tV`9HZKQ067qAts}hK&^wR?1eCyMU zffHof7kA|2IEIxOGRuY2(oUD6K_+yv&u@5hV4I z@gG){ZavTH+@Ol|FHXTTaZLE`ptz8zneXnaAqM}+O{N?nq44!B*5Wly&X>8-%>J{q zSclNHg%}8k9!~js#8OA&BC*hNge1(I{MA$AAdz{TfERMXBaR>|=uO{^swsT#fobKX zUP50MIP!ya%+TT5StK;cKt?ZAp%#~GKF;{#yZeQPR=6E&4D{-pBg5JOby->GQOF}O zE08F(XoNZN@zYTmKFi;qD5Nxic<2ZpjVclbA3l&tq40)bfXxW!BNQ|SBuz;ibp9Do zqHv#Wo|Q8)a$d1|^bEYj&mbX`nTL-M(?FmvvkcDZDu}SxT?9lMY7SA@t^k$n_*^?W z&dx#ii)AeoczpQXGiP4>`Bqhm5?VdC%`rJLeV&F9-`Fkapnt#*hb9#r=RQ0-^0t6Y z576c@1CSFJ`2eOID_HgwAjkUC^um8BWI)y$G1=NLVZw=vrgx5w(~evQ^*j5fE&$}H z(~!15uZ4n7E@%4H(-=*q_bnh!g?38Dfp>Kj>w2TRb9Vx-y$($Yk8R{8MTQ1ov42-S2f?D>jbJG^jj zr9s+tb)9y}D=$^)Ju_gy_5sx@waqp#SjfXjltD5z)F(kc7*J;lLp$MNe!uj@@5Yv^QCq*r&0pHZ9sYOhC zL6Xg2Gy?b&+p*&CA80_r)1EToFd~tX)+=-a;0^tPqsR(@W6pWQb?l@1oc|K>%U<-sQ^sb zRDEXeNvB?Tjbz0bol1qSrP=8FGa{3+{ns=KkG4*jW>QFSv>JadrGNnlihwMtu*u=+ z?d>nsTijEN4(f*-f(Gx=$_^^11_I%~+o{;P#xN4n5%~v{Y?t)t1F9WMmalNu;7qX% zN%ry8)L4)+YdkwvU8A_cTzHfx7X^s{N_Vrvy8`?pbK#N$BI^mowdiR_>qSk$jyKpW zkIBBRJh*db_OPdt&xm+ihg2=6dFtucT)XA#O}exqbC&vJ`MCcRUDlxvI~ zv8e?Kg{0{elC?rb%|dg0QcdMX7|c`-<${YNg~0kMwvO~^My}J z_7E{QrFe8|52NLkFaw-^uMzPHgNg*MO>PwJE);D=xcsH*wY3IZqw_@pI@+iu(DsU{ zW0py@Yuc1IV91Zy_hTB1ygtEly|YSI z)dOR^U=} zOi0scnK{E!lW=aaKr+svhb#1b(U_YqTV!&T{W*%?m-6&?{z8_~q6JPo!L-&X*wZL7 zEJR4o3JeOhaTW&+oDH7bxXyrHj>Zh7XRLpEL}&b&)(n*LR?*#73E5g5XWEp5WsvPC zu}-u<2Yh5@IN4AHnfj1jXMg+JCv^er=^^j)M{YdJ$fj$5Hq{zJeeMo0<#NazLiRCp z4%s$3M*G5j{LF*_Jk7_M^m?LC^I$spq-}^Xg-|_AMbgdjETPW5oNM6e5I_EYl2u~Q z&QJ}#M@K1t^CXMQ%z^WY8?EGSn4vPpV@Vg@bN7q|oYx!Raa`)F;wusClx9yZz4_^p zXP)8$BX5VXp1RYuEjBaTXy6ryLGeCfCAK%3rV+8;ItW`PMEB(jHFSfQYrn8Zi1c!5 zoN{;^Rg-#RLUVgvD4Yjl>6}pFp28Tx{LAmYq^e^(CCkhX90+qQxD1Dj}G_@F(~JWqJA~B9rWm z`JX5d^1ugC6LkZ}l`eu-Ajmicz{|~(AOW-tv{M$;H_p!@D{JTYTeLf;ZcBZ(_wq(FRiy7c&~fdJQF`x0pMYG85esZuY_PuE-_7k)%u_*@^xkc|FQz(; zJF&gzs4<`asKP(H!)TTy52*s)I6)EYwFBqTdLi-A2#-UWftq zvbA23E0Zt1ZPoJh3!d9_a3$9S*O;HJdu~dsR~&cg=8^7_gnL(FCj0Pm%}=Nc`joe| z`kGQF#+ee2tR50Ae(3hv;CAj=oR}!GDQXWMH+VNEL+$jh?B3xuRiB+$E$F@(3Pce$ zN_XU+e7EDLu8aw_E!D?l9`BaXYIrH-EAbVzH_Eiu)4{$9)G*E>{i$1|@1f>SX?|5! z`i6RnROX7)WfY%w3IEGwmVoqaEKe^Aejsq z{r;v@JI@0Z?KKmA%4RlSe_5b+>lr8nwxZ9&s!ubdW+Y{(1-?x4>waoacRmwJ0Mp}EhBT{qI&dg3IXf@cqlk#P5DIf@q8oMvjLuNGR+BW*F*B)MB zrQ^z6`y4Vk)hs4M;mMR}F)7EK?-1I~Q@E7YD;s%DD@9l_^>bj*dO{+ha3c@vE~Fr= z-yS0CM@{LbCB@Sk_`vpO`uU-O^UgOx6Fy-$^=qN~`i*F7P(F{Rj#QY7+$lS=xjGVe zz};wE>i2wnq#>7-V4o3QhAt+yL&#I-w}fbzA9F0*Omd5zacKhu-z9I&@{Akcgblz7 z2n!mPujhViLZn7)<9hGT?kjss_R7`bTJRvWXA8a)pTBk){my^)(i^@UYK zlKZYYc_!l}yZIXPfb=iTq-~Lgp>@|7(ggrdxv@&Fd5@mUbpb5|!w0UdP15SjW>CEr z0_QKojmss`{sVK3RHT@Yeocvmk}ubDubg9;*2h-No25CPm{?_^WwnZprVQURC{yuZ zKA)?(0+pow86jn}1-@slgn{CdCQqodIT3GdF1Od^j-vZO=p9BpE0p-7Q^ zRK8})kFUu&${EN9ecz(%?DSVUx92>YTlw|ag}>V}C_R{Y8YcMSbB|N&M|!mjgf#4v zWrpJlr@7Ufl)UA;;?ybNRSK5WUc$}%ke!UMM+Q#ES#V*#I{Dpl(Qz*47fc6BE>2LD zhlG$bz=%t9v^s%uS~CG@+QGKDQWjKSDkW)XBC|K>w*^J(r3UYfPlyU1y(-xwvBP+| zy~oMq82d7p&!dgjxl4J@8gWIrjjjAro1#R9;4ndydY*OnprPLM=QFGly@k~$ISO1F z!$`lhig*Yqd0`6GEX_JzA1SlVuNyHto9KFwH-YAbW=cg%D?gu0%hkrmIb7B*YIbFX zOTp7qBB|smG@d#fG+pXXBSvL+ixLaBk`2B-ENU-e*kO|C4DDhcmAzp$&>7vpQX#+e zG-JB1;DgTE>_l@pkA}r}tBk&jM3Zk#Wgv2tI43e#xLOtXXgc+t#I3T#0sRl$m+!Hs zN|;X31qH6!J4Z6%dyTvp`(-wM^mE($YYZtjHcNkPzf5`lRQO4illMjX7fSt?W(x_4 znz|a>vvg#)XBv&dJH+l=1z6V0yi(RX5$7H+39p&e_O%6c2a~fUoo)7f6|;qtUCF(` z+ujdlVrqVksOzsG{v?u)aXVP1&e>kw%5|A8rsLz;jARRcj8xu=Q+A016W!^1lWG~M zpT4X0yG~H_Bh*#KQ?J@( z=17Cdy*Rtk++R?yT&?8O^e<-csY>lgHOZZ?eJc((Mj-+|$%;vRCmOiH6l{9;JL{WO z`$?n6zU{O;X;p@Wai;ZRtX?gO_;qC=K_zZdtzFGHcuOO>LG|9ZMP?$1WZwq`!NxbL zsUgYa*-4h`wNHvn1^HNFGsZUG%`qO`lu12#bc4=WC-FN!jh>3HpL%k_L5H|Z>1K@R z!R+?7M5WY|UACKjea#Ch{w8-DTNpC$2L;8?Q5DK-2@h24hVz3{7_w@Qd9<^P+Y;EG z1N{h+IQX3S+hJ>6;rL53MDOS$UMY$yo^1gj}SM>0aX8<`hZXRh})c#$1pBrtMWx(XC%I26diUnS$zd_7TCS3PE{_s+son61D(kg*&=o^@TTp(BtI+?$^uox| zU<&$+mjfJI#kN)7z81XJ%`vn}N6j+ej&ZzeNtYV*oylAHWf%v0!QHO!ev|@k9d2D` zeqFvoYxU}xy29{_Pxnr(IK2DZ#($7~b=ZiFt4rN>oxM(jE$v+S%<`XL{-r3VNckZl zeLjz1vKfu#&>fn|6S_aCwl1t%Ia7T~!Tiw3e%G<8W=W^-vx|RpqWqIasANgR&h;n$ znnbveR%L}7f}D_;R;FJ1o$o+Xdx(!MJ$dVqS9rDR?QNm5-eX6+8z}ItRBmju#@ESD zLvrKM4r6vv?uU?_V(TYN2kv!-KAF;Q3DobatL(K%ef5YNfVs(TyEunG=$#{@wu8z8 z-PVBSmy91y`Eos4dHN4K?@|c&`3sq!ZR2hhxJ?qmR#U@GFj+Wes^oT@Ye!pldWxR++nIGOx9J+Wj2HL*VBb2TJ#F%vh0uK#Zyxd-gGE{_EjS zM=hf+sfYowO;&Eut${oJJWu zP$FyyxOVMJGmh6Fq3&x$p7$+?jeI|!pFOfnHzn8DDs&~2hCwbg|LGUMq=L}g?B`em z$2fKUN&fJwv+yynM8}WMLv5F_Q@tM?JTFr7mtOMMnd2CS?|-H6vLzG?R^eX3Wgq^t zyvIjbi_~F@KSh@N4AesWtu3NaHpjLq7b07tzUUcQSWz?SgptTNK*i@{r6fbs)=gdu zNAtP><+!?@0lHr@!{!0nmItir9+0PZE8Ou8*5@60YaqD@`2pX`FeCl%Cx2OaL^3`6 z8TySxB$Hm?9$Rej*4baAR_s6N6HS(}nRLz!LM9VBY!@fmYc8z3oiVjB=ykw-8JA$C z1rh`Il`cGWbG7(xoA$)=+5Gl-oXkvJ0CgMXjvJZHXv2Y{*~iVa>^4am>84q;@lTnN zIX>y0;bqL8nVRH(d#I|ucg;b`)0b{euB5DY%?Wt$cxL24M(TSz!<$M1BmP-iy=yM< zb0_$2wj6I)6#nseSNx-wj)DGH92J=>^H7|Kwq6QhLs@T|Irrvv1g)y&x{(yAGSP%% zR>_?W^)G`ro%4SG9=UPQRk}dki`j|tA+zBy3s1hElsVDokE;_2Rwt~aB#GbAsYpNF zU~;YYQR427ad?7fMfSmsh~&%L*df{4`d3?tarU5VuFsK@2{mY@93hDHJ5Nb@j^T>c zo$m#yuMBV;#JuhA$v@x9`+Y*FPns?;V|o$u{odKTK_dsEDIRJUAo5v-VUj-_0d`&n z`BzyTKX?B_wl9K1C>9Kc+g9y=9Q^WqWaVXl|GLcFz&fkwL7c6M<83YJcF(zi2dpxj z+JerzbUB(COfJ+*b<4^8uN~2;JHGv#hGXrX4(7&qs5RJ)maXqfTRWul-Ha}b4(zR> z@?5%fAk!k;qw0}8M^uut^qnYw!5i%#*=P=Trur$KKh4{oV=zpl@DcYYE)5so`(atF z{%sDqpWp?nZ9_mqGIG3pf+yjtOyHTBh2~+YI*Ltg|GaDGm)cz*ttIzp1On#07LkkOi;45dSv@a<~KT`NMcg(=>rkQ5vM;n9T zkJmYi?$oWhX_L;b$Y~*^&U{`;a`X$`Q4{=?ZG&1(0W6G`hf5v_OpYc`axmHwHjs!i zzUW~jLB!4Wj23(uX7YnLb3)?d+OLW*PX;0Ct9fzw zUo(?;%;)O8V#pRR-o%YM#^~HtY@*ejYpCn-OU73I)Dj{dKAWZ& z6?Z6yqw|*6z~l@A>3Nk1mQ5VX)<+Iy!e7*>VKP>6thKsvRbtz_?LEC8R)d198p={f zZnPX2d9l%CxLIm*fxKUT&dY2(aG0eZqf6Z{d}Hd1+iSkSCaIlv+Hm>-b5g7-1x4C( zId6`)pDQ!RY7~pZ@Z4R#Z9lwtg)WxOqLXONlFsr^O#?FH!%Q?R5{a$j-*5urvJT@a zobV}VlC(dmZul(s!?@1UjHR7^l}wgj3;dP3FN^KAs1Ox_|C9j`L{_#I{HfQAi;GQ% zf_}gUudO_IdA3WTZv2uR9})KbTTejlI)Pz3U3rXIiNb5=0J=30I)9_=xAE{ zfi|;w3PkT%g=m*f=gnozmzu}nGMgJG(zvDnDeZP-R}ai##`R=1 z9z)>W>x6^j+6f3NSo8lY9`s;AE^$V&x-+aic+jGF&0Z@NEVLT{d&4EtqVT{t?{2Ve z9BkeIH92T_E5-dRAEYH24M4z%Kvg!&3#$c3$_&r6kzm-m?loOTzuTI3xHK7=*;)l% z5Y3RF{=j4lg7scgOs}LFa?GC%smlg~>ViFhNs@0uvFGED8ty}@)x}}v3Rj82EsR>k zXyCY2rh|CfIlNL_Yn6o!m56QL{rcRqcsX~Wh5pZ%dfjGWnpLgjzO>ei<@Z*kDDP^- zweN0&d~7J_O_Oj(d_5$rcl92a?P)$y?LJtLoec%C#c`y6N{|Vju+Mgbzc1dI&40;i z&IT$-`gBUa9pHZYOb47M&Wc`$n6}_t#6&ND#hv_n#QIN=W-?5Mx;|erK)fJS`s~d6 z9^C?m4nU9&KI1)1*<53YB`afUQGOw?Tsne!Zq<{eJ~k!XN%T8x@UuU&auXNJO{^jSf0*uD*1vh)w+1{+9ZkRa`h zH3nO;>HybErJY$tdgP+AKX9Rvsk~a3+6OoNT^XAJT?dhH$8cw;LnqzENFFx4^k_7L z{I?NlYU{YtFxX09Xd*NiHttnvjn#f3P7Fywb}?)U+ooqN4TLyJHvw` z)vwS9eBLT~s}bd$8R1Rhm7*hk^6|Xr(_Q3cgsGfrm%gRnXBOu2O(y&_et{gy^sv|0 zxTNm+-rthqL|on#aA*3_iAJP(=n!pszK zK5QDY8UdkPG=BenJaMr;Trc#uiO|GXeyX{@p$D_Yac4)h*5A6Ubk!jDaSwlB%a?6( z#eDivV1L^}u{N-)z5(lpwhc&@qsNPpEWL-^p-NO0Tq9|pXZvdN>Fb{@dJ|5@Bi)}H zL6-q5-j5nGN%YS-ocypp>?@1n7+(OrAOCs>ReI$+w@)Ig9%n)@)mP2feomQ<=S=ZJ zvNq}M&#;~nV!$z`o+XUZD_o7}t#3pqUcqCy+)dN2MNob)2G#3+p2P<&T|JDvU?)LT z%Qgx43Bx;(6eVa@>~b+V<-vB*WV!1T(X-_uBcmZPQD)B8Lq40W67p?tYI@>-B~Q2^ zc;2rDC*aOPo*M$^vzuaS$A8^|rl)%uyIXIat^L_-aI4@8fR7omZ7TX%p*sA#*h|1gHj}-u4`#k^OVFvpXXcSV5?i&~?ZU^vs(A0DBejH{@o}eE7xI1Pdg$RO= zJ=D8}iuSe0fmfZXN#}f=dQOiVwkifLPV|5?=ea&;)sA0u6XoTKOGs~61Ck9LZj&i1UrkMG$B343_QwTuccx^c1 zj)Z^@2n{;dXav$tC7UH-x313eG0>Mke)wEXyK4i84cPc~BL#1thm;DOm8mv+3BOL8 zK3^}u7N=!r8YG0z)4Oc_>Nnk|36SddBP9K9$jzitY*BDtXg_?k!CQFq!khS)*rVnG zvmO>l$hC(-&_Q_4^)j;DW__-O$4ohISfBirlF_T)PnVcJ_|Z?%(R+l9I-%id{ia%qo27_ZcC3)eYr6PKV8!IigUj5 z%*1nbTRqu`LFp}ZCb1Tq!~t5(`alRTmxzHWJ4g934>=-MyJ5otxT`T_g4+DA;UlmF z4N9jWp@`|^0QPH{i($IBWo>V`;MJv7hm|-LXv-Ft@K6Gv+e#BIpYp4Q+jPFoR;Ftv z)(%H`mH3x(I`tEtNt`o`96jIeX>C#~)E}?d{j7GqnO=zcf<&~-4P{NMk&~EYOoVy;jNo;q|*Gp6TF#Xkrd3evac@6_4l`xDYT*5XaR1yg4e+pYsC zIbCM3gxpWd>{6Z0M9=#qyyX8E= zh;kNe7K|(Rr9spuDpbb_e=~DT@O~iW6^mzuIx{_gwbK_ZBxZ@i9*CxRQ&&EEZqvyI zKUom2Bxn}Aw|unjSJ90eciHKaK$yTeF%in;RDR;a$FvOIKwRsPyvqwE{)N+_vZ8!6 zdLXhQY-L3ro2hUn#p$!K$Sn4SXAVvbI?D>Q$TgyR1Cud>|5azU*Q*pbF4UGGuN~~{ z++{?1$i8O&@2pRW@iK`r@ysswH36XEdFJ9(#1%c8<$x_3f4cw9K5I5|oAz@yk|?KP z&6zqw*D6edYw`=24e@e$FqK96scR){=U$$7!jvOVk*YVWUJ(Q52p&SNIb3EK#WEGP zn?zhLqaK=U*792i-3(-}O0`0k@@aE%sM11RKT~gz_zYWqx(-e%v1D?v~~p z9z7X>tK0DP%6oVz#i9=nxO$nkU}LnjA6FqBwh_2swUJi+;Y*WbQp?|F2q&sM_27CCzDq z`_!DXV(hd3dW9pTXK!nkHT)e3y88D?uBKc%90#MaFX;K4;=0p;Ne;(4@Qb|79bU?* zSI-`I#j(5n)IGT}YVG4EBe?q(bEgURzjJ5*{!z*j zc>SpoV~&9x^4h<`t`NsSoa(cIGt8$1y|Wt?{b82%C9B=w-Pt&e_f`*j^xEhQ|2u|1 zt(m*;M2SqDP}i_WBuQLKQ_$F5FUv|46{Y*k8yIAHcw0Qxy4qIB4#~vu5{Wm>PqGzi z0QH^1+uC9-m87{*sTp?_D_EX3)T`=5G@2Q-m0hzzhTgJ$dDeVM2yjch6D|k6q$w;z zWv-KcVfM=nf$Ix^CAsGy~`qk6U{ z${_|U9+Q4*&;@r;9OVujsf#Qs3p)ENi8+B*7R8OvR*V<72M2aDBr-5;f7ZW#QPr?Z zucRJvFKS>IdonDZa*qn)kAwoL&59P(|5Q4i;*?M(8R+q`!ztpx$dZ9}d@FgiT?hQm zqnS&#y z5q7etH%$~;NqmI1dv@C{r!B;Kv?VS4O6u8wso1*%_27*;s3*M(rDe{R@808ltIf|} z%US+Jv@OANL4C~g9O~mPi_R08R36q1TiepsXA{#db_EvuOzMyi>|U>nVPsQcLBuL?!Q4b#s>OATE@H_Xz>jt4thK%m>i*cD9i$Tmv2 zQ#V^!u;gXp)-PO!Q}o09#Ix!DW-+rcX`yc|y#-e;B%Qb=W0{3+;8-#zKX}KtsaJ`| zitTUtB>%%Ck~p;8tK z-|0Jbe>b|M-%qSzdgDV$g_pDSqxZ@uMCP|z5dPkoWp*q_+XvQJTeep}A!@iL_cfRN z1_E?NSJ!&^772@$7%$1FrocX!d~BAKRte6=U8@b8XPxP)5A8q9$dq;P18eNqFhyst)>}V z9r(Q5Jz*M8dzE~T5B%mzhvumEi#A6tShLcO9{2W*bQ_x<5kBRaRId$gk4s{7tmD~f zc5*MhmQzI8-(P1k_SJ;h_dyyOXu*qfe23$ylJ{b0)3F`aFy=pAoZrQgq`foVE9M%g z<~@S0rc8GHnpN%s0bdyEV>bZWd~IHo_;Y;z`BM+P%Hi^4^|C&x9`d(wKeB0kgij$`Z|X`!bs#O??lB$V+ZN+`6>-7u*= zA`o(_X`&^KQ#sx=*~xS<`CrJfU+&PH7dM#z89jHspjz+-0oH!UBqUPQ5@o`G>Kdpx zs%P->3G?uq5eQvf3-Eo{=9tNevQ%l@T9?cX%dtqLNm!2^iRy&XC-2)LPr8n*VGn#k zU~kz?@`p919fy5PCKtYffcgV@v=4b%Da+4eN=-z|cThZBLn};n%BA0pA>NLHi+jbo zJf@#{J$JcLG$-_>R60|W3TytK&%5X@T2&=U zVfgY}7grxp9>Uagt|SEGCkFJ>I~rB54t?N$!)BP}ard3QfUB?KOANDW=dbh2i3WTf zy1CfA3}ZwiWOnM*LgR5piAov}H3s(qaDaGduRWetkCgP4Oz@xl$kn8NY5NUKd6VXI zYZY^_*}?1~_jZw16Ke=GapS$ zxB7b0znOBxa#k0-H*~%3+n#m_xHY^57o4sCpm5gLPKjpQ+*C5&)YreSDxhe*wM*iI zQ7*qrCZ)ru!L9h(tiv_QcaCu*iIJyGstour$Iiu z%PH|ZC~{q|hl)bNyw~rbc2{2801a@&(;wI4ZnbM!$`h4;SQ{Fu@w_JJTQT;$!sh75 zJy=Ua$LARMyUO=ns$~}-*Ou0NFAGkK&$+VCT$w>a))hwokuqMYrGKYClMNipQfEmf zc*9wd8qd2l2=$PgTm6T%vd(_~jxONANKbOXD(_P8GnVTmetDc@%sIbReGVw;W)^Pc zGtxZ486VhDh;L!{ppB8mCc{8a)ouh_)WxpX(3}6QwVgw_^dMp6uWC)KZFN*1X`1mf z;L||}g{b-7k*Ulvjh2X=@6KhyNg3C{8qgkvL;)q6wbwiyZhHha&_;+a1h)4Uaw8&k zj;`I{A$DPF!lUb*RGT%52q2%u^&rALypJQga%nP$!!4GLn;*@Tv!a{y=Br0VQzOLi t%Asb(8k8&zsMY_UCHudu+J~A`4W!hYt!7Uxw{!XrFt}x+Q?BI@`ag~}wr~Id literal 0 HcmV?d00001 diff --git a/mobile/packages/ui/showcase/assets/themes/github_dark.json b/mobile/packages/ui/showcase/assets/themes/github_dark.json new file mode 100644 index 0000000000..bd4801482e --- /dev/null +++ b/mobile/packages/ui/showcase/assets/themes/github_dark.json @@ -0,0 +1,339 @@ +{ + "name": "GitHub Dark", + "settings": [ + { + "settings": { + "foreground": "#e1e4e8", + "background": "#24292e" + } + }, + { + "scope": [ + "comment", + "punctuation.definition.comment", + "string.comment" + ], + "settings": { + "foreground": "#6a737d" + } + }, + { + "scope": [ + "constant", + "entity.name.constant", + "variable.other.constant", + "variable.other.enummember", + "variable.language" + ], + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "entity", + "entity.name" + ], + "settings": { + "foreground": "#b392f0" + } + }, + { + "scope": "variable.parameter.function", + "settings": { + "foreground": "#e1e4e8" + } + }, + { + "scope": "entity.name.tag", + "settings": { + "foreground": "#85e89d" + } + }, + { + "scope": "keyword", + "settings": { + "foreground": "#f97583" + } + }, + { + "scope": [ + "storage", + "storage.type" + ], + "settings": { + "foreground": "#f97583" + } + }, + { + "scope": [ + "storage.modifier.package", + "storage.modifier.import", + "storage.type.java" + ], + "settings": { + "foreground": "#e1e4e8" + } + }, + { + "scope": [ + "string", + "punctuation.definition.string", + "string punctuation.section.embedded source" + ], + "settings": { + "foreground": "#9ecbff" + } + }, + { + "scope": "support", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.property-name", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "variable", + "settings": { + "foreground": "#ffab70" + } + }, + { + "scope": "variable.other", + "settings": { + "foreground": "#e1e4e8" + } + }, + { + "scope": "invalid.broken", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "invalid.deprecated", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "invalid.illegal", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "invalid.unimplemented", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "message.error", + "settings": { + "foreground": "#fdaeb7" + } + }, + { + "scope": "string variable", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "source.regexp", + "string.regexp" + ], + "settings": { + "foreground": "#dbedff" + } + }, + { + "scope": [ + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition" + ], + "settings": { + "foreground": "#dbedff" + } + }, + { + "scope": "string.regexp constant.character.escape", + "settings": { + "fontStyle": "bold", + "foreground": "#85e89d" + } + }, + { + "scope": "support.constant", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "support.variable", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.module-reference", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "punctuation.definition.list.begin.markdown", + "settings": { + "foreground": "#ffab70" + } + }, + { + "scope": [ + "markup.heading", + "markup.heading entity.name" + ], + "settings": { + "fontStyle": "bold", + "foreground": "#79b8ff" + } + }, + { + "scope": "markup.quote", + "settings": { + "foreground": "#85e89d" + } + }, + { + "scope": "markup.italic", + "settings": { + "fontStyle": "italic", + "foreground": "#e1e4e8" + } + }, + { + "scope": "markup.bold", + "settings": { + "fontStyle": "bold", + "foreground": "#e1e4e8" + } + }, + { + "scope": "markup.underline", + "settings": { + "fontStyle": "underline" + } + }, + { + "scope": "markup.inline.raw", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted" + ], + "settings": { + "foreground": "#fdaeb7" + } + }, + { + "scope": [ + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted" + ], + "settings": { + "foreground": "#85e89d" + } + }, + { + "scope": [ + "markup.changed", + "punctuation.definition.changed" + ], + "settings": { + "foreground": "#ffab70" + } + }, + { + "scope": [ + "markup.ignored", + "markup.untracked" + ], + "settings": { + "foreground": "#2f363d" + } + }, + { + "scope": "meta.diff.range", + "settings": { + "fontStyle": "bold", + "foreground": "#b392f0" + } + }, + { + "scope": "meta.diff.header", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.separator", + "settings": { + "fontStyle": "bold", + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.output", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote" + ], + "settings": { + "foreground": "#d1d5da" + } + }, + { + "scope": "brackethighlighter.unmatched", + "settings": { + "foreground": "#fdaeb7" + } + }, + { + "scope": [ + "constant.other.reference.link", + "string.other.link" + ], + "settings": { + "fontStyle": "underline", + "foreground": "#dbedff" + } + } + ] +} diff --git a/mobile/packages/ui/showcase/lib/app_theme.dart b/mobile/packages/ui/showcase/lib/app_theme.dart new file mode 100644 index 0000000000..995bf3c91e --- /dev/null +++ b/mobile/packages/ui/showcase/lib/app_theme.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // Light theme colors + static const _primary500 = Color(0xFF4250AF); + static const _primary100 = Color(0xFFD4D6F0); + static const _primary900 = Color(0xFF181E44); + static const _danger500 = Color(0xFFE53E3E); + static const _light50 = Color(0xFFFAFAFA); + static const _light300 = Color(0xFFD4D4D4); + static const _light500 = Color(0xFF737373); + + // Dark theme colors + static const _darkPrimary500 = Color(0xFFACCBFA); + static const _darkPrimary300 = Color(0xFF616D94); + static const _darkDanger500 = Color(0xFFE88080); + static const _darkLight50 = Color(0xFF0A0A0A); + static const _darkLight100 = Color(0xFF171717); + static const _darkLight200 = Color(0xFF262626); + + static ThemeData get lightTheme { + return ThemeData( + colorScheme: const ColorScheme.light( + primary: _primary500, + onPrimary: Colors.white, + primaryContainer: _primary100, + onPrimaryContainer: _primary900, + secondary: _light500, + onSecondary: Colors.white, + error: _danger500, + onError: Colors.white, + surface: _light50, + onSurface: Color(0xFF1A1C1E), + surfaceContainerHighest: Color(0xFFE3E4E8), + outline: Color(0xFFD1D3D9), + outlineVariant: _light300, + ), + useMaterial3: true, + fontFamily: 'GoogleSans', + scaffoldBackgroundColor: _light50, + cardTheme: const CardThemeData( + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: _light300, width: 1), + ), + ), + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + foregroundColor: Color(0xFF1A1C1E), + ), + ); + } + + static ThemeData get darkTheme { + return ThemeData( + colorScheme: const ColorScheme.dark( + primary: _darkPrimary500, + onPrimary: Color(0xFF0F1433), + primaryContainer: _darkPrimary300, + onPrimaryContainer: _primary100, + secondary: Color(0xFFC4C6D0), + onSecondary: Color(0xFF2E3042), + error: _darkDanger500, + onError: Color(0xFF0F1433), + surface: _darkLight50, + onSurface: Color(0xFFE3E3E6), + surfaceContainerHighest: _darkLight200, + outline: Color(0xFF8E9099), + outlineVariant: Color(0xFF43464F), + ), + useMaterial3: true, + fontFamily: 'GoogleSans', + scaffoldBackgroundColor: _darkLight50, + cardTheme: const CardThemeData( + elevation: 0, + color: _darkLight100, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: _darkLight200, width: 1), + ), + ), + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + backgroundColor: _darkLight50, + surfaceTintColor: Colors.transparent, + foregroundColor: Color(0xFFE3E3E6), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/constants.dart b/mobile/packages/ui/showcase/lib/constants.dart new file mode 100644 index 0000000000..cfca4cfda9 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/constants.dart @@ -0,0 +1,16 @@ +const String appTitle = '@immich/ui'; + +class LayoutConstants { + static const double sidebarWidth = 220.0; + + static const double gridSpacing = 16.0; + static const double gridAspectRatio = 2.5; + + static const double borderRadiusSmall = 6.0; + static const double borderRadiusMedium = 8.0; + static const double borderRadiusLarge = 12.0; + + static const double iconSizeSmall = 16.0; + static const double iconSizeMedium = 18.0; + static const double iconSizeLarge = 20.0; +} diff --git a/mobile/packages/ui/showcase/lib/main.dart b/mobile/packages/ui/showcase/lib/main.dart new file mode 100644 index 0000000000..6cd2df4fe5 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/main.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/app_theme.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/router.dart'; +import 'package:showcase/widgets/example_card.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await initializeCodeHighlighter(); + runApp(const ShowcaseApp()); +} + +class ShowcaseApp extends StatefulWidget { + const ShowcaseApp({super.key}); + + @override + State createState() => _ShowcaseAppState(); +} + +class _ShowcaseAppState extends State { + ThemeMode _themeMode = ThemeMode.light; + late final GoRouter _router; + + @override + void initState() { + super.initState(); + _router = AppRouter.createRouter(_toggleTheme); + } + + void _toggleTheme() { + setState(() { + _themeMode = _themeMode == ThemeMode.light + ? ThemeMode.dark + : ThemeMode.light; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: appTitle, + themeMode: _themeMode, + routerConfig: _router, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + debugShowCheckedModeBanner: false, + builder: (context, child) => ImmichThemeProvider( + colorScheme: Theme.of(context).colorScheme, + child: child!, + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart b/mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart new file mode 100644 index 0000000000..1bae98e0a4 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class CloseButtonPage extends StatelessWidget { + const CloseButtonPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.closeButton.name, + child: ComponentExamples( + title: 'ImmichCloseButton', + subtitle: 'Pre-configured close button for dialogs and sheets.', + examples: [ + ExampleCard( + title: 'Default & Custom', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichCloseButton(onPressed: () {}), + ImmichCloseButton( + variant: ImmichVariant.filled, + onPressed: () {}, + ), + ImmichCloseButton( + color: ImmichColor.secondary, + onPressed: () {}, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart new file mode 100644 index 0000000000..af4c87f40e --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class HtmlTextBoldText extends StatelessWidget { + const HtmlTextBoldText({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichHtmlText( + 'This is bold text and strong text.', + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart new file mode 100644 index 0000000000..a764d7173e --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class HtmlTextLinks extends StatelessWidget { + const HtmlTextLinks({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichHtmlText( + 'Read the documentation or visit GitHub.', + linkHandlers: { + 'docs-link': () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))); + }, + 'github-link': () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))); + }, + }, + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart new file mode 100644 index 0000000000..836d949b66 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class HtmlTextNestedTags extends StatelessWidget { + const HtmlTextNestedTags({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichHtmlText( + 'You can combine bold and links together.', + linkHandlers: { + 'link': () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Nested link clicked!'))); + }, + }, + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/form_page.dart b/mobile/packages/ui/showcase/lib/pages/components/form_page.dart new file mode 100644 index 0000000000..14567031de --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/form_page.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class FormPage extends StatefulWidget { + const FormPage({super.key}); + + @override + State createState() => _FormPageState(); +} + +class _FormPageState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + String _result = ''; + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.form.name, + child: ComponentExamples( + title: 'ImmichForm', + subtitle: + 'Form container with built-in validation and submit handling.', + examples: [ + ExampleCard( + title: 'Login Form', + preview: Column( + children: [ + ImmichForm( + submitText: 'Login', + submitIcon: Icons.login, + onSubmit: () async { + await Future.delayed(const Duration(seconds: 1)); + setState(() { + _result = 'Form submitted!'; + }); + }, + child: Column( + spacing: 10, + children: [ + ImmichTextInput( + label: 'Email', + controller: _emailController, + keyboardType: TextInputType.emailAddress, + validator: (value) => + value?.isEmpty ?? true ? 'Required' : null, + ), + ImmichPasswordInput( + label: 'Password', + controller: _passwordController, + validator: (value) => + value?.isEmpty ?? true ? 'Required' : null, + ), + ], + ), + ), + if (_result.isNotEmpty) ...[ + const SizedBox(height: 16), + Text(_result, style: const TextStyle(color: Colors.green)), + ], + ], + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart b/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart new file mode 100644 index 0000000000..64dbc70597 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:showcase/pages/components/examples/html_text_bold_text.dart'; +import 'package:showcase/pages/components/examples/html_text_links.dart'; +import 'package:showcase/pages/components/examples/html_text_nested_tags.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class HtmlTextPage extends StatelessWidget { + const HtmlTextPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.htmlText.name, + child: ComponentExamples( + title: 'ImmichHtmlText', + subtitle: 'Render text with HTML formatting (bold, links).', + examples: [ + ExampleCard( + title: 'Bold Text', + preview: const HtmlTextBoldText(), + code: 'html_text_bold_text.dart', + ), + ExampleCard( + title: 'Links', + preview: const HtmlTextLinks(), + code: 'html_text_links.dart', + ), + ExampleCard( + title: 'Nested Tags', + preview: const HtmlTextNestedTags(), + code: 'html_text_nested_tags.dart', + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart b/mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart new file mode 100644 index 0000000000..4418b1de4f --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class IconButtonPage extends StatelessWidget { + const IconButtonPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.iconButton.name, + child: ComponentExamples( + title: 'ImmichIconButton', + subtitle: 'Icon-only button with customizable styling.', + examples: [ + ExampleCard( + title: 'Variants & Colors', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichIconButton( + icon: Icons.add, + onPressed: () {}, + variant: ImmichVariant.filled, + ), + ImmichIconButton( + icon: Icons.edit, + onPressed: () {}, + variant: ImmichVariant.ghost, + ), + ImmichIconButton( + icon: Icons.delete, + onPressed: () {}, + color: ImmichColor.secondary, + ), + ImmichIconButton( + icon: Icons.settings, + onPressed: () {}, + disabled: true, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart b/mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart new file mode 100644 index 0000000000..772dd7882f --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class PasswordInputPage extends StatelessWidget { + const PasswordInputPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.passwordInput.name, + child: ComponentExamples( + title: 'ImmichPasswordInput', + subtitle: 'Password field with visibility toggle.', + examples: [ + ExampleCard( + title: 'Password Input', + preview: ImmichPasswordInput( + label: 'Password', + hintText: 'Enter your password', + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password is required'; + } + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } + return null; + }, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart b/mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart new file mode 100644 index 0000000000..59e5b86294 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class TextButtonPage extends StatefulWidget { + const TextButtonPage({super.key}); + + @override + State createState() => _TextButtonPageState(); +} + +class _TextButtonPageState extends State { + bool _isLoading = false; + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.textButton.name, + child: ComponentExamples( + title: 'ImmichTextButton', + subtitle: + 'A versatile button component with multiple variants and color options.', + examples: [ + ExampleCard( + title: 'Variants', + description: + 'Filled and ghost variants for different visual hierarchy', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'Filled', + variant: ImmichVariant.filled, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Ghost', + variant: ImmichVariant.ghost, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'Colors', + description: 'Primary and secondary color options', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'Primary', + color: ImmichColor.primary, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Secondary', + color: ImmichColor.secondary, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'With Icons', + description: 'Add leading icons', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'With Icon', + icon: Icons.add, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Download', + icon: Icons.download, + variant: ImmichVariant.ghost, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'Loading State', + description: 'Shows loading indicator during async operations', + preview: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ImmichTextButton( + onPressed: () async { + setState(() => _isLoading = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _isLoading = false); + }, + labelText: _isLoading ? 'Loading...' : 'Click Me', + loading: _isLoading, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'Disabled State', + description: 'Buttons can be disabled', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'Disabled', + disabled: true, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Disabled Ghost', + variant: ImmichVariant.ghost, + disabled: true, + expanded: false, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart b/mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart new file mode 100644 index 0000000000..5a0bfec6cd --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class TextInputPage extends StatefulWidget { + const TextInputPage({super.key}); + + @override + State createState() => _TextInputPageState(); +} + +class _TextInputPageState extends State { + final _controller1 = TextEditingController(); + final _controller2 = TextEditingController(); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.textInput.name, + child: ComponentExamples( + title: 'ImmichTextInput', + subtitle: 'Text field with validation support.', + examples: [ + ExampleCard( + title: 'Basic Usage', + preview: Column( + children: [ + ImmichTextInput( + label: 'Email', + hintText: 'Enter your email', + controller: _controller1, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + ImmichTextInput( + label: 'Username', + controller: _controller2, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Username is required'; + } + if (value.length < 3) { + return 'Username must be at least 3 characters'; + } + return null; + }, + ), + ], + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _controller1.dispose(); + _controller2.dispose(); + super.dispose(); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart b/mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart new file mode 100644 index 0000000000..17de02d80a --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class ConstantsPage extends StatefulWidget { + const ConstantsPage({super.key}); + + @override + State createState() => _ConstantsPageState(); +} + +class _ConstantsPageState extends State { + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.constants.name, + child: ComponentExamples( + title: 'Constants', + subtitle: 'Consistent spacing, sizing, and styling constants.', + expand: true, + examples: [ + const ExampleCard( + title: 'Spacing', + description: 'ImmichSpacing (4.0 → 48.0)', + preview: Column( + children: [ + _SpacingBox(label: 'xs', size: ImmichSpacing.xs), + _SpacingBox(label: 'sm', size: ImmichSpacing.sm), + _SpacingBox(label: 'md', size: ImmichSpacing.md), + _SpacingBox(label: 'lg', size: ImmichSpacing.lg), + _SpacingBox(label: 'xl', size: ImmichSpacing.xl), + _SpacingBox(label: 'xxl', size: ImmichSpacing.xxl), + _SpacingBox(label: 'xxxl', size: ImmichSpacing.xxxl), + ], + ), + ), + const ExampleCard( + title: 'Border Radius', + description: 'ImmichRadius (0.0 → 24.0)', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _RadiusBox(label: 'none', radius: ImmichRadius.none), + _RadiusBox(label: 'xs', radius: ImmichRadius.xs), + _RadiusBox(label: 'sm', radius: ImmichRadius.sm), + _RadiusBox(label: 'md', radius: ImmichRadius.md), + _RadiusBox(label: 'lg', radius: ImmichRadius.lg), + _RadiusBox(label: 'xl', radius: ImmichRadius.xl), + _RadiusBox(label: 'xxl', radius: ImmichRadius.xxl), + ], + ), + ), + const ExampleCard( + title: 'Icon Sizes', + description: 'ImmichIconSize (16.0 → 48.0)', + preview: Wrap( + spacing: 16, + runSpacing: 16, + alignment: WrapAlignment.start, + children: [ + _IconSizeBox(label: 'xs', size: ImmichIconSize.xs), + _IconSizeBox(label: 'sm', size: ImmichIconSize.sm), + _IconSizeBox(label: 'md', size: ImmichIconSize.md), + _IconSizeBox(label: 'lg', size: ImmichIconSize.lg), + _IconSizeBox(label: 'xl', size: ImmichIconSize.xl), + _IconSizeBox(label: 'xxl', size: ImmichIconSize.xxl), + ], + ), + ), + const ExampleCard( + title: 'Text Sizes', + description: 'ImmichTextSize (10.0 → 60.0)', + preview: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Caption', + style: TextStyle(fontSize: ImmichTextSize.caption), + ), + Text('Label', style: TextStyle(fontSize: ImmichTextSize.label)), + Text('Body', style: TextStyle(fontSize: ImmichTextSize.body)), + Text('H6', style: TextStyle(fontSize: ImmichTextSize.h6)), + Text('H5', style: TextStyle(fontSize: ImmichTextSize.h5)), + Text('H4', style: TextStyle(fontSize: ImmichTextSize.h4)), + Text('H3', style: TextStyle(fontSize: ImmichTextSize.h3)), + Text('H2', style: TextStyle(fontSize: ImmichTextSize.h2)), + Text('H1', style: TextStyle(fontSize: ImmichTextSize.h1)), + ], + ), + ), + const ExampleCard( + title: 'Elevation', + description: 'ImmichElevation (0.0 → 16.0)', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _ElevationBox(label: 'none', elevation: ImmichElevation.none), + _ElevationBox(label: 'xs', elevation: ImmichElevation.xs), + _ElevationBox(label: 'sm', elevation: ImmichElevation.sm), + _ElevationBox(label: 'md', elevation: ImmichElevation.md), + _ElevationBox(label: 'lg', elevation: ImmichElevation.lg), + _ElevationBox(label: 'xl', elevation: ImmichElevation.xl), + _ElevationBox(label: 'xxl', elevation: ImmichElevation.xxl), + ], + ), + ), + const ExampleCard( + title: 'Border Width', + description: 'ImmichBorderWidth (0.5 → 4.0)', + preview: Column( + children: [ + _BorderBox( + label: 'hairline', + borderWidth: ImmichBorderWidth.hairline, + ), + _BorderBox(label: 'base', borderWidth: ImmichBorderWidth.base), + _BorderBox(label: 'md', borderWidth: ImmichBorderWidth.md), + _BorderBox(label: 'lg', borderWidth: ImmichBorderWidth.lg), + _BorderBox(label: 'xl', borderWidth: ImmichBorderWidth.xl), + ], + ), + ), + const ExampleCard( + title: 'Animation Durations', + description: 'ImmichDuration (100ms → 700ms)', + preview: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + _AnimatedDurationBox( + label: 'Extra Fast', + duration: ImmichDuration.extraFast, + ), + _AnimatedDurationBox( + label: 'Fast', + duration: ImmichDuration.fast, + ), + _AnimatedDurationBox( + label: 'Normal', + duration: ImmichDuration.normal, + ), + _AnimatedDurationBox( + label: 'Slow', + duration: ImmichDuration.slow, + ), + _AnimatedDurationBox( + label: 'Extra Slow', + duration: ImmichDuration.extraSlow, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SpacingBox extends StatelessWidget { + final String label; + final double size; + + const _SpacingBox({required this.label, required this.size}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + SizedBox( + width: 60, + child: Text( + label, + style: const TextStyle(fontFamily: 'GoogleSansCode'), + ), + ), + Container( + width: size, + height: 24, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text('${size.toStringAsFixed(1)}px'), + ], + ), + ); + } +} + +class _RadiusBox extends StatelessWidget { + final String label; + final double radius; + + const _RadiusBox({required this.label, required this.radius}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(radius), + ), + ), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12)), + ], + ); + } +} + +class _IconSizeBox extends StatelessWidget { + final String label; + final double size; + + const _IconSizeBox({required this.label, required this.size}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon(Icons.palette_rounded, size: size), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12)), + Text( + '${size.toStringAsFixed(0)}px', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + ], + ); + } +} + +class _ElevationBox extends StatelessWidget { + final String label; + final double elevation; + + const _ElevationBox({required this.label, required this.elevation}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Material( + elevation: elevation, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Container( + width: 60, + height: 60, + alignment: Alignment.center, + child: Text(label, style: const TextStyle(fontSize: 12)), + ), + ), + const SizedBox(height: 4), + Text( + elevation.toStringAsFixed(1), + style: const TextStyle(fontSize: 10), + ), + ], + ); + } +} + +class _BorderBox extends StatelessWidget { + final String label; + final double borderWidth; + + const _BorderBox({required this.label, required this.borderWidth}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: const TextStyle(fontFamily: 'GoogleSansCode'), + ), + ), + Expanded( + child: Container( + height: 40, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: borderWidth, + ), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + ), + const SizedBox(width: 8), + Text('${borderWidth.toStringAsFixed(1)}px'), + ], + ), + ); + } +} + +class _AnimatedDurationBox extends StatefulWidget { + final String label; + final Duration duration; + + const _AnimatedDurationBox({required this.label, required this.duration}); + + @override + State<_AnimatedDurationBox> createState() => _AnimatedDurationBoxState(); +} + +class _AnimatedDurationBoxState extends State<_AnimatedDurationBox> { + bool _atEnd = false; + bool _isAnimating = false; + + void _playAnimation() async { + if (_isAnimating) return; + setState(() => _isAnimating = true); + setState(() => _atEnd = true); + await Future.delayed(widget.duration); + if (!mounted) return; + setState(() => _atEnd = false); + await Future.delayed(widget.duration); + if (!mounted) return; + setState(() => _isAnimating = false); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + children: [ + SizedBox( + width: 90, + child: Text( + widget.label, + style: const TextStyle(fontFamily: 'GoogleSansCode', fontSize: 12), + ), + ), + Expanded( + child: Container( + height: 32, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(6), + ), + child: AnimatedAlign( + duration: widget.duration, + curve: Curves.easeInOut, + alignment: _atEnd ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 60, + height: 28, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Text( + '${widget.duration.inMilliseconds}ms', + style: TextStyle( + fontSize: 11, + color: colorScheme.onPrimary, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: _isAnimating ? null : _playAnimation, + icon: Icon( + Icons.play_arrow_rounded, + color: _isAnimating ? colorScheme.outline : colorScheme.primary, + ), + iconSize: 24, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/home_page.dart b/mobile/packages/ui/showcase/lib/pages/home_page.dart new file mode 100644 index 0000000000..de7af6c26b --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/home_page.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/routes.dart'; + +class HomePage extends StatelessWidget { + final VoidCallback onThemeToggle; + + const HomePage({super.key, required this.onThemeToggle}); + + @override + Widget build(BuildContext context) { + return Title( + title: appTitle, + color: Theme.of(context).colorScheme.primary, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + children: [ + Text( + appTitle, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + 'A collection of Flutter components that are shared across all Immich projects', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + height: 1.5, + ), + ), + const SizedBox(height: 48), + ...routesByCategory.entries.map((entry) { + if (entry.key == AppRouteCategory.root) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.key.displayName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 16), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: LayoutConstants.gridSpacing, + mainAxisSpacing: LayoutConstants.gridSpacing, + childAspectRatio: LayoutConstants.gridAspectRatio, + ), + itemCount: entry.value.length, + itemBuilder: (context, index) { + return _ComponentCard(route: entry.value[index]); + }, + ), + const SizedBox(height: 48), + ], + ); + }), + ], + ), + ); + } +} + +class _ComponentCard extends StatelessWidget { + final AppRoute route; + + const _ComponentCard({required this.route}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => context.go(route.path), + borderRadius: const BorderRadius.all(Radius.circular(LayoutConstants.borderRadiusLarge)), + child: Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(route.icon, size: 32, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 16), + Text( + route.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + + const SizedBox(height: 8), + Text( + route.description, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant, height: 1.4), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/router.dart b/mobile/packages/ui/showcase/lib/router.dart new file mode 100644 index 0000000000..014de44fd8 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/router.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:showcase/pages/components/close_button_page.dart'; +import 'package:showcase/pages/components/form_page.dart'; +import 'package:showcase/pages/components/html_text_page.dart'; +import 'package:showcase/pages/components/icon_button_page.dart'; +import 'package:showcase/pages/components/password_input_page.dart'; +import 'package:showcase/pages/components/text_button_page.dart'; +import 'package:showcase/pages/components/text_input_page.dart'; +import 'package:showcase/pages/design_system/constants_page.dart'; +import 'package:showcase/pages/home_page.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/shell_layout.dart'; + +class AppRouter { + static GoRouter createRouter(VoidCallback onThemeToggle) { + return GoRouter( + initialLocation: AppRoute.home.path, + routes: [ + ShellRoute( + builder: (context, state, child) => + ShellLayout(onThemeToggle: onThemeToggle, child: child), + routes: AppRoute.values + .map( + (route) => GoRoute( + path: route.path, + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: switch (route) { + AppRoute.home => HomePage(onThemeToggle: onThemeToggle), + AppRoute.textButton => const TextButtonPage(), + AppRoute.iconButton => const IconButtonPage(), + AppRoute.closeButton => const CloseButtonPage(), + AppRoute.textInput => const TextInputPage(), + AppRoute.passwordInput => const PasswordInputPage(), + AppRoute.form => const FormPage(), + AppRoute.htmlText => const HtmlTextPage(), + AppRoute.constants => const ConstantsPage(), + }, + ), + ), + ) + .toList(), + ), + ], + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/routes.dart b/mobile/packages/ui/showcase/lib/routes.dart new file mode 100644 index 0000000000..a39fb7bc34 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/routes.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +enum AppRouteCategory { + root(''), + forms('Forms'), + buttons('Buttons'), + designSystem('Design System'); + + final String displayName; + const AppRouteCategory(this.displayName); +} + +enum AppRoute { + home( + name: 'Home', + description: 'Home page', + path: '/', + category: AppRouteCategory.root, + icon: Icons.home_outlined, + ), + textButton( + name: 'Text Button', + description: 'Versatile button with filled and ghost variants', + path: '/text-button', + category: AppRouteCategory.buttons, + icon: Icons.smart_button_rounded, + ), + iconButton( + name: 'Icon Button', + description: 'Icon-only button with customizable styling', + path: '/icon-button', + category: AppRouteCategory.buttons, + icon: Icons.radio_button_unchecked_rounded, + ), + closeButton( + name: 'Close Button', + description: 'Pre-configured close button for dialogs', + path: '/close-button', + category: AppRouteCategory.buttons, + icon: Icons.close_rounded, + ), + textInput( + name: 'Text Input', + description: 'Text field with validation support', + path: '/text-input', + category: AppRouteCategory.forms, + icon: Icons.text_fields_outlined, + ), + passwordInput( + name: 'Password Input', + description: 'Password field with visibility toggle', + path: '/password-input', + category: AppRouteCategory.forms, + icon: Icons.password_outlined, + ), + form( + name: 'Form', + description: 'Form container with built-in validation', + path: '/form', + category: AppRouteCategory.forms, + icon: Icons.description_outlined, + ), + htmlText( + name: 'Html Text', + description: 'Render text with HTML formatting', + path: '/html-text', + category: AppRouteCategory.forms, + icon: Icons.code_rounded, + ), + constants( + name: 'Constants', + description: 'Spacing, colors, typography, and more', + path: '/constants', + category: AppRouteCategory.designSystem, + icon: Icons.palette_outlined, + ); + + final String name; + final String description; + final String path; + final AppRouteCategory category; + final IconData icon; + + const AppRoute({ + required this.name, + required this.description, + required this.path, + required this.category, + required this.icon, + }); +} + +final routesByCategory = AppRoute.values + .fold>>({}, (map, route) { + map.putIfAbsent(route.category, () => []).add(route); + return map; + }); diff --git a/mobile/packages/ui/showcase/lib/widgets/component_examples.dart b/mobile/packages/ui/showcase/lib/widgets/component_examples.dart new file mode 100644 index 0000000000..21e6516079 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/component_examples.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +class ComponentExamples extends StatelessWidget { + final String title; + final String? subtitle; + final List examples; + final bool expand; + + const ComponentExamples({ + super.key, + required this.title, + this.subtitle, + required this.examples, + this.expand = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 24, 24, 24), + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _PageHeader(title: title, subtitle: subtitle), + ), + const SliverPadding(padding: EdgeInsets.only(top: 24)), + if (expand) + SliverList.builder( + itemCount: examples.length, + itemBuilder: (context, index) => examples[index], + ) + else + SliverLayoutBuilder( + builder: (context, constraints) { + return SliverList.builder( + itemCount: examples.length, + itemBuilder: (context, index) => Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.crossAxisExtent * 0.6, + maxWidth: constraints.crossAxisExtent, + ), + child: IntrinsicWidth(child: examples[index]), + ), + ), + ); + }, + ), + ], + ), + ); + } +} + +class _PageHeader extends StatelessWidget { + final String title; + final String? subtitle; + + const _PageHeader({required this.title, this.subtitle}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.bold), + ), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/example_card.dart b/mobile/packages/ui/showcase/lib/widgets/example_card.dart new file mode 100644 index 0000000000..fea561afb6 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/example_card.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:showcase/constants.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +late final Highlighter _codeHighlighter; + +Future initializeCodeHighlighter() async { + await Highlighter.initialize(['dart']); + final darkTheme = await HighlighterTheme.loadFromAssets([ + 'assets/themes/github_dark.json', + ], const TextStyle(color: Color(0xFFe1e4e8))); + + _codeHighlighter = Highlighter(language: 'dart', theme: darkTheme); +} + +class ExampleCard extends StatefulWidget { + final String title; + final String? description; + final Widget preview; + final String? code; + + const ExampleCard({ + super.key, + required this.title, + this.description, + required this.preview, + this.code, + }); + + @override + State createState() => _ExampleCardState(); +} + +class _ExampleCardState extends State { + bool _showPreview = true; + String? code; + + @override + void initState() { + super.initState(); + if (widget.code != null) { + rootBundle + .loadString('lib/pages/components/examples/${widget.code!}') + .then((value) { + setState(() { + code = value; + }); + }); + } + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 1, + margin: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + if (widget.description != null) + Text( + widget.description!, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (code != null) ...[ + const SizedBox(width: 16), + Row( + children: [ + _ToggleButton( + icon: Icons.visibility_rounded, + label: 'Preview', + isSelected: _showPreview, + onTap: () => setState(() => _showPreview = true), + ), + const SizedBox(width: 8), + _ToggleButton( + icon: Icons.code_rounded, + label: 'Code', + isSelected: !_showPreview, + onTap: () => setState(() => _showPreview = false), + ), + ], + ), + ], + ], + ), + ), + const Divider(height: 1), + if (_showPreview) + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox(width: double.infinity, child: widget.preview), + ) + else + Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Color(0xFF24292e), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular( + LayoutConstants.borderRadiusMedium, + ), + bottomRight: Radius.circular( + LayoutConstants.borderRadiusMedium, + ), + ), + ), + child: _CodeCard(code: code!), + ), + ], + ), + ); + } +} + +class _ToggleButton extends StatelessWidget { + final IconData icon; + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _ToggleButton({ + required this.icon, + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(24)), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(24)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: Theme.of(context).colorScheme.onPrimary, + ), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ), + ); + } +} + +class _CodeCard extends StatelessWidget { + final String code; + + const _CodeCard({required this.code}); + + @override + Widget build(BuildContext context) { + final lines = code.split('\n'); + final lineNumberColor = Colors.white.withValues(alpha: 0.4); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate( + lines.length, + (index) => SizedBox( + height: 20, + child: Text( + '${index + 1}', + style: TextStyle( + fontFamily: 'GoogleSansCode', + fontSize: 13, + color: lineNumberColor, + height: 1.5, + ), + ), + ), + ), + ), + const SizedBox(width: 16), + SelectableText.rich( + _codeHighlighter.highlight(code), + style: const TextStyle( + fontFamily: 'GoogleSansCode', + fontSize: 13, + height: 1.54, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/page_title.dart b/mobile/packages/ui/showcase/lib/widgets/page_title.dart new file mode 100644 index 0000000000..eae3bf6ffb --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/page_title.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class PageTitle extends StatelessWidget { + final String title; + final Widget child; + + const PageTitle({super.key, required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + return Title( + title: '$title | @immich/ui', + color: Theme.of(context).colorScheme.primary, + child: child, + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/shell_layout.dart b/mobile/packages/ui/showcase/lib/widgets/shell_layout.dart new file mode 100644 index 0000000000..8bcb687e75 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/shell_layout.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/widgets/sidebar_navigation.dart'; + +class ShellLayout extends StatelessWidget { + final Widget child; + final VoidCallback onThemeToggle; + + const ShellLayout({ + super.key, + required this.child, + required this.onThemeToggle, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/immich_logo.png', height: 32, width: 32), + const SizedBox(width: 8), + Image.asset( + isDark + ? 'assets/immich-text-dark.png' + : 'assets/immich-text-light.png', + height: 24, + filterQuality: FilterQuality.none, + isAntiAlias: true, + ), + ], + ), + actions: [ + IconButton( + icon: Icon( + isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined, + size: LayoutConstants.iconSizeLarge, + ), + onPressed: onThemeToggle, + tooltip: 'Toggle theme', + ), + ], + shape: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1), + ), + ), + body: Row( + children: [ + const SidebarNavigation(), + const VerticalDivider(), + Expanded(child: child), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart b/mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart new file mode 100644 index 0000000000..10eba170e6 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/routes.dart'; + +class SidebarNavigation extends StatelessWidget { + const SidebarNavigation({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: LayoutConstants.sidebarWidth, + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), + children: [ + ...routesByCategory.entries.expand((entry) { + final category = entry.key; + final routes = entry.value; + return [ + if (category != AppRouteCategory.root) _CategoryHeader(category), + ...routes.map((route) => _NavItem(route)), + const SizedBox(height: 24), + ]; + }), + ], + ), + ); + } +} + +class _CategoryHeader extends StatelessWidget { + final AppRouteCategory category; + + const _CategoryHeader(this.category); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8), + child: Text( + category.displayName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + } +} + +class _NavItem extends StatelessWidget { + final AppRoute route; + + const _NavItem(this.route); + + @override + Widget build(BuildContext context) { + final currentRoute = GoRouterState.of(context).uri.toString(); + final isSelected = currentRoute == route.path; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + context.go(route.path); + }, + borderRadius: BorderRadius.circular( + LayoutConstants.borderRadiusMedium, + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? (isDark + ? Colors.white.withValues(alpha: 0.1) + : Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.5)) + : Colors.transparent, + borderRadius: BorderRadius.circular( + LayoutConstants.borderRadiusMedium, + ), + ), + child: Row( + children: [ + Icon( + route.icon, + size: 20, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + route.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/pubspec.lock b/mobile/packages/ui/showcase/pubspec.lock new file mode 100644 index 0000000000..4d8ec62b90 --- /dev/null +++ b/mobile/packages/ui/showcase/pubspec.lock @@ -0,0 +1,393 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + url: "https://pub.dev" + source: hosted + version: "11.5.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + url: "https://pub.dev" + source: hosted + version: "2.1.5" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a + url: "https://pub.dev" + source: hosted + version: "17.0.1" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + immich_ui: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.0.0" + irondash_engine_context: + dependency: transitive + description: + name: irondash_engine_context + sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7" + url: "https://pub.dev" + source: hosted + version: "0.5.5" + irondash_message_channel: + dependency: transitive + description: + name: irondash_message_channel + sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 + url: "https://pub.dev" + source: hosted + version: "0.7.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pixel_snap: + dependency: transitive + description: + name: pixel_snap + sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" + url: "https://pub.dev" + source: hosted + version: "0.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + super_clipboard: + dependency: transitive + description: + name: super_clipboard + sha256: e73f3bb7e66cc9260efa1dc507f979138e7e106c3521e2dda2d0311f6d728a16 + url: "https://pub.dev" + source: hosted + version: "0.9.1" + super_native_extensions: + dependency: transitive + description: + name: super_native_extensions + sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569 + url: "https://pub.dev" + source: hosted + version: "0.9.1" + syntax_highlight: + dependency: "direct main" + description: + name: syntax_highlight + sha256: "4d3ba40658cadba6ba55d697f29f00b43538ebb6eb4a0ca0e895c568eaced138" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" +sdks: + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.35.0" diff --git a/mobile/packages/ui/showcase/pubspec.yaml b/mobile/packages/ui/showcase/pubspec.yaml new file mode 100644 index 0000000000..e45ce07e66 --- /dev/null +++ b/mobile/packages/ui/showcase/pubspec.yaml @@ -0,0 +1,47 @@ +name: showcase +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.9.2 + +dependencies: + flutter: + sdk: flutter + immich_ui: + path: ../ + go_router: ^17.0.1 + syntax_highlight: ^0.5.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + assets: + - assets/ + - assets/themes/ + - lib/pages/components/examples/ + + fonts: + - family: GoogleSans + fonts: + - asset: ../../../fonts/GoogleSans/GoogleSans-Regular.ttf + - asset: ../../../fonts/GoogleSans/GoogleSans-Italic.ttf + style: italic + - asset: ../../../fonts/GoogleSans/GoogleSans-Medium.ttf + weight: 500 + - asset: ../../../fonts/GoogleSans/GoogleSans-SemiBold.ttf + weight: 600 + - asset: ../../../fonts/GoogleSans/GoogleSans-Bold.ttf + weight: 700 + - family: GoogleSansCode + fonts: + - asset: ../../../fonts/GoogleSansCode/GoogleSansCode-Regular.ttf + - asset: ../../../fonts/GoogleSansCode/GoogleSansCode-Medium.ttf + weight: 500 + - asset: ../../../fonts/GoogleSansCode/GoogleSansCode-SemiBold.ttf + weight: 600 \ No newline at end of file diff --git a/mobile/packages/ui/showcase/web/favicon.ico b/mobile/packages/ui/showcase/web/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7ec34e9e53c53af721fe70e6177fd4f8e75625f0 GIT binary patch literal 15086 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 rx2V}n>sIfhp;D>8Y*wmoJJ=30I)9_=xAE{ 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 literal 0 HcmV?d00001 diff --git a/mobile/packages/ui/showcase/web/icons/Icon-maskable-512.png b/mobile/packages/ui/showcase/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..a7220554bced1f2d0702d9911829b9272716d509 GIT binary patch literal 13544 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 literal 0 HcmV?d00001 diff --git a/mobile/packages/ui/showcase/web/icons/apple-icon-180.png b/mobile/packages/ui/showcase/web/icons/apple-icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..4e642631a3ad61c030b0fca2bb1f2b92efd16d34 GIT binary patch literal 6358 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 Y2jUXJ7`x~Fp8x;=07*qoM6N<$g8y~;Bme*a literal 0 HcmV?d00001 diff --git a/mobile/packages/ui/showcase/web/index.html b/mobile/packages/ui/showcase/web/index.html new file mode 100644 index 0000000000..abf42ad1fd --- /dev/null +++ b/mobile/packages/ui/showcase/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + @immich/ui + + + + + + diff --git a/mobile/packages/ui/showcase/web/manifest.json b/mobile/packages/ui/showcase/web/manifest.json new file mode 100644 index 0000000000..25b44bd1ae --- /dev/null +++ b/mobile/packages/ui/showcase/web/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "@immich/ui Showcase", + "short_name": "@immich/ui", + "start_url": ".", + "display": "standalone", + "background_color": "#FCFCFD", + "theme_color": "#4250AF", + "description": "Immich UI component library showcase and documentation", + "orientation": "landscape", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/mobile/packages/ui/test/html_test.dart b/mobile/packages/ui/test/html_test.dart new file mode 100644 index 0000000000..27f68ff66c --- /dev/null +++ b/mobile/packages/ui/test/html_test.dart @@ -0,0 +1,266 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_ui/src/components/html_text.dart'; + +import 'test_utils.dart'; + +/// Text.rich creates a nested structure: root -> wrapper -> actual children +List _getContentSpans(WidgetTester tester) { + final richText = tester.widget(find.byType(RichText)); + final root = richText.text as TextSpan; + + if (root.children?.isNotEmpty ?? false) { + final wrapper = root.children!.first; + if (wrapper is TextSpan && wrapper.children != null) { + return wrapper.children!; + } + } + return []; +} + +TextSpan _findSpan(List spans, String text) { + return spans.firstWhere( + (span) => span is TextSpan && span.text == text, + orElse: () => throw StateError('No span found with text: "$text"'), + ) as TextSpan; +} + +String _concatenateText(List spans) { + return spans.whereType().map((s) => s.text ?? '').join(); +} + +void _triggerTap(TextSpan span) { + final recognizer = span.recognizer; + if (recognizer is TapGestureRecognizer) { + recognizer.onTap?.call(); + } +} + +void main() { + group('ImmichHtmlText', () { + testWidgets('renders plain text without HTML tags', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('This is plain text'), + ); + + expect(find.text('This is plain text'), findsOneWidget); + }); + + testWidgets('handles mixed content with bold and links', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'This is an example of HTML text with bold.', + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + + final exampleSpan = _findSpan(spans, 'example'); + expect(exampleSpan.style?.fontWeight, FontWeight.bold); + + final boldSpan = _findSpan(spans, 'bold'); + expect(boldSpan.style?.fontWeight, FontWeight.bold); + + final linkSpan = _findSpan(spans, 'HTML text'); + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.style?.fontWeight, FontWeight.bold); + expect(linkSpan.recognizer, isA()); + + expect(_concatenateText(spans), 'This is an example of HTML text with bold.'); + }); + + testWidgets('applies text style properties', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText( + 'Test text', + style: TextStyle( + fontSize: 16, + color: Colors.purple, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + + final text = tester.widget(find.byType(Text)); + final richText = text.textSpan as TextSpan; + + expect(richText.style?.fontSize, 16); + expect(richText.style?.color, Colors.purple); + expect(text.textAlign, TextAlign.center); + expect(text.maxLines, 2); + expect(text.overflow, TextOverflow.ellipsis); + }); + + testWidgets('handles text with special characters', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('Text with & < > " \' characters'), + ); + + expect(find.byType(RichText), findsOneWidget); + + final spans = _getContentSpans(tester); + expect(_concatenateText(spans), 'Text with & < > " \' characters'); + }); + + group('bold', () { + testWidgets('renders bold text with tag', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('This is bold text'), + ); + + final spans = _getContentSpans(tester); + final boldSpan = _findSpan(spans, 'bold'); + + expect(boldSpan.style?.fontWeight, FontWeight.bold); + expect(_concatenateText(spans), 'This is bold text'); + }); + + testWidgets('renders bold text with tag', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('This is strong text'), + ); + + final spans = _getContentSpans(tester); + final strongSpan = _findSpan(spans, 'strong'); + + expect(strongSpan.style?.fontWeight, FontWeight.bold); + }); + + testWidgets('handles nested bold tags', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('Text with bold and nested'), + ); + + final spans = _getContentSpans(tester); + + final nestedSpan = _findSpan(spans, 'nested'); + expect(nestedSpan.style?.fontWeight, FontWeight.bold); + + final boldSpan = _findSpan(spans, 'bold and '); + expect(boldSpan.style?.fontWeight, FontWeight.bold); + + expect(_concatenateText(spans), 'Text with bold and nested'); + }); + }); + + group('link', () { + testWidgets('renders link text with tag', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'This is a custom link text', + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'custom link'); + + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.recognizer, isA()); + }); + + testWidgets('handles link tap with callback', (tester) async { + var linkTapped = false; + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Tap here', + linkHandlers: {'link': () => linkTapped = true}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'here'); + expect(linkSpan.recognizer, isA()); + + _triggerTap(linkSpan); + expect(linkTapped, isTrue); + }); + + testWidgets('handles custom prefixed link tags', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'Refer to docs and other', + linkHandlers: { + 'docs-link': () {}, + 'other-link': () {}, + }, + ), + ); + + final spans = _getContentSpans(tester); + final docsSpan = _findSpan(spans, 'docs'); + final otherSpan = _findSpan(spans, 'other'); + + expect(docsSpan.style?.decoration, TextDecoration.underline); + expect(otherSpan.style?.decoration, TextDecoration.underline); + }); + + testWidgets('applies custom link style', (tester) async { + const customLinkStyle = TextStyle( + color: Colors.red, + decoration: TextDecoration.overline, + ); + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Click here', + linkStyle: customLinkStyle, + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'here'); + + expect(linkSpan.style?.color, Colors.red); + expect(linkSpan.style?.decoration, TextDecoration.overline); + }); + + testWidgets('link without handler renders but is not tappable', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'Link without handler: click me', + linkHandlers: {'other-link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'click me'); + + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.recognizer, isNull); + }); + + testWidgets('handles multiple links with different handlers', (tester) async { + var firstLinkTapped = false; + var secondLinkTapped = false; + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Go to docs or help', + linkHandlers: { + 'docs-link': () => firstLinkTapped = true, + 'help-link': () => secondLinkTapped = true, + }, + ), + ); + + final spans = _getContentSpans(tester); + final docsSpan = _findSpan(spans, 'docs'); + final helpSpan = _findSpan(spans, 'help'); + + _triggerTap(docsSpan); + expect(firstLinkTapped, isTrue); + expect(secondLinkTapped, isFalse); + + _triggerTap(helpSpan); + expect(secondLinkTapped, isTrue); + }); + }); + }); +} diff --git a/mobile/packages/ui/test/test_utils.dart b/mobile/packages/ui/test/test_utils.dart new file mode 100644 index 0000000000..42cc74da87 --- /dev/null +++ b/mobile/packages/ui/test/test_utils.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension WidgetTesterExtension on WidgetTester { + /// Pumps a widget wrapped in MaterialApp and Scaffold for testing. + Future pumpTestWidget(Widget widget) { + return pumpWidget(MaterialApp(home: Scaffold(body: widget))); + } +} From b2a510efee57dc33c7e57f88636be121b1953d40 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 19 Feb 2026 13:52:21 -0500 Subject: [PATCH 073/143] refactor: remove unused actions (#26363) --- web/src/lib/components/asset-viewer/actions/action.ts | 2 -- web/src/lib/components/asset-viewer/asset-viewer.svelte | 1 - web/src/lib/components/timeline/TimelineAssetViewer.svelte | 3 +-- web/src/lib/constants.ts | 2 -- 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index 19cc5afa8d..812047e350 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -8,11 +8,9 @@ type ActionMap = { [AssetAction.TRASH]: { asset: TimelineAsset }; [AssetAction.DELETE]: { asset: TimelineAsset }; [AssetAction.RESTORE]: { asset: TimelineAsset }; - [AssetAction.ADD]: { asset: TimelineAsset }; [AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto }; [AssetAction.STACK]: { stack: StackResponseDto }; [AssetAction.UNSTACK]: { assets: TimelineAsset[] }; - [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset }; [AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto }; [AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto }; [AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset }; diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 8c9bb4156b..8811d46ff4 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -339,7 +339,6 @@ }; break; } - case AssetAction.KEEP_THIS_DELETE_OTHERS: case AssetAction.UNSTACK: { closeViewer(); break; diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index e26d969858..628c501397 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -127,8 +127,7 @@ const handleAction = (action: Action) => { switch (action.type) { case AssetAction.ARCHIVE: - case AssetAction.UNARCHIVE: - case AssetAction.ADD: { + case AssetAction.UNARCHIVE: { timelineManager.upsertAssets([action.asset]); break; } diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 53a73d471d..30ae796136 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -6,11 +6,9 @@ export enum AssetAction { TRASH = 'trash', DELETE = 'delete', RESTORE = 'restore', - ADD = 'add', ADD_TO_ALBUM = 'add-to-album', STACK = 'stack', UNSTACK = 'unstack', - KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others', SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset', REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack', SET_VISIBILITY_LOCKED = 'set-visibility-locked', From a43680c8b1a281a05c8e6d52ca6fe41473364988 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:18:44 +0000 Subject: [PATCH 074/143] chore(mobile): simplify drag logic (#26291) We were manually tracking whether gestures should be blocked, which was a remnant of how the old code worked. This is no longer needed as we have better heuristics for knowing whether we should skip drag updates now. Co-authored-by: Alex --- .../asset_viewer/asset_page.widget.dart | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 3dfc37e2f3..a294adb669 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -45,7 +45,6 @@ class _AssetPageState extends ConsumerState { late PhotoViewControllerValue _initialPhotoViewState; - bool _blockGestures = false; bool _showingDetails = false; bool _isZoomed = false; @@ -58,7 +57,6 @@ class _AssetPageState extends ConsumerState { DragStartDetails? _dragStart; _DragIntent _dragIntent = _DragIntent.none; Drag? _drag; - bool _dragInProgress = false; bool _shouldPopOnDrag = false; @override @@ -137,9 +135,7 @@ class _AssetPageState extends ConsumerState { } void _updateDrag(DragUpdateDetails details) { - if (_blockGestures) return; - - _dragInProgress = true; + if (_dragStart == null) return; if (_dragIntent == _DragIntent.none) { _dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) { @@ -160,16 +156,12 @@ class _AssetPageState extends ConsumerState { } void _endDrag(DragEndDetails details) { - _dragInProgress = false; + if (_dragStart == null) return; - if (_blockGestures) { - _blockGestures = false; - return; - } + _dragStart = null; final intent = _dragIntent; _dragIntent = _DragIntent.none; - _dragStart = null; switch (intent) { case _DragIntent.none: @@ -201,10 +193,7 @@ class _AssetPageState extends ConsumerState { PhotoViewScaleStateController scaleStateController, ) { _viewController = controller; - if (!_showingDetails && _isZoomed) { - _blockGestures = true; - return; - } + if (!_showingDetails && _isZoomed) return; _beginDrag(details); } @@ -235,7 +224,7 @@ class _AssetPageState extends ConsumerState { } void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) { - if (!_showingDetails && !_dragInProgress) _viewer.toggleControls(); + if (!_showingDetails && _dragStart == null) _viewer.toggleControls(); } void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) => @@ -249,7 +238,7 @@ class _AssetPageState extends ConsumerState { _viewer.setZoomed(_isZoomed); if (scaleState != PhotoViewScaleState.initial) { - if (!_dragInProgress) _viewer.setControls(false); + if (_dragStart == null) _viewer.setControls(false); ref.read(videoPlayerControlsProvider.notifier).pause(); return; From 8eec3c810ed952db4648c7666a375dcef88f6dd3 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 21:21:03 +0100 Subject: [PATCH 075/143] fix(web): single select scroll behavior (#26358) refactor(timeline): remove single select scroll behavior --- web/src/lib/components/timeline/Timeline.svelte | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 9c07bff828..04f833e87a 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -419,11 +419,6 @@ } onSelect(asset); - if (singleSelect) { - timelineManager.scrollTo(0); - return; - } - const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0; const deselect = assetInteraction.hasSelectedAsset(asset.id); From 1d11106dd09cc07033dd95b59f4f1d82fe1942bb Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 19 Feb 2026 15:27:30 -0500 Subject: [PATCH 076/143] refactor: add to album (#26366) --- .../components/asset-viewer/actions/action.ts | 3 +- .../actions/add-to-album-action.svelte | 44 ---------- .../asset-viewer/asset-viewer-nav-bar.svelte | 13 ++- .../asset-viewer/asset-viewer.svelte | 14 +--- .../memory-page/memory-viewer.svelte | 7 +- .../timeline/actions/AddToAlbumAction.svelte | 54 ------------ web/src/lib/constants.ts | 1 - web/src/lib/managers/event-manager.svelte.ts | 2 +- .../lib/modals/AssetAddToAlbumModal.svelte | 27 ++++++ web/src/lib/services/album.service.ts | 82 +++++++++++++++++-- web/src/lib/services/asset.service.ts | 20 ++++- .../lib/stores/asset-interaction.svelte.ts | 9 ++ web/src/lib/utils/asset-utils.ts | 76 ----------------- web/src/lib/utils/file-uploader.ts | 4 +- .../[[assetId=id]]/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 7 +- .../[[assetId=id]]/+page.svelte | 7 +- .../[[assetId=id]]/+page.svelte | 10 +-- .../[[assetId=id]]/+page.svelte | 9 +- .../[[assetId=id]]/+page.svelte | 16 +++- .../(user)/photos/[[assetId=id]]/+page.svelte | 9 +- .../[[assetId=id]]/+page.svelte | 16 ++-- .../[[assetId=id]]/+page.svelte | 8 +- 23 files changed, 202 insertions(+), 242 deletions(-) delete mode 100644 web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte delete mode 100644 web/src/lib/components/timeline/actions/AddToAlbumAction.svelte create mode 100644 web/src/lib/modals/AssetAddToAlbumModal.svelte diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index 812047e350..df57d73a7e 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -1,6 +1,6 @@ import type { AssetAction } from '$lib/constants'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk'; +import type { AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk'; type ActionMap = { [AssetAction.ARCHIVE]: { asset: TimelineAsset }; @@ -8,7 +8,6 @@ type ActionMap = { [AssetAction.TRASH]: { asset: TimelineAsset }; [AssetAction.DELETE]: { asset: TimelineAsset }; [AssetAction.RESTORE]: { asset: TimelineAsset }; - [AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto }; [AssetAction.STACK]: { stack: StackResponseDto }; [AssetAction.UNSTACK]: { assets: TimelineAsset[] }; [AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto }; diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte deleted file mode 100644 index cf8ba15024..0000000000 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - - diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 93ce2f01e3..e3b03c3a7b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -3,7 +3,6 @@ import ActionButton from '$lib/components/ActionButton.svelte'; import ActionMenuItem from '$lib/components/ActionMenuItem.svelte'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; - import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; @@ -102,6 +101,7 @@ Unfavorite, PlayMotionPhoto, StopMotionPhoto, + AddToAlbum, ZoomIn, ZoomOut, Copy, @@ -129,6 +129,7 @@ Unfavorite, PlayMotionPhoto, StopMotionPhoto, + AddToAlbum, ZoomIn, ZoomOut, Copy, @@ -181,14 +182,12 @@ - {#if !isLocked} - {#if asset.isTrashed} - - {:else} - - {/if} + {#if !isLocked && asset.isTrashed} + {/if} + + {#if isOwner} {#if stack} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 8811d46ff4..c011a5e466 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -167,9 +167,7 @@ }), ); - if (!sharedLink) { - await handleGetAllAlbums(); - } + await onAlbumAddAssets(); }); onDestroy(() => { @@ -182,7 +180,7 @@ syncAssetViewerOpenClass(false); }); - const handleGetAllAlbums = async () => { + const onAlbumAddAssets = async () => { if (authManager.isSharedLink) { return; } @@ -303,10 +301,6 @@ }; const handleAction = async (action: Action) => { switch (action.type) { - case AssetAction.ADD_TO_ALBUM: { - await handleGetAllAlbums(); - break; - } case AssetAction.DELETE: case AssetAction.TRASH: { eventManager.emit('AssetsDelete', [asset.id]); @@ -369,7 +363,7 @@ const refresh = async () => { await refreshStack(); - await handleGetAllAlbums(); + await onAlbumAddAssets(); ocrManager.clear(); if (!sharedLink) { if (previewStackedAsset) { @@ -441,7 +435,7 @@ - + diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 20d43f1974..49fb7fa6b9 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -10,7 +10,6 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -25,6 +24,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte'; @@ -34,7 +34,7 @@ import { cancelMultiselect } from '$lib/utils/asset-utils'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk'; - import { IconButton, toastManager } from '@immich/ui'; + import { ActionButton, IconButton, toastManager } from '@immich/ui'; import { mdiCardsOutline, mdiChevronDown, @@ -328,6 +328,7 @@ assets={assetInteraction.selectedAssets} clearSelect={() => cancelMultiselect(assetInteraction)} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} - + diff --git a/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte b/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte deleted file mode 100644 index 6dce0ce084..0000000000 --- a/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - -{#if menuItem} - -{/if} - -{#if !menuItem} - -{/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 30ae796136..389ebbefab 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -6,7 +6,6 @@ export enum AssetAction { TRASH = 'trash', DELETE = 'delete', RESTORE = 'restore', - ADD_TO_ALBUM = 'add-to-album', STACK = 'stack', UNSTACK = 'unstack', SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset', diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index ead2ffe4b0..b161356a68 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -39,7 +39,7 @@ export type Events = { AssetEditsApplied: [string]; AssetsTag: [string[]]; - AlbumAddAssets: []; + AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }]; AlbumUpdate: [AlbumResponseDto]; AlbumDelete: [AlbumResponseDto]; AlbumShare: []; diff --git a/web/src/lib/modals/AssetAddToAlbumModal.svelte b/web/src/lib/modals/AssetAddToAlbumModal.svelte new file mode 100644 index 0000000000..b35c125d08 --- /dev/null +++ b/web/src/lib/modals/AssetAddToAlbumModal.svelte @@ -0,0 +1,27 @@ + + + diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index ac0a1045b3..05e0fdb78d 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -1,6 +1,7 @@ import { goto } from '$app/navigation'; import ToastAction from '$lib/components/ToastAction.svelte'; import { AlbumPageViewMode } from '$lib/constants'; +import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte'; @@ -11,17 +12,22 @@ import { user } from '$lib/stores/user.store'; import { createAlbumAndRedirect } from '$lib/utils/album-utils'; import { downloadArchive } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; + import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { - addAssetsToAlbum, + addAssetsToAlbum as addToAlbum, + addAssetsToAlbums as addToAlbums, addUsersToAlbum, AlbumUserRole, + BulkIdErrorReason, deleteAlbum, removeUserFromAlbum, updateAlbumInfo, updateAlbumUser, type AlbumResponseDto, + type AlbumsAddAssetsResponseDto, + type BulkIdResponseDto, type UpdateAlbumDto, type UserResponseDto, } from '@immich/sdk'; @@ -86,7 +92,12 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse color: 'primary', icon: mdiPlusBoxOutline, $if: () => assets.length > 0, - onAction: () => addAssets(album, assets), + onAction: () => + addAssetsToAlbums( + [album.id], + assets.map(({ id }) => id), + { notify: true }, + ).then(() => undefined), }; const Upload: ActionItem = { @@ -100,18 +111,73 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse return { AddAssets, Upload }; }; -const addAssets = async (album: AlbumResponseDto, assets: TimelineAsset[]) => { +export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], { notify }: { notify: boolean }) => { const $t = await getFormatter(); - const assetIds = assets.map(({ id }) => id); try { - const results = await addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: assetIds } }); + if (albumIds.length === 1) { + const albumId = albumIds[0]; + const results = await addToAlbum({ ...authManager.params, id: albumId, bulkIdsDto: { ids: assetIds } }); + if (notify) { + notifyAddToAlbum($t, albumId, assetIds, results); + } + } - const count = results.filter(({ success }) => success).length; - toastManager.success($t('assets_added_count', { values: { count } })); - eventManager.emit('AlbumAddAssets'); + if (albumIds.length > 1) { + const results = await addToAlbums({ ...authManager.params, albumsAddAssetsDto: { albumIds, assetIds } }); + if (notify) { + notifyAddToAlbums($t, albumIds, assetIds, results); + } + } + + eventManager.emit('AlbumAddAssets', { assetIds, albumIds }); + return true; } catch (error) { handleError(error, $t('errors.error_adding_assets_to_album')); + return false; + } +}; + +const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: string[], results: BulkIdResponseDto[]) => { + const successCount = results.filter(({ success }) => success).length; + const duplicateCount = results.filter(({ error }) => error === 'duplicate').length; + let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }); + if (successCount > 0) { + description = $t('assets_added_to_album_count', { values: { count: successCount } }); + } else if (duplicateCount > 0) { + description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } }); + } + + toastManager.custom( + { + component: ToastAction, + props: { + title: $t('info'), + color: 'primary', + description, + button: { text: $t('view_album'), color: 'primary', onClick: () => goto(Route.viewAlbum({ id: albumId })) }, + }, + }, + { timeout: 5000 }, + ); +}; + +const notifyAddToAlbums = ( + $t: MessageFormatter, + albumIds: string[], + assetIds: string[], + results: AlbumsAddAssetsResponseDto, +) => { + if (results.error === BulkIdErrorReason.Duplicate) { + toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } })); + } else if (results.error) { + toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } })); + } else { + toastManager.success( + $t('assets_added_to_albums_count', { + values: { albumTotal: albumIds.length, assetTotal: assetIds.length }, + }), + ); } }; diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index f9b33d5687..bbe4d9301b 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -2,6 +2,7 @@ import { ProjectionType } from '$lib/constants'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; +import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte'; import AssetTagModal from '$lib/modals/AssetTagModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import { user as authUser, preferences } from '$lib/stores/user.store'; @@ -42,6 +43,7 @@ import { mdiMagnifyPlusOutline, mdiMotionPauseOutline, mdiMotionPlayOutline, + mdiPlus, mdiShareVariantOutline, mdiTagPlusOutline, mdiTune, @@ -59,6 +61,13 @@ export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlConte ctx.clearSelect(); }; + const AddToAlbum: ActionItem = { + title: $t('add_to_album'), + icon: mdiPlus, + shortcuts: [{ key: 'l' }], + onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds }), + }; + const RefreshFacesJob: ActionItem = { title: $t('refresh_faces'), icon: mdiHeadSyncOutline, @@ -84,7 +93,7 @@ export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlConte $if: () => isAllVideos, }; - return { RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob }; + return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob }; }; export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => { @@ -161,6 +170,14 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = shortcuts: [{ key: 'f' }], }; + const AddToAlbum: ActionItem = { + title: $t('add_to_album'), + icon: mdiPlus, + shortcuts: [{ key: 'l' }], + $if: () => asset.visibility !== AssetVisibility.Locked && !asset.isTrashed, + onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds: [asset.id] }), + }; + const Offline: ActionItem = { title: $t('asset_offline'), icon: mdiAlertOutline, @@ -260,6 +277,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = Unfavorite, PlayMotionPhoto, StopMotionPhoto, + AddToAlbum, ZoomIn, ZoomOut, Copy, diff --git a/web/src/lib/stores/asset-interaction.svelte.ts b/web/src/lib/stores/asset-interaction.svelte.ts index 817354e619..48c8080269 100644 --- a/web/src/lib/stores/asset-interaction.svelte.ts +++ b/web/src/lib/stores/asset-interaction.svelte.ts @@ -1,5 +1,6 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { user } from '$lib/stores/user.store'; +import type { AssetControlContext } from '$lib/types'; import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { fromStore } from 'svelte/store'; @@ -22,6 +23,14 @@ export class AssetInteraction { private user = fromStore(user); private userId = $derived(this.user.current?.id); + asControlContext(): AssetControlContext { + return { + getOwnedAssets: () => this.selectedAssets.filter((asset) => asset.ownerId === this.userId), + getAssets: () => this.selectedAssets, + clearSelect: () => this.clearMultiselect(), + }; + } + isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed)); isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === AssetVisibility.Archive)); isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite)); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index fc3911e45b..73a6965dd9 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,10 +1,8 @@ -import { goto } from '$app/navigation'; import ToastAction from '$lib/components/ToastAction.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { downloadManager } from '$lib/managers/download-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { Route } from '$lib/route'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; import { downloadRequest, withError } from '$lib/utils'; @@ -13,10 +11,7 @@ import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; import { asQueryString } from '$lib/utils/shared-links'; import { - addAssetsToAlbum as addAssets, - addAssetsToAlbums as addToAlbums, AssetVisibility, - BulkIdErrorReason, bulkTagAssets, createStack, deleteAssets, @@ -41,77 +36,6 @@ import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; import { handleError } from './handle-error'; -export const addAssetsToAlbum = async (albumId: string, assetIds: string[], showNotification = true) => { - const result = await addAssets({ - ...authManager.params, - id: albumId, - bulkIdsDto: { - ids: assetIds, - }, - }); - const count = result.filter(({ success }) => success).length; - const duplicateErrorCount = result.filter(({ error }) => error === 'duplicate').length; - const $t = get(t); - - if (showNotification) { - let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }); - if (count > 0) { - description = $t('assets_added_to_album_count', { values: { count } }); - } else if (duplicateErrorCount > 0) { - description = $t('assets_were_part_of_album_count', { values: { count: duplicateErrorCount } }); - } - toastManager.custom( - { - component: ToastAction, - props: { - title: $t('info'), - color: 'primary', - description, - button: { - text: $t('view_album'), - color: 'primary', - onClick() { - return goto(Route.viewAlbum({ id: albumId })); - }, - }, - }, - }, - { timeout: 5000 }, - ); - } -}; - -export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], showNotification = true) => { - const result = await addToAlbums({ - ...authManager.params, - albumsAddAssetsDto: { - albumIds, - assetIds, - }, - }); - - if (!showNotification) { - return result; - } - - if (showNotification) { - const $t = get(t); - - if (result.error === BulkIdErrorReason.Duplicate) { - toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } })); - return result; - } - if (result.error) { - toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } })); - return result; - } - toastManager.success( - $t('assets_added_to_albums_count', { values: { albumTotal: albumIds.length, assetTotal: assetIds.length } }), - ); - return result; - } -}; - export const tagAssets = async ({ assetIds, tagIds, diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 8558244cfb..e33022eb37 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -1,10 +1,10 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { uploadManager } from '$lib/managers/upload-manager.svelte'; +import { addAssetsToAlbums } from '$lib/services/album.service'; import { uploadAssetsStore } from '$lib/stores/upload'; import { user } from '$lib/stores/user.store'; import { UploadState } from '$lib/types'; import { uploadRequest } from '$lib/utils'; -import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { ExecutorQueue } from '$lib/utils/executor-queue'; import { asQueryString } from '$lib/utils/shared-links'; import { @@ -213,7 +213,7 @@ async function fileUploader({ if (albumId) { uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') }); - await addAssetsToAlbum(albumId, [responseData.id], false); + await addAssetsToAlbums([albumId], [responseData.id], { notify: false }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') }); } 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 38817650c1..b9e5e166dd 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 @@ -14,7 +14,6 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -45,6 +44,7 @@ handleDownloadAlbum, } from '$lib/services/album.service'; import { getGlobalActions } from '$lib/services/app.service'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -438,9 +438,11 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + {#if assetInteraction.isAllUserOwned} assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 74993cb64b..b13146aab6 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -2,7 +2,6 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -17,8 +16,10 @@ import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; + import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui'; import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -68,10 +69,12 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + timelineManager.removeAssets(assetIds)} /> - + diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index c9ac99d10f..3cafdcbc5b 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -8,7 +8,6 @@ import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import Sidebar from '$lib/components/sidebar/sidebar.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -27,10 +26,9 @@ import { foldersStore } from '$lib/stores/folders.svelte'; import { preferences } from '$lib/stores/user.store'; import { cancelMultiselect } from '$lib/utils/asset-utils'; - import { getAssetControlContext } from '$lib/utils/context'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { joinPaths } from '$lib/utils/tree-utils'; - import { IconButton, Text } from '@immich/ui'; + import { ActionButton, CommandPaletteDefaultProvider, IconButton, Text } from '@immich/ui'; import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiSelectAll } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -119,8 +117,8 @@ assets={assetInteraction.selectedAssets} clearSelect={() => cancelMultiselect(assetInteraction)} > - {@const Actions = getAssetBulkActions($t, getAssetControlContext())} - + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - cancelMultiselect(assetInteraction)} /> + import { goto } from '$app/navigation'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte'; import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetVisibility } from '@immich/sdk'; + import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui'; import { mdiArrowLeft } from '@mdi/js'; - import type { PageData } from './$types'; import { t } from 'svelte-i18n'; + import type { PageData } from './$types'; interface Props { data: PageData; @@ -44,8 +45,10 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + {:else} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3c18b866c1..d28847068b 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -12,7 +12,6 @@ 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'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -31,6 +30,7 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { getPersonActions } from '$lib/services/person.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { locale } from '$lib/stores/preferences.store'; @@ -40,7 +40,15 @@ import { handleError } from '$lib/utils/handle-error'; import { isExternalUrl } from '$lib/utils/navigation'; import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; - import { ContextMenuButton, LoadingSpinner, modalManager, toastManager, type ActionItem } from '@immich/ui'; + import { + ActionButton, + CommandPaletteDefaultProvider, + ContextMenuButton, + LoadingSpinner, + modalManager, + toastManager, + type ActionItem, + } from '@immich/ui'; import { mdiAccountBoxOutline, mdiAccountMultipleCheckOutline, mdiArrowLeft, mdiDotsVertical } from '@mdi/js'; import { DateTime } from 'luxon'; import { onMount } from 'svelte'; @@ -455,9 +463,11 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index bef36d5602..dd2080a831 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -4,7 +4,6 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -36,12 +35,11 @@ type OnLink, type OnUnlink, } from '$lib/utils/actions'; - import { getAssetControlContext } from '$lib/utils/context'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility } from '@immich/sdk'; - import { ImageCarousel } from '@immich/ui'; + import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui'; import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -130,11 +128,12 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > - {@const Actions = getAssetBulkActions($t, getAssetControlContext())} + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + {#if isAllUserOwned} { + const onAlbumAddAssets = ({ assetIds }: { assetIds: string[] }) => { cancelMultiselect(assetInteraction); if (terms.isNotInAlbum.toString() == 'true') { @@ -248,6 +247,8 @@ + + {#if terms}
cancelMultiselect(assetInteraction)} > - {@const Actions = getAssetBulkActions($t, getAssetControlContext())} + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + {#if isAllUserOwned} - + diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 868f23bf55..fefd8dd032 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -9,7 +9,6 @@ import Sidebar from '$lib/components/sidebar/sidebar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -25,12 +24,13 @@ import SkipLink from '$lib/elements/SkipLink.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { getTagActions } from '$lib/services/tag.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences, user } from '$lib/stores/user.store'; import { joinPaths, TreeNode } from '$lib/utils/tree-utils'; import { getAllTags, type TagResponseDto } from '@immich/sdk'; - import { Text } from '@immich/ui'; + import { ActionButton, CommandPaletteDefaultProvider, Text } from '@immich/ui'; import { mdiDotsVertical, mdiTag, mdiTagMultiple } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -120,9 +120,11 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} From 3d4dec0cca011fb5e3aa50bd5ec73a664d08b73d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 19 Feb 2026 15:42:37 -0500 Subject: [PATCH 077/143] refactor: asset actions (#26367) --- web/src/lib/components/ActionButton.svelte | 15 ---- .../album-page/album-shared-link.svelte | 3 +- .../components/album-page/album-viewer.svelte | 3 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 90 +++++-------------- .../navigation-bar/navigation-bar.svelte | 3 +- .../sharedlinks-page/SharedLinkCard.svelte | 3 +- .../[[assetId=id]]/+page.svelte | 10 ++- 7 files changed, 34 insertions(+), 93 deletions(-) delete mode 100644 web/src/lib/components/ActionButton.svelte diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte deleted file mode 100644 index ae8d1199e0..0000000000 --- a/web/src/lib/components/ActionButton.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -{#if icon && isEnabled(action)} - onAction(action)} /> -{/if} diff --git a/web/src/lib/components/album-page/album-shared-link.svelte b/web/src/lib/components/album-page/album-shared-link.svelte index c99a5f6407..0969b60d29 100644 --- a/web/src/lib/components/album-page/album-shared-link.svelte +++ b/web/src/lib/components/album-page/album-shared-link.svelte @@ -1,9 +1,8 @@ - +
- - - - - - - - - - - + + + + + + + + + + + {#if isOwner} {/if} - + {#if isOwner} @@ -179,14 +133,14 @@ {/if} - - + + {#if !isLocked && asset.isTrashed} {/if} - + {#if isOwner} @@ -249,10 +203,10 @@ {/if} {#if isOwner}
- - - - + + + + {/if} {/if} diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index c4d94bdf56..7be4a58131 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -5,7 +5,6 @@ - - -
-
- Google Play - - Get it on Google Play - -
+ +
+ + Get it on Google Play + -
- App Store - - Download on the App Store - -
+ + Download on the App Store + -
- F-Droid - - Get it on F-Droid - -
-
- - + + Get it on F-Droid + +
+ From 5c7c07a09fb19ee070b4e28c45a4aae840770395 Mon Sep 17 00:00:00 2001 From: David Baxter Date: Thu, 19 Feb 2026 13:09:05 -0800 Subject: [PATCH 079/143] perf: add indexes to improve People API response times (#26337) Add SQL indexes for people search endpoints --- .../migrations/1771478781948-PeopleSearchIndex.ts | 15 +++++++++++++++ server/src/schema/tables/asset-face.table.ts | 5 +++++ server/src/schema/tables/asset.table.ts | 5 +++++ 3 files changed, 25 insertions(+) create mode 100644 server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts diff --git a/server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts b/server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts new file mode 100644 index 0000000000..f09257a3ce --- /dev/null +++ b/server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE INDEX "asset_id_timeline_notDeleted_idx" ON "asset" ("id") WHERE visibility = 'timeline' AND "deletedAt" IS NULL;`.execute(db); + await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE "deletedAt" IS NULL AND "isVisible" IS TRUE;`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_id_timeline_notDeleted_idx', '{"type":"index","name":"asset_id_timeline_notDeleted_idx","sql":"CREATE INDEX \\"asset_id_timeline_notDeleted_idx\\" ON \\"asset\\" (\\"id\\") WHERE visibility = ''timeline'' AND \\"deletedAt\\" IS NULL;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE \\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE;"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "asset_id_timeline_notDeleted_idx";`.execute(db); + await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_id_timeline_notDeleted_idx';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db); +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 8b156f2a17..8a3b3ac611 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -27,6 +27,11 @@ import { }) // schemaFromDatabase does not preserve column order @Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] }) +@Index({ + name: 'asset_face_personId_assetId_notDeleted_isVisible_idx', + columns: ['personId', 'assetId'], + where: '"deletedAt" IS NULL AND "isVisible" IS TRUE', +}) @Index({ columns: ['personId', 'assetId'] }) export class AssetFaceTable { @PrimaryGeneratedColumn() diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 0b3da710ac..765a2900e5 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -55,6 +55,11 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; using: 'gin', expression: 'f_unaccent("originalFileName") gin_trgm_ops', }) +@Index({ + name: 'asset_id_timeline_notDeleted_idx', + columns: ['id'], + where: `visibility = 'timeline' AND "deletedAt" IS NULL`, +}) // For all assets, each originalpath must be unique per user and library export class AssetTable { @PrimaryGeneratedColumn() From 7b4cabc2c68b79d9e836fcac7f0ceb72f2568e3a Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 22:10:55 +0100 Subject: [PATCH 080/143] chore: update task commands in web/mise.toml to use pnpm (#26345) * chore: update task commands in mise.toml to use pnpm * Replaced direct commands with pnpm run equivalents for consistency. * Added new tasks for type checking and Svelte checks. * Removed deprecated svelte-kit-sync task and adjusted dependencies accordingly. * mroe * chore: update mise.toml to add demo server task * Removed the direct IMMICH_SERVER_URL setting from the environment section. * Added a new task for starting the demo server with the IMMICH_SERVER_URL environment variable. * Ensured consistency in task definitions. --- mise.toml | 5 ++--- web/mise.toml | 50 ++++++++++++++++++++------------------------------ 2 files changed, 22 insertions(+), 33 deletions(-) diff --git a/mise.toml b/mise.toml index 7cb3a024e3..5e3088974c 100644 --- a/mise.toml +++ b/mise.toml @@ -37,13 +37,12 @@ run = "pnpm install --filter @immich/sdk --frozen-lockfile" [tasks."sdk:build"] dir = "open-api/typescript-sdk" -env._.path = "./node_modules/.bin" -run = "tsc" +run = "pnpm run build" # i18n tasks [tasks."i18n:format"] dir = "i18n" -run = { task = ":i18n:format-fix" } +run = "pnpm run format" [tasks."i18n:format-fix"] dir = "i18n" diff --git a/web/mise.toml b/web/mise.toml index 5aca2d737d..00b2b30c6b 100644 --- a/web/mise.toml +++ b/web/mise.toml @@ -1,56 +1,46 @@ [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" +run = "pnpm run build" [tasks."build-stats"] -env.BUILD_STATS = "true" -env._.path = "./node_modules/.bin" -run = "vite build" +run = "pnpm run build:stats" [tasks.preview] -env._.path = "./node_modules/.bin" -run = "vite preview" +run = "pnpm run preview" [tasks.start] -env._.path = "./node_modules/.bin" -run = "vite dev --host 0.0.0.0 --port 3000" +depends = [":install", "//:sdk:install", "//:sdk:build"] +run = "pnpm run dev" + +[tasks."start-demo"] +env.IMMICH_SERVER_URL = "https://demo.immich.app" +run = { task = ":start" } [tasks.test] -depends = ["svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "vitest" +run = "pnpm run test" [tasks.format] -env._.path = "./node_modules/.bin" -run = "prettier --check ." +run = "pnpm run format" [tasks."format-fix"] -env._.path = "./node_modules/.bin" -run = "prettier --write ." +run = "pnpm run format:fix" [tasks.lint] -env._.path = "./node_modules/.bin" -run = "eslint . --max-warnings 0 --concurrency 4" +run = "pnpm run lint" [tasks."lint-fix"] -run = { task = "lint --fix" } +run = "pnpm run lint:fix" -[tasks.check] -depends = ["svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "tsc --noEmit" +[tasks.check-typescript] +run = "pnpm run check:typescript" [tasks."check-svelte"] -depends = ["svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "svelte-check --no-tsconfig --fail-on-warnings" +run = "pnpm run check:svelte" + +[tasks.check] +run = { tasks = [":check-typescript", ":check-svelte"] } [tasks.checklist] run = [ From e8bedfdb7a981f6ca3b8be6d00a3c46a0863ce91 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:19:19 -0500 Subject: [PATCH 081/143] chore(deps): update dependency @sveltejs/kit to v2.52.2 [security] (#26371) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 107 ++++++++++++++++++++++++------------------------- 1 file changed, 53 insertions(+), 54 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33c0815f60..6e25b7c820 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -742,7 +742,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.64.0 - version: 0.64.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) + version: 0.64.0(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) '@mapbox/mapbox-gl-rtl-text': specifier: 0.3.0 version: 0.3.0 @@ -863,13 +863,13 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.10.0 version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.4 version: 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -4317,8 +4317,8 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.50.2': - resolution: {integrity: sha512-875hTUkEbz+MyJIxWbQjfMaekqdmEKUUfR7JyKcpfMRZqcGyrO9Gd+iS1D/Dx8LpE5FEtutWGOtlAh4ReSAiOA==} + '@sveltejs/kit@2.52.2': + resolution: {integrity: sha512-1in76dftrofUt138rVLvYuwiQLkg9K3cG8agXEE6ksf7gCGs8oIr3+pFrVtbRmY9JvW+psW5fvLM/IwVybOLBA==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -5316,8 +5316,8 @@ packages: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true @@ -14819,12 +14819,12 @@ snapshots: node-emoji: 2.2.0 svelte: 5.51.5 - '@immich/ui@0.64.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)': + '@immich/ui@0.64.0(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)': dependencies: '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.51.5) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) + bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) luxon: 3.7.2 simple-icons: 16.9.0 svelte: 5.51.5 @@ -15230,7 +15230,7 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdx': 2.0.13 - acorn: 8.15.0 + acorn: 8.16.0 collapse-white-space: 2.1.0 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 @@ -15239,7 +15239,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 - recma-jsx: 1.0.1(acorn@8.15.0) + recma-jsx: 1.0.1(acorn@8.16.0) recma-stringify: 1.0.0 rehype-recma: 1.0.0 remark-mdx: 3.1.1 @@ -16156,13 +16156,13 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@sveltejs/acorn-typescript@1.0.9(acorn@8.15.0)': + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -16178,20 +16178,19 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 - '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 - acorn: 8.15.0 + acorn: 8.16.0 cookie: 0.6.0 devalue: 5.6.3 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 mrmime: 2.0.1 - sade: 1.8.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 svelte: 5.51.5 @@ -17361,23 +17360,23 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-import-attributes@1.9.5(acorn@8.15.0): + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn-import-phases@1.0.4(acorn@8.15.0): + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk@8.3.4: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn@8.15.0: {} + acorn@8.16.0: {} address@1.2.2: {} @@ -17700,15 +17699,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): + bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) + runed: 0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) svelte: 5.51.5 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -19074,7 +19073,7 @@ snapshots: esast-util-from-js@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - acorn: 8.15.0 + acorn: 8.16.0 esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 @@ -19306,8 +19305,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -20343,8 +20342,8 @@ snapshots: import-in-the-middle@2.0.0: dependencies: - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) cjs-module-lexer: 1.4.3 module-details-from-path: 1.0.4 @@ -21505,8 +21504,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 @@ -21783,7 +21782,7 @@ snapshots: mlly@1.8.0: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.2 @@ -23233,10 +23232,10 @@ snapshots: estree-util-build-jsx: 3.0.1 vfile: 6.0.3 - recma-jsx@1.0.1(acorn@8.15.0): + recma-jsx@1.0.1(acorn@8.16.0): dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) estree-util-to-js: 2.0.0 recma-parse: 1.0.0 recma-stringify: 1.0.0 @@ -23541,14 +23540,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): + runed@0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.51.5 optionalDependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -24276,10 +24275,10 @@ snapshots: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) + runed: 0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) style-to-object: 1.0.14 svelte: 5.51.5 transitivePeerDependencies: @@ -24289,10 +24288,10 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) '@types/estree': 1.0.8 '@types/trusted-types': 2.0.7 - acorn: 8.15.0 + acorn: 8.16.0 aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 @@ -24469,7 +24468,7 @@ snapshots: terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -24840,7 +24839,7 @@ snapshots: unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 - acorn: 8.15.0 + acorn: 8.16.0 picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 @@ -25242,7 +25241,7 @@ snapshots: webpack-bundle-analyzer@4.10.2: dependencies: '@discoveryjs/json-ext': 0.5.7 - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk: 8.3.4 commander: 7.2.0 debounce: 1.2.1 @@ -25332,8 +25331,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.19.0 @@ -25364,8 +25363,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.19.0 From 01050a3d54084fb8eed585ec9188061c3be4006d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 19 Feb 2026 16:50:39 -0500 Subject: [PATCH 082/143] fix: pin code reset modal (#26370) --- web/src/lib/modals/PinCodeResetModal.svelte | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/src/lib/modals/PinCodeResetModal.svelte b/web/src/lib/modals/PinCodeResetModal.svelte index 024f0c8528..a51e0c3583 100644 --- a/web/src/lib/modals/PinCodeResetModal.svelte +++ b/web/src/lib/modals/PinCodeResetModal.svelte @@ -1,7 +1,7 @@ -{#if featureFlagsManager.value.passwordLogin === false} +{#if featureFlagsManager.value.passwordLogin}
{$t('reset_pin_code_description')}
@@ -37,9 +37,7 @@
{:else} - - -
{$t('reset_pin_code_description')}
-
-
+ +
{$t('reset_pin_code_description')}
+
{/if} From 7461479f6025c5dc709165b0aa07dcbd82c15458 Mon Sep 17 00:00:00 2001 From: dotlambda Date: Thu, 19 Feb 2026 14:58:25 -0800 Subject: [PATCH 083/143] chore(ml): remove unused dependency ftfy (#25529) --- machine-learning/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index e3d24ce172..c43d0df2cc 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -8,7 +8,6 @@ readme = "README.md" dependencies = [ "aiocache>=0.12.1,<1.0", "fastapi>=0.95.2,<1.0", - "ftfy>=6.1.1", "gunicorn>=21.1.0", "huggingface-hub>=0.20.1,<1.0", "insightface>=0.7.3,<1.0", From a1839b367648cc45344e9988a607d776e61ce721 Mon Sep 17 00:00:00 2001 From: Benjamin Nguyen Date: Fri, 20 Feb 2026 03:07:26 -0800 Subject: [PATCH 084/143] fix(mobile): Reset "People" search filter chip if no selections are made (#26267) * filter by tags * reset people search filter chip if no selections --- .../presentation/pages/search/drift_search.page.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 45a14643c9..0d9bba146a 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -150,10 +150,12 @@ class DriftSearchPage extends HookConsumerWidget { handleOnSelect(Set value) { filter.value = filter.value.copyWith(people: value); - peopleCurrentFilterWidget.value = Text( - value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '), - style: context.textTheme.labelLarge, - ); + final label = value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '); + if (label.isNotEmpty) { + peopleCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge); + } else { + peopleCurrentFilterWidget.value = null; + } } handleClear() { From 19da6553904fa7f04505830540b3ab045b7b2826 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 20 Feb 2026 09:16:42 -0500 Subject: [PATCH 085/143] fix: exiftool-vendored.exe (#26393) --- .pnpmfile.cjs | 16 +++++++++++----- pnpm-lock.yaml | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index 0e76dabe66..6dbed0bb6c 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -4,12 +4,18 @@ module.exports = { if (!pkg.name) { return pkg; } + // make exiftool-vendored.pl a regular dependency since Docker prod + // images build with --no-optional to reduce image size if (pkg.name === "exiftool-vendored") { - if (pkg.optionalDependencies["exiftool-vendored.pl"]) { - // make exiftool-vendored.pl a regular dependency - pkg.dependencies["exiftool-vendored.pl"] = - pkg.optionalDependencies["exiftool-vendored.pl"]; - delete pkg.optionalDependencies["exiftool-vendored.pl"]; + const binaryPackage = + process.platform === "win32" + ? "exiftool-vendored.exe" + : "exiftool-vendored.pl"; + + if (pkg.optionalDependencies[binaryPackage]) { + pkg.dependencies[binaryPackage] = + pkg.optionalDependencies[binaryPackage]; + delete pkg.optionalDependencies[binaryPackage]; } } return pkg; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e25b7c820..0e8f0c84b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ overrides: packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54= -pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0= +pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA= importers: From b4e16efdf4f15ce192514bc84d30c86175a14d5a Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 20 Feb 2026 09:23:40 -0500 Subject: [PATCH 086/143] test: face ordering issue/flakiness (#26382) --- e2e/src/specs/server/api/asset.e2e-spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index d4eee16232..11e825a7cd 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -253,7 +253,8 @@ describe('/asset', () => { expect(status).toBe(200); expect(body.id).toEqual(facesAsset.id); - expect(body.people).toMatchObject(expectedFaces); + const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name)); + expect(sortedPeople).toMatchObject(expectedFaces); }); }); From 6044b4164852e06869497b35001cf428d7fbb857 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 20 Feb 2026 09:37:07 -0500 Subject: [PATCH 087/143] fix: align devcontainers with standard development containers (#26321) --- .devcontainer/devcontainer.json | 27 ++-------- .../mobile/container-compose-overrides.yml | 20 +++----- .devcontainer/mobile/devcontainer.json | 3 +- .devcontainer/server/container-common.sh | 51 +------------------ .../server/container-compose-overrides.yml | 21 +++----- .devcontainer/server/container-start.sh | 17 ------- server/Dockerfile.dev | 10 ++-- 7 files changed, 25 insertions(+), 124 deletions(-) delete mode 100755 .devcontainer/server/container-start.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c6c2b3b51e..1d1a6eec16 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,6 +2,7 @@ "name": "Immich - Backend, Frontend and ML", "service": "immich-server", "runServices": [ + "immich-init", "immich-server", "redis", "database", @@ -31,29 +32,8 @@ "tasks": { "version": "2.0.0", "tasks": [ - { - "label": "Fix Permissions, Install Dependencies", - "type": "shell", - "command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0", - "isBackground": true, - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "dedicated", - "showReuseMessage": true, - "clear": false, - "group": "Devcontainer tasks", - "close": true - }, - "runOptions": { - "runOn": "default" - }, - "problemMatcher": [] - }, { "label": "Immich API Server (Nest)", - "dependsOn": ["Fix Permissions, Install Dependencies"], "type": "shell", "command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0", "isBackground": true, @@ -74,7 +54,6 @@ }, { "label": "Immich Web Server (Vite)", - "dependsOn": ["Fix Permissions, Install Dependencies"], "type": "shell", "command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0", "isBackground": true, @@ -130,8 +109,8 @@ } }, "overrideCommand": true, - "workspaceFolder": "/workspaces/immich", - "remoteUser": "node", + "workspaceFolder": "/usr/src/app", + "remoteUser": "root", "userEnvProbe": "loginInteractiveShell", "remoteEnv": { // The location where your uploaded files are stored diff --git a/.devcontainer/mobile/container-compose-overrides.yml b/.devcontainer/mobile/container-compose-overrides.yml index 99e41cbece..3d9e1b00b6 100644 --- a/.devcontainer/mobile/container-compose-overrides.yml +++ b/.devcontainer/mobile/container-compose-overrides.yml @@ -1,23 +1,17 @@ services: + immich-app-base: + image: busybox immich-server: + extends: + service: immich-app-base + profiles: !reset [] + image: immich-server-dev:latest build: target: dev-container-mobile environment: - IMMICH_SERVER_URL=http://127.0.0.1:2283/ - volumes: !override # bind mount host to /workspaces/immich - - ..:/workspaces/immich + volumes: - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage - /etc/localtime:/etc/localtime:ro immich-web: env_file: !reset [] diff --git a/.devcontainer/mobile/devcontainer.json b/.devcontainer/mobile/devcontainer.json index 140a2ecac3..0be9b72969 100644 --- a/.devcontainer/mobile/devcontainer.json +++ b/.devcontainer/mobile/devcontainer.json @@ -2,6 +2,7 @@ "name": "Immich - Mobile", "service": "immich-server", "runServices": [ + "immich-init", "immich-server", "redis", "database", @@ -35,7 +36,7 @@ }, "forwardPorts": [], "overrideCommand": true, - "workspaceFolder": "/workspaces/immich", + "workspaceFolder": "/usr/src/app", "remoteUser": "node", "userEnvProbe": "loginInteractiveShell", "remoteEnv": { diff --git a/.devcontainer/server/container-common.sh b/.devcontainer/server/container-common.sh index 3aa72379c3..fa3e60f211 100755 --- a/.devcontainer/server/container-common.sh +++ b/.devcontainer/server/container-common.sh @@ -2,11 +2,6 @@ export IMMICH_PORT="${DEV_SERVER_PORT:-2283}" export DEV_PORT="${DEV_PORT:-3000}" -# search for immich directory inside workspace. -# /workspaces/immich is the bind mount, but other directories can be mounted if runing -# Devcontainer: Clone [repository|pull request] in container volumne -WORKSPACES_DIR="/workspaces" -IMMICH_DIR="$WORKSPACES_DIR/immich" IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log" log() { @@ -30,52 +25,8 @@ run_cmd() { return "${PIPESTATUS[0]}" } -# Find directories excluding /workspaces/immich -mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*") - -if [ ${#other_dirs[@]} -gt 1 ]; then - log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR." - exit 1 -elif [ ${#other_dirs[@]} -eq 1 ]; then - export IMMICH_WORKSPACE="${other_dirs[0]}" -else - export IMMICH_WORKSPACE="$IMMICH_DIR" -fi +export IMMICH_WORKSPACE="/usr/src/app" log "Found immich workspace in $IMMICH_WORKSPACE" log "" -fix_permissions() { - - log "Fixing permissions for ${IMMICH_WORKSPACE}" - - # Change ownership for directories that exist - for dir in "${IMMICH_WORKSPACE}/.vscode" \ - "${IMMICH_WORKSPACE}/server/upload" \ - "${IMMICH_WORKSPACE}/.pnpm-store" \ - "${IMMICH_WORKSPACE}/.github/node_modules" \ - "${IMMICH_WORKSPACE}/cli/node_modules" \ - "${IMMICH_WORKSPACE}/e2e/node_modules" \ - "${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \ - "${IMMICH_WORKSPACE}/server/node_modules" \ - "${IMMICH_WORKSPACE}/server/dist" \ - "${IMMICH_WORKSPACE}/web/node_modules" \ - "${IMMICH_WORKSPACE}/web/dist"; do - if [ -d "$dir" ]; then - run_cmd sudo chown node -R "$dir" - fi - done - - log "" -} - -install_dependencies() { - - log "Installing dependencies" - ( - cd "${IMMICH_WORKSPACE}" || exit 1 - export CI=1 FROZEN=1 OFFLINE=1 - run_cmd make setup-web-dev setup-server-dev - ) - log "" -} diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index cc2b0c907b..5c312efd07 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -1,26 +1,21 @@ services: + immich-app-base: + image: busybox immich-server: + extends: + service: immich-app-base + profiles: !reset [] + image: immich-server-dev:latest build: target: dev-container-server env_file: !reset [] hostname: immich-dev environment: - IMMICH_SERVER_URL=http://127.0.0.1:2283/ - volumes: !override - - ..:/workspaces/immich + volumes: - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - /etc/localtime:/etc/localtime:ro - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + - pnpm_store_server:/buildcache/pnpm-store - ../plugins:/build/corePlugin immich-web: env_file: !reset [] diff --git a/.devcontainer/server/container-start.sh b/.devcontainer/server/container-start.sh deleted file mode 100755 index 0edd38172e..0000000000 --- a/.devcontainer/server/container-start.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# shellcheck source=common.sh -# shellcheck disable=SC1091 -source /immich-devcontainer/container-common.sh - -log "Setting up Immich dev container..." -fix_permissions - -log "Setup complete, please wait while backend and frontend services automatically start" -log -log "If necessary, the services may be manually started using" -log -log "$ /immich-devcontainer/container-start-backend.sh" -log "$ /immich-devcontainer/container-start-frontend.sh" -log -log "From different terminal windows, as these scripts automatically restart the server" -log "on error, and will continuously run in a loop" diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 74757956fc..f778c20afb 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -27,16 +27,14 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"] FROM dev AS dev-container-server RUN apt-get update --allow-releaseinfo-change && \ - apt-get install sudo inetutils-ping openjdk-21-jre-headless \ + apt-get install inetutils-ping openjdk-21-jre-headless \ vim nano curl \ -y --no-install-recommends --fix-missing -RUN usermod -aG sudo node && \ - echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \ - mkdir -p /workspaces/immich +RUN mkdir -p /workspaces && \ + ln -s /usr/src/app /workspaces/immich -RUN chown node:node -R /workspaces -COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/ +COPY --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/ WORKDIR /workspaces/immich From 84f29569410ec95a80e15d61060991b45963fe36 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 20 Feb 2026 15:54:08 +0100 Subject: [PATCH 088/143] fix(cli): delete sidecar files after upload if requested (#26353) * fix(cli): delete sidecar files after upload if requested Introduced a new function, findSidecar, to locate XMP sidecar files based on specified naming conventions. Updated the deleteFiles function to delete associated sidecar files when the main asset file is deleted. Added unit tests for findSidecar to ensure correct functionality. * lint and format * fix test * chore: clean up --------- Co-authored-by: Jason Rasmussen --- cli/src/commands/asset.spec.ts | 92 +++++++++++++++++++++++++++++++++- cli/src/commands/asset.ts | 54 ++++++++++++-------- 2 files changed, 123 insertions(+), 23 deletions(-) diff --git a/cli/src/commands/asset.spec.ts b/cli/src/commands/asset.spec.ts index 7dce135985..ea57eeb74b 100644 --- a/cli/src/commands/asset.spec.ts +++ b/cli/src/commands/asset.spec.ts @@ -7,7 +7,15 @@ import { describe, expect, it, MockedFunction, vi } from 'vitest'; import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk'; import createFetchMock from 'vitest-fetch-mock'; -import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset'; +import { + checkForDuplicates, + deleteFiles, + findSidecar, + getAlbumName, + startWatch, + uploadFiles, + UploadOptionsDto, +} from 'src/commands/asset'; vi.mock('@immich/sdk'); @@ -309,3 +317,85 @@ describe('startWatch', () => { await fs.promises.rm(testFolder, { recursive: true, force: true }); }); }); + +describe('findSidecar', () => { + let testDir: string; + let testFilePath: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-sidecar-')); + testFilePath = path.join(testDir, 'test.jpg'); + fs.writeFileSync(testFilePath, 'test'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should find sidecar file with photo.xmp naming convention', () => { + const sidecarPath = path.join(testDir, 'test.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + const result = findSidecar(testFilePath); + expect(result).toBe(sidecarPath); + }); + + it('should find sidecar file with photo.ext.xmp naming convention', () => { + const sidecarPath = path.join(testDir, 'test.jpg.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + const result = findSidecar(testFilePath); + expect(result).toBe(sidecarPath); + }); + + it('should prefer photo.ext.xmp over photo.xmp when both exist', () => { + const sidecarPath1 = path.join(testDir, 'test.xmp'); + const sidecarPath2 = path.join(testDir, 'test.jpg.xmp'); + fs.writeFileSync(sidecarPath1, 'xmp data 1'); + fs.writeFileSync(sidecarPath2, 'xmp data 2'); + + const result = findSidecar(testFilePath); + // Should return the first one found (photo.xmp) based on the order in the code + expect(result).toBe(sidecarPath1); + }); + + it('should return undefined when no sidecar file exists', () => { + const result = findSidecar(testFilePath); + expect(result).toBeUndefined(); + }); +}); + +describe('deleteFiles', () => { + let testDir: string; + let testFilePath: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-delete-')); + testFilePath = path.join(testDir, 'test.jpg'); + fs.writeFileSync(testFilePath, 'test'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should delete asset and sidecar file when main file is deleted', async () => { + const sidecarPath = path.join(testDir, 'test.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: true, concurrency: 1 }); + + expect(fs.existsSync(testFilePath)).toBe(false); + expect(fs.existsSync(sidecarPath)).toBe(false); + }); + + it('should not delete sidecar file when delete option is false', async () => { + const sidecarPath = path.join(testDir, 'test.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: false, concurrency: 1 }); + + expect(fs.existsSync(testFilePath)).toBe(true); + expect(fs.existsSync(sidecarPath)).toBe(true); + }); +}); diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 42c33491f2..7d4b09b69d 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -17,7 +17,7 @@ import { Matcher, watch as watchFs } from 'chokidar'; import { MultiBar, Presets, SingleBar } from 'cli-progress'; import { chunk } from 'lodash-es'; import micromatch from 'micromatch'; -import { Stats, createReadStream } from 'node:fs'; +import { Stats, createReadStream, existsSync } from 'node:fs'; import { stat, unlink } from 'node:fs/promises'; import path, { basename } from 'node:path'; import { Queue } from 'src/queue'; @@ -403,23 +403,6 @@ export const uploadFiles = async ( const uploadFile = async (input: string, stats: Stats): Promise => { const { baseUrl, headers } = defaults; - const assetPath = path.parse(input); - const noExtension = path.join(assetPath.dir, assetPath.name); - - const sidecarsFiles = await Promise.all( - // XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp - [`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => { - try { - const stats = await stat(sidecarPath); - return new UploadFile(sidecarPath, stats.size); - } catch { - return false; - } - }), - ); - - const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false); - const formData = new FormData(); formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, '')); formData.append('deviceId', 'CLI'); @@ -429,8 +412,15 @@ const uploadFile = async (input: string, stats: Stats): Promise => { +export const findSidecar = (filepath: string): string | undefined => { + const assetPath = path.parse(filepath); + const noExtension = path.join(assetPath.dir, assetPath.name); + + // XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp + for (const sidecarPath of [`${noExtension}.xmp`, `${filepath}.xmp`]) { + if (existsSync(sidecarPath)) { + return sidecarPath; + } + } +}; + +export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise => { let fileCount = 0; if (options.delete) { fileCount += uploaded.length; @@ -474,7 +476,15 @@ const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: Uplo const chunkDelete = async (files: Asset[]) => { for (const assetBatch of chunk(files, options.concurrency)) { - await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath))); + await Promise.all( + assetBatch.map(async (input: Asset) => { + await unlink(input.filepath); + const sidecarPath = findSidecar(input.filepath); + if (sidecarPath) { + await unlink(sidecarPath); + } + }), + ); deletionProgress.update(assetBatch.length); } }; From 18bf96b4b2351ee6076378d736f0d8c9d9b88d92 Mon Sep 17 00:00:00 2001 From: Benjamin Nguyen Date: Fri, 20 Feb 2026 06:57:28 -0800 Subject: [PATCH 089/143] fix(mobile): handle userPreferencesProvider error state during sync (#26332) fix drift_search_page render bug --- mobile/lib/presentation/pages/search/drift_search.page.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0d9bba146a..0ce3f20641 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -698,7 +698,7 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_location'.t(context: context), currentFilter: locationCurrentFilterWidget.value, ), - if (userPreferences.value?.tagsEnabled ?? false) + if (userPreferences.valueOrNull?.tagsEnabled ?? false) SearchFilterChip( icon: Icons.sell_outlined, onTap: showTagPicker, @@ -724,14 +724,13 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_media_type'.t(context: context), currentFilter: mediaTypeCurrentFilterWidget.value, ), - if (userPreferences.value?.ratingsEnabled ?? false) ...[ + if (userPreferences.valueOrNull?.ratingsEnabled ?? false) SearchFilterChip( icon: Icons.star_outline_rounded, onTap: showStarRatingPicker, label: 'search_filter_star_rating'.t(context: context), currentFilter: ratingCurrentFilterWidget.value, ), - ], SearchFilterChip( icon: Icons.display_settings_outlined, onTap: showDisplayOptionPicker, From aae64b5e2f3a8fce08e6ddf9f41240ac1fe3eca9 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 20 Feb 2026 10:04:17 -0500 Subject: [PATCH 090/143] test: thumbnail selector (#26383) * test: face ordering issue/flakiness * test: thumbnail selector --- e2e/src/ui/specs/timeline/utils.ts | 7 ++----- web/src/lib/components/assets/thumbnail/thumbnail.svelte | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index 774839b174..e3799a7c3b 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -65,7 +65,7 @@ export const thumbnailUtils = { return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`); }, selectedAsset(page: Page) { - return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])'); + return page.locator('[data-thumbnail-focus-container][data-selected]'); }, async clickAssetId(page: Page, assetId: string) { await thumbnailUtils.withAssetId(page, assetId).click(); @@ -103,11 +103,8 @@ export const thumbnailUtils = { await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0); }, async expectSelectedReadonly(page: Page, assetId: string) { - // todo - need a data attribute for selected await expect( - page.locator( - `[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`, - ), + page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`), ).toBeVisible(); }, async expectTimelineHasOnScreenAssets(page: Page) { diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 8270646470..5604e6f59d 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -223,6 +223,7 @@ bind:this={element} data-asset={asset.id} data-thumbnail-focus-container + data-selected={selected ? true : undefined} tabindex={0} role="link" > From 82c6302549e4b041c3939723519283f5c9a7879e Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 20 Feb 2026 20:21:26 +0200 Subject: [PATCH 091/143] feat(mobile): timeline - add persistentBottomBar flag (#25634) * feat(mobile): timeline - add selectable all-assets control * feature(mobile): introduce bottomWidgetBuilder in Timeline remove redundant code * fix(mobile): remove redundant code * refactor(mobile): refactor new code in Timeline * fix(mobile): fix format * refactor(mobile): replace unsupported Dart syntax for analyzer compatibility * refactor(mobile): remove Timeline.bottomSheet and migrate to bottomWidgetBuilder * refactor(mobile): restore Timeline.bottomSheet and remove bottomWidgetBuilder add withPersistentBottomBar param to Timeline class * refactor(mobile): refactor var name --------- Co-authored-by: Peter Ombodi --- .../widgets/timeline/timeline.widget.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 9f7c695c8b..5190e2007f 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -74,6 +74,7 @@ class Timeline extends StatefulWidget { this.snapToMonth = true, this.initialScrollOffset, this.readOnly = false, + this.persistentBottomBar = false, }); final Widget? topSliverWidget; @@ -87,6 +88,7 @@ class Timeline extends StatefulWidget { final bool snapToMonth; final double? initialScrollOffset; final bool readOnly; + final bool persistentBottomBar; @override State createState() => _TimelineState(); @@ -143,6 +145,7 @@ class _TimelineState extends State { appBar: widget.appBar, bottomSheet: widget.bottomSheet, withScrubber: widget.withScrubber, + persistentBottomBar: widget.persistentBottomBar, snapToMonth: widget.snapToMonth, initialScrollOffset: widget.initialScrollOffset, ), @@ -173,6 +176,7 @@ class _SliverTimeline extends ConsumerStatefulWidget { this.appBar, this.bottomSheet, this.withScrubber = true, + this.persistentBottomBar = false, this.snapToMonth = true, this.initialScrollOffset, }); @@ -182,6 +186,7 @@ class _SliverTimeline extends ConsumerStatefulWidget { final Widget? appBar; final Widget? bottomSheet; final bool withScrubber; + final bool persistentBottomBar; final bool snapToMonth; final double? initialScrollOffset; @@ -404,6 +409,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled; + final isBottomWidgetVisible = + widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar); return PopScope( canPop: !isMultiSelectEnabled, @@ -519,7 +527,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { child: Stack( children: [ timeline, - if (!isSelectionMode && isMultiSelectEnabled) ...[ + if (isMultiSelectStatusVisible) Positioned( top: MediaQuery.paddingOf(context).top, left: 25, @@ -528,8 +536,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { child: Center(child: _MultiSelectStatusButton()), ), ), - if (widget.bottomSheet != null) widget.bottomSheet!, - ], + if (isBottomWidgetVisible) widget.bottomSheet!, ], ), ), From 27c45b5ddb594ae29c9850a4419a8e179137c8b2 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:31:30 +0100 Subject: [PATCH 092/143] fix(web): restore close action for asset viewer (#26418) --- web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index dc81614c64..884929845b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -94,7 +94,7 @@ const sharedLink = getSharedLink(); - +
Date: Sat, 21 Feb 2026 14:42:31 +0100 Subject: [PATCH 093/143] fix(web): escape handling on album page (#26419) --- web/src/lib/modals/AlbumOptionsModal.svelte | 3 +-- web/src/lib/services/album.service.ts | 16 +++------------- .../[[assetId=id]]/+page.svelte | 17 +++++++++++------ 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/web/src/lib/modals/AlbumOptionsModal.svelte b/web/src/lib/modals/AlbumOptionsModal.svelte index 392389fe92..4553f022df 100644 --- a/web/src/lib/modals/AlbumOptionsModal.svelte +++ b/web/src/lib/modals/AlbumOptionsModal.svelte @@ -3,7 +3,6 @@ import HeaderActionButton from '$lib/components/HeaderActionButton.svelte'; import OnEvents from '$lib/components/OnEvents.svelte'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; - import { AlbumPageViewMode } from '$lib/constants'; import { getAlbumActions, handleRemoveUserFromAlbum, @@ -56,7 +55,7 @@ sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id); }; - const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album, AlbumPageViewMode.OPTIONS)); + const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album)); let sharedLinks: SharedLinkResponseDto[] = $state([]); diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index 05e0fdb78d..0f155df0e9 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -1,6 +1,5 @@ import { goto } from '$app/navigation'; import ToastAction from '$lib/components/ToastAction.svelte'; -import { AlbumPageViewMode } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; @@ -32,7 +31,7 @@ import { type UserResponseDto, } from '@immich/sdk'; import { modalManager, toastManager, type ActionItem } from '@immich/ui'; -import { mdiArrowLeft, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js'; +import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js'; import { type MessageFormatter } from 'svelte-i18n'; import { get } from 'svelte/store'; @@ -46,7 +45,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => { return { Create }; }; -export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, viewMode: AlbumPageViewMode) => { +export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => { const isOwned = get(user).id === album.ownerId; const Share: ActionItem = { @@ -73,16 +72,7 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, v onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }), }; - const Close: ActionItem = { - title: $t('go_back'), - type: $t('command'), - icon: mdiArrowLeft, - onAction: () => goto(Route.albums()), - $if: () => viewMode === AlbumPageViewMode.VIEW, - shortcuts: { key: 'Escape' }, - }; - - return { Share, AddUsers, CreateSharedLink, Close }; + return { Share, AddUsers, CreateSharedLink }; }; export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => { 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 97e101f728..f05380257a 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 @@ -127,10 +127,6 @@ await handleCloseSelectAssets(); return; } - if (viewMode === AlbumPageViewMode.OPTIONS) { - viewMode = AlbumPageViewMode.VIEW; - return; - } if ($showAssetViewer) { return; } @@ -138,7 +134,7 @@ cancelMultiselect(assetInteraction); return; } - return; + await goto(Route.albums()); }; const refreshAlbum = async () => { @@ -311,8 +307,17 @@ }; const { Cast } = $derived(getGlobalActions($t)); - const { Share, Close } = $derived(getAlbumActions($t, album, viewMode)); + const { Share } = $derived(getAlbumActions($t, album)); const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets)); + + const Close = $derived({ + title: $t('go_back'), + type: $t('command'), + icon: mdiArrowLeft, + onAction: handleEscape, + $if: () => !$showAssetViewer, + shortcuts: { key: 'Escape' }, + }); Date: Sat, 21 Feb 2026 14:43:23 +0100 Subject: [PATCH 094/143] fix(web): album description auto height (#26420) --- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 f05380257a..44a0c5e678 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 @@ -301,9 +301,10 @@ return; } - album.albumUsers = album.albumUsers.map((albumUser) => + const albumUsers = album.albumUsers.map((albumUser) => albumUser.user.id === userId ? { ...albumUser, role } : albumUser, ); + album = { ...album, albumUsers }; }; const { Cast } = $derived(getGlobalActions($t)); @@ -357,7 +358,7 @@ id={album.id} albumName={album.albumName} {isOwned} - onUpdate={(albumName) => (album.albumName = albumName)} + onUpdate={(albumName) => (album = { ...album, albumName })} /> {#if album.assetCount > 0} @@ -406,8 +407,11 @@
{/if} - - + album.description, (description) => (album = { ...album, description })} + />
{/if} From 25d0bdc9f5e7af371a49c256bd4380460ce7a260 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Sat, 21 Feb 2026 08:44:33 -0500 Subject: [PATCH 095/143] chore: replace remaining usages of npm with pnpm (#26411) --- .github/workflows/test.yml | 2 +- Makefile | 2 +- cli/package.json | 4 ++-- docs/package.json | 2 +- e2e/package.json | 14 +++++++------- server/package.json | 10 +++++----- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 681baea066..1cad2b0023 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -511,7 +511,7 @@ jobs: run: pnpm install --frozen-lockfile if: ${{ !cancelled() }} - name: Install Playwright Browsers - run: npx playwright install chromium --only-shell + run: pnpm exec playwright install chromium --only-shell if: ${{ !cancelled() }} - name: Docker build run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 diff --git a/Makefile b/Makefile index 2fc1c5d801..4d76913d8f 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ attach-server: docker exec -it docker_immich-server_1 sh renovate: - LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset + LOG_LEVEL=debug pnpm exec renovate --platform=local --repository-cache=reset # Directories that need to be created for volumes or build output VOLUME_DIRS = \ diff --git a/cli/package.json b/cli/package.json index 8e2aec0282..d553ffb299 100644 --- a/cli/package.json +++ b/cli/package.json @@ -45,8 +45,8 @@ "build": "vite build", "build:dev": "vite build --sourcemap true", "lint": "eslint \"src/**/*.ts\" --max-warnings 0", - "lint:fix": "npm run lint -- --fix", - "prepack": "npm run build", + "lint:fix": "pnpm run lint --fix", + "prepack": "pnpm run build", "test": "vitest", "test:cov": "vitest --coverage", "format": "prettier --check .", diff --git a/docs/package.json b/docs/package.json index c22826b3cb..8c270f013b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,7 +8,7 @@ "format:fix": "prettier --write .", "start": "docusaurus start --port 3005", "copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0", - "build": "npm run copy:openapi && docusaurus build", + "build": "pnpm run copy:openapi && docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", diff --git a/e2e/package.json b/e2e/package.json index cebd9fafc2..02facc450d 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -8,16 +8,16 @@ "test": "vitest --run", "test:watch": "vitest", "test:maintenance": "vitest --run --config vitest.maintenance.config.ts", - "test:web": "npx playwright test --project=web", - "test:web:maintenance": "npx playwright test --project=maintenance", - "test:web:ui": "npx playwright test --project=ui", - "start:web": "npx playwright test --ui --project=web", - "start:web:maintenance": "npx playwright test --ui --project=maintenance", - "start:web:ui": "npx playwright test --ui --project=ui", + "test:web": "pnpm exec playwright test --project=web", + "test:web:maintenance": "pnpm exec playwright test --project=maintenance", + "test:web:ui": "pnpm exec playwright test --project=ui", + "start:web": "pnpm exec playwright test --ui --project=web", + "start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance", + "start:web:ui": "pnpm exec playwright test --ui --project=ui", "format": "prettier --check .", "format:fix": "prettier --write .", "lint": "eslint \"src/**/*.ts\" --max-warnings 0", - "lint:fix": "npm run lint -- --fix", + "lint:fix": "pnpm run lint --fix", "check": "tsc --noEmit" }, "keywords": [], diff --git a/server/package.json b/server/package.json index 814934b1be..fa10f8bd1a 100644 --- a/server/package.json +++ b/server/package.json @@ -9,15 +9,15 @@ "build": "nest build", "format": "prettier --check .", "format:fix": "prettier --write .", - "start": "npm run start:dev", + "start": "pnpm run start:dev", "nest": "nest", "start:dev": "nest start --watch --", "start:debug": "nest start --debug 0.0.0.0:9230 --watch --", "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0", - "lint:fix": "npm run lint -- --fix", + "lint:fix": "pnpm run lint --fix", "check": "tsc --noEmit", - "check:code": "npm run format && npm run lint && npm run check", - "check:all": "npm run check:code && npm run test:cov", + "check:code": "pnpm run format && pnpm run lint && pnpm run check", + "check:all": "pnpm run check:code && pnpm run test:cov", "test": "vitest --config test/vitest.config.mjs", "test:cov": "vitest --config test/vitest.config.mjs --coverage", "test:medium": "vitest --config test/vitest.config.medium.mjs", @@ -28,7 +28,7 @@ "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", + "schema:reset": "pnpm run schema:drop && pnpm run migrations:run", "sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:sql": "node ./dist/bin/sync-sql.js", "email:dev": "email dev -p 3050 --dir src/emails" From a4d95b7aba5c5fb4d573add80e2302b83e1ef1ce Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:14:53 +0100 Subject: [PATCH 096/143] fix(web): prevent side panel overlap during transition (#26398) --- .../asset-viewer/asset-viewer.svelte | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index c011a5e466..b09c663aaf 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -432,6 +432,12 @@ ); const { Tag } = $derived(getAssetActions($t, asset)); + const showDetailPanel = $derived( + asset.hasMetadata && + $slideshowState === SlideshowState.None && + assetViewerManager.isShowDetailPanel && + !assetViewerManager.isShowEditor, + ); @@ -571,25 +577,22 @@
{/if} - {#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor} + {#if showDetailPanel || assetViewerManager.isShowEditor}
- -
- {/if} - - {#if assetViewerManager.isShowEditor} -
- + {#if showDetailPanel} +
+ +
+ {:else if assetViewerManager.isShowEditor} +
+ +
+ {/if}
{/if} From 1d25267f224064ce6de67ee239acf9fcc8d2e44f Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 21 Feb 2026 10:41:44 -0500 Subject: [PATCH 097/143] fix(mobile): buffer width/height referenced after recycling (#26415) recycle after getters --- .../main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt index 50ff11b0c2..64e67cbfee 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt @@ -48,7 +48,6 @@ fun Bitmap.toNativeBuffer(): Map { try { val buffer = NativeBuffer.wrap(pointer, size) copyPixelsToBuffer(buffer) - recycle() return mapOf( "pointer" to pointer, "width" to width.toLong(), @@ -57,8 +56,9 @@ fun Bitmap.toNativeBuffer(): Map { ) } catch (e: Exception) { NativeBuffer.free(pointer) - recycle() throw e + } finally { + recycle() } } From 8ba20cbd44e3c1d42f3bda86ac668d884cbec776 Mon Sep 17 00:00:00 2001 From: Alex Balgavy <8124851+thezeroalpha@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:28:17 +0100 Subject: [PATCH 098/143] feat: tap to see next/previous image (#20286) * feat(mobile): tap behavior for next/previous image This change enables switching to the next/previous photo in the photo viewer by tapping the left/right quarter of the screen. * Avoid animation on first/last image * Add changes to asset_viewer.page * Add setting for tap navigation, disable by default Not everyone wants to have tapping for next/previous image enabled, so this commit adds a settings toggle. Since it might be confusing behavior for new users, it is disabled by default. * chore: refactor * fix: lint --------- Co-authored-by: Alex Tran --- i18n/en.json | 3 ++ mobile/lib/domain/models/store.model.dart | 3 ++ .../lib/pages/common/gallery_viewer.page.dart | 33 +++++++++++++++++-- .../asset_viewer/asset_page.widget.dart | 28 ++++++++++++++-- .../asset_viewer/asset_viewer.page.dart | 13 +++++++- mobile/lib/services/app_settings.service.dart | 1 + .../asset_viewer_settings.dart | 7 +++- .../image_viewer_tap_to_navigate_setting.dart | 30 +++++++++++++++++ mobile/packages/ui/showcase/pubspec.lock | 8 ++--- mobile/pubspec.lock | 8 ++--- 10 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart diff --git a/i18n/en.json b/i18n/en.json index 95e9584032..440f9beb64 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2026,6 +2026,9 @@ "set_profile_picture": "Set profile picture", "set_slideshow_to_fullscreen": "Set Slideshow to fullscreen", "set_stack_primary_asset": "Set as primary asset", + "setting_image_navigation_enable_subtitle": "If enabled, you can navigate to the previous/next image by tapping the leftmost/rightmost quarter of the screen.", + "setting_image_navigation_enable_title": "Tap to Navigate", + "setting_image_navigation_title": "Image Navigation", "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", "setting_image_viewer_original_title": "Load original image", diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index f6bed7cf61..00545aa01a 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -73,6 +73,9 @@ enum StoreKey { autoPlayVideo._(139), albumGridView._(140), + // Image viewer navigation settings + tapToNavigate._(141), + // Experimental stuff photoManagerCustomFilter._(1000), betaPromptShown._(1001), diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 9a7e78ddb8..0ef27f854b 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -221,8 +221,37 @@ class GalleryViewerPage extends HookConsumerWidget { onDragUpdate: (_, details, __) { handleSwipeUpDown(details); }, - onTapDown: (_, __, ___) { - ref.read(showControlsProvider.notifier).toggle(); + onTapDown: (ctx, tapDownDetails, _) { + final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.tapToNavigate); + if (!tapToNavigate) { + ref.read(showControlsProvider.notifier).toggle(); + return; + } + + double tapX = tapDownDetails.globalPosition.dx; + double screenWidth = ctx.width; + + // We want to change images if the user taps in the leftmost or + // rightmost quarter of the screen + bool tappedLeftSide = tapX < screenWidth / 4; + bool tappedRightSide = tapX > screenWidth * (3 / 4); + + int? currentPage = controller.page?.toInt(); + int maxPage = renderList.totalAssets - 1; + + if (tappedLeftSide && currentPage != null) { + // Nested if because we don't want to fallback to show/hide controls + if (currentPage != 0) { + controller.jumpToPage(currentPage - 1); + } + } else if (tappedRightSide && currentPage != null) { + // Nested if because we don't want to fallback to show/hide controls + if (currentPage != maxPage) { + controller.jumpToPage(currentPage + 1); + } + } else { + ref.read(showControlsProvider.notifier).toggle(); + } }, onLongPressStart: asset.isMotionPhoto ? (_, __, ___) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index a294adb669..ba52b67dfd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -16,8 +16,10 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -29,8 +31,9 @@ enum _DragIntent { none, scroll, dismiss } class AssetPage extends ConsumerStatefulWidget { final int index; final int heroOffset; + final void Function(int direction)? onTapNavigate; - const AssetPage({super.key, required this.index, required this.heroOffset}); + const AssetPage({super.key, required this.index, required this.heroOffset, this.onTapNavigate}); @override ConsumerState createState() => _AssetPageState(); @@ -224,7 +227,28 @@ class _AssetPageState extends ConsumerState { } void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) { - if (!_showingDetails && _dragStart == null) _viewer.toggleControls(); + if (_showingDetails || _dragStart != null) return; + + final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.tapToNavigate); + if (!tapToNavigate) { + _viewer.toggleControls(); + return; + } + + final tapX = details.globalPosition.dx; + final screenWidth = context.width; + + // Navigate if the user taps in the leftmost or rightmost quarter of the screen + final tappedLeftSide = tapX < screenWidth / 4; + final tappedRightSide = tapX > screenWidth * (3 / 4); + + if (tappedLeftSide) { + widget.onTapNavigate?.call(-1); + } else if (tappedRightSide) { + widget.onTapNavigate?.call(1); + } else { + _viewer.toggleControls(); + } } void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) => 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 515f635493..3ed5fb2034 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -96,6 +96,16 @@ class _AssetViewerState extends ConsumerState { bool _assetReloadRequested = false; + void _onTapNavigate(int direction) { + final page = _pageController.page?.toInt(); + if (page == null) return; + final target = page + direction; + final maxPage = ref.read(timelineServiceProvider).totalAssets - 1; + if (target >= 0 && target <= maxPage) { + _pageController.jumpToPage(target); + } + } + @override void initState() { super.initState(); @@ -270,7 +280,8 @@ class _AssetViewerState extends ConsumerState { : const FastClampingScrollPhysics(), itemCount: ref.read(timelineServiceProvider).totalAssets, onPageChanged: (index) => _onAssetChanged(index), - itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset), + itemBuilder: (context, index) => + AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate), ), ), if (!CurrentPlatform.isIOS) diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 4e740ebfe5..db4fc9965a 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -35,6 +35,7 @@ enum AppSettingsEnum { loopVideo(StoreKey.loopVideo, "loopVideo", true), loadOriginalVideo(StoreKey.loadOriginalVideo, "loadOriginalVideo", false), autoPlayVideo(StoreKey.autoPlayVideo, "autoPlayVideo", true), + tapToNavigate(StoreKey.tapToNavigate, "tapToNavigate", false), mapThemeMode(StoreKey.mapThemeMode, null, 0), mapShowFavoriteOnly(StoreKey.mapShowFavoriteOnly, null, false), mapIncludeArchived(StoreKey.mapIncludeArchived, null, false), diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart index 5dea38d85e..1555790ff9 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart'; +import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'video_viewer_settings.dart'; @@ -8,7 +9,11 @@ class AssetViewerSettings extends StatelessWidget { @override Widget build(BuildContext context) { - final assetViewerSetting = [const ImageViewerQualitySetting(), const VideoViewerSettings()]; + final assetViewerSetting = [ + const ImageViewerQualitySetting(), + const ImageViewerTapToNavigateSetting(), + const VideoViewerSettings(), + ]; return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true); } diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart new file mode 100644 index 0000000000..759162cab8 --- /dev/null +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart @@ -0,0 +1,30 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; + +class ImageViewerTapToNavigateSetting extends HookConsumerWidget { + const ImageViewerTapToNavigateSetting({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSubTitle(title: "setting_image_navigation_title".tr()), + SettingsSwitchListTile( + valueNotifier: tapToNavigate, + title: "setting_image_navigation_enable_title".tr(), + subtitle: "setting_image_navigation_enable_subtitle".tr(), + onChanged: (_) => ref.invalidate(appSettingsServiceProvider), + ), + ], + ); + } +} diff --git a/mobile/packages/ui/showcase/pubspec.lock b/mobile/packages/ui/showcase/pubspec.lock index 4d8ec62b90..b0725051d3 100644 --- a/mobile/packages/ui/showcase/pubspec.lock +++ b/mobile/packages/ui/showcase/pubspec.lock @@ -227,10 +227,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" path: dependency: transitive description: @@ -328,10 +328,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" typed_data: dependency: transitive description: diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 28adfc2ab7..077544b4f7 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1217,10 +1217,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1910,10 +1910,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" thumbhash: dependency: "direct main" description: From f0e2fced57a72ddf1f6be2def2d902868aa4bada Mon Sep 17 00:00:00 2001 From: Noel S Date: Sat, 21 Feb 2026 22:37:36 -0700 Subject: [PATCH 099/143] feat(mobile): video zooming in asset viewer (#22036) * wip * Functional implementation, still need to bug test. * Fixed flickering bugs * Fixed bug with drag actions interfering with zoom panning. Fixed video being zoomable when bottom sheet is shown. Code cleanup. * Add comments and simplify video controls * Clearer variable name * Fix bug where the redundant onTapDown would interfere with zooming gestures * Fix zoom not working the second time when viewing a video. * fix video of live photo retaining pan from photo portion * code cleanup and simplified widget stack --------- Co-authored-by: Alex --- .../asset_viewer/asset_page.widget.dart | 42 +++++++------- .../asset_viewer/video_viewer.widget.dart | 58 ++++++++++++++----- .../video_viewer_controls.widget.dart | 38 +++++++----- .../asset_viewer/center_play_button.dart | 31 +++++----- 4 files changed, 101 insertions(+), 68 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index ba52b67dfd..43b31b829c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -53,6 +53,7 @@ class _AssetPageState extends ConsumerState { final _scrollController = ScrollController(); late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); + final ValueNotifier _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial); double _snapOffset = 0.0; double _lastScrollOffset = 0.0; @@ -81,6 +82,7 @@ class _AssetPageState extends ConsumerState { _proxyScrollController.dispose(); _scaleBoundarySub?.cancel(); _eventSubscription?.cancel(); + _videoScaleStateNotifier.dispose(); super.dispose(); } @@ -255,10 +257,11 @@ class _AssetPageState extends ConsumerState { ref.read(isPlayingMotionVideoProvider.notifier).playing = true; void _onScaleStateChanged(PhotoViewScaleState scaleState) { - _isZoomed = switch (scaleState) { - PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true, - _ => false, - }; + _isZoomed = + scaleState == PhotoViewScaleState.zoomedIn || + scaleState == PhotoViewScaleState.covering || + _videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn || + _videoScaleStateNotifier.value == PhotoViewScaleState.covering; _viewer.setZoomed(_isZoomed); if (scaleState != PhotoViewScaleState.initial) { @@ -340,34 +343,33 @@ class _AssetPageState extends ConsumerState { } return PhotoView.customChild( + key: ValueKey(displayAsset), onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, onDragCancel: _onDragCancel, - onTapUp: _onTapUp, heroAttributes: heroAttributes, filterQuality: FilterQuality.high, - maxScale: 1.0, basePosition: Alignment.center, disableScaleGestures: true, - scaleStateChangedCallback: _onScaleStateChanged, + minScale: PhotoViewComputedScale.contained, + initialScale: PhotoViewComputedScale.contained, + tightMode: true, onPageBuild: _onPageBuild, enablePanAlways: true, backgroundDecoration: backgroundDecoration, - child: SizedBox( - width: context.width, - height: context.height, - child: NativeVideoViewer( + child: NativeVideoViewer( + key: ValueKey(displayAsset), + asset: displayAsset, + scaleStateNotifier: _videoScaleStateNotifier, + disableScaleGestures: showingDetails, + image: Image( key: ValueKey(displayAsset.heroTag), - asset: displayAsset, - image: Image( - key: ValueKey(displayAsset), - image: getFullImageProvider(displayAsset, size: context.sizeData), - fit: BoxFit.contain, - height: context.height, - width: context.width, - alignment: Alignment.center, - ), + image: getFullImageProvider(displayAsset, size: context.sizeData), + height: context.height, + width: context.width, + fit: BoxFit.contain, + alignment: Alignment.center, ), ), ); diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 643d3e87ef..0f6568e8fd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -25,6 +26,7 @@ import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/hooks/interval_hook.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -52,6 +54,8 @@ class NativeVideoViewer extends HookConsumerWidget { final bool showControls; final int playbackDelayFactor; final Widget image; + final ValueNotifier? scaleStateNotifier; + final bool disableScaleGestures; const NativeVideoViewer({ super.key, @@ -59,6 +63,8 @@ class NativeVideoViewer extends HookConsumerWidget { required this.image, this.showControls = true, this.playbackDelayFactor = 1, + this.scaleStateNotifier, + this.disableScaleGestures = false, }); @override @@ -138,6 +144,7 @@ class NativeVideoViewer extends HookConsumerWidget { final videoSource = useMemoized>(() => createSource()); final aspectRatio = useState(null); + useMemoized(() async { if (!context.mounted || aspectRatio.value != null) { return null; @@ -313,6 +320,20 @@ class NativeVideoViewer extends HookConsumerWidget { Timer(const Duration(milliseconds: 200), checkIfBuffering); } + Size? videoContextSize(double? videoAspectRatio, BuildContext? context) { + Size? videoContextSize; + if (videoAspectRatio == null || context == null) { + return null; + } + final contextAspectRatio = context.width / context.height; + if (videoAspectRatio > contextAspectRatio) { + videoContextSize = Size(context.width, context.width / aspectRatio.value!); + } else { + videoContextSize = Size(context.height * aspectRatio.value!, context.height); + } + return videoContextSize; + } + ref.listen(currentAssetNotifier, (_, value) { final playerController = controller.value; if (playerController != null && value != asset) { @@ -393,26 +414,31 @@ class NativeVideoViewer extends HookConsumerWidget { } }); - return Stack( - children: [ - // This remains under the video to avoid flickering - // For motion videos, this is the image portion of the asset - Center(key: ValueKey(asset.heroTag), child: image), - if (aspectRatio.value != null && !isCasting) - Visibility.maintain( - key: ValueKey(asset), - visible: isVisible.value, - child: Center( + return SizedBox( + width: context.width, + height: context.height, + child: Stack( + children: [ + // Hide thumbnail once video is visible to avoid it showing in background when zooming out on video. + if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image), + if (aspectRatio.value != null && !isCasting && isCurrent) + Visibility.maintain( key: ValueKey(asset), - child: AspectRatio( + visible: isVisible.value, + child: PhotoView.customChild( key: ValueKey(asset), - aspectRatio: aspectRatio.value!, - child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null, + enableRotation: false, + disableScaleGestures: disableScaleGestures, + // Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet. + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state, + childSize: videoContextSize(aspectRatio.value, context), + child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController), ), ), - ), - if (showControls) const Center(child: VideoViewerControls()), - ], + if (showControls) const Center(child: VideoViewerControls()), + ], + ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart index a2c1372c83..28cfe5e73c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart @@ -81,27 +81,35 @@ class VideoViewerControls extends HookConsumerWidget { } } + void toggleControlsVisibility() { + if (showBuffering) { + return; + } + if (showControls) { + ref.read(assetViewerProvider.notifier).setControls(false); + } else { + showControlsAndStartHideTimer(); + } + } + return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: showControlsAndStartHideTimer, - child: AbsorbPointer( - absorbing: !showControls, + behavior: HitTestBehavior.translucent, + onTap: toggleControlsVisibility, + child: IgnorePointer( + ignoring: !showControls, child: Stack( children: [ if (showBuffering) const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400))) else - GestureDetector( - onTap: () => ref.read(assetViewerProvider.notifier).setControls(false), - child: CenterPlayButton( - backgroundColor: Colors.black54, - iconColor: Colors.white, - isFinished: state == VideoPlaybackState.completed, - isPlaying: - state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), - show: assetIsVideo && showControls, - onPressed: togglePlay, - ), + CenterPlayButton( + backgroundColor: Colors.black54, + iconColor: Colors.white, + isFinished: state == VideoPlaybackState.completed, + isPlaying: + state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), + show: assetIsVideo && showControls, + onPressed: togglePlay, ), ], ), diff --git a/mobile/lib/widgets/asset_viewer/center_play_button.dart b/mobile/lib/widgets/asset_viewer/center_play_button.dart index 26d0a41129..55d8be8095 100644 --- a/mobile/lib/widgets/asset_viewer/center_play_button.dart +++ b/mobile/lib/widgets/asset_viewer/center_play_button.dart @@ -21,23 +21,20 @@ class CenterPlayButton extends StatelessWidget { @override Widget build(BuildContext context) { - return ColoredBox( - color: Colors.transparent, - child: Center( - child: UnconstrainedBox( - child: AnimatedOpacity( - opacity: show ? 1.0 : 0.0, - duration: const Duration(milliseconds: 100), - child: DecoratedBox( - decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), - child: IconButton( - iconSize: 32, - padding: const EdgeInsets.all(12.0), - icon: isFinished - ? Icon(Icons.replay, color: iconColor) - : AnimatedPlayPause(color: iconColor, playing: isPlaying), - onPressed: onPressed, - ), + return Center( + child: UnconstrainedBox( + child: AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: const Duration(milliseconds: 100), + child: DecoratedBox( + decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), + child: IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12.0), + icon: isFinished + ? Icon(Icons.replay, color: iconColor) + : AnimatedPlayPause(color: iconColor, playing: isPlaying), + onPressed: onPressed, ), ), ), From 3ce0654cab8504c562ae8632fa247c9176d4c722 Mon Sep 17 00:00:00 2001 From: Timon Date: Sun, 22 Feb 2026 06:53:39 +0100 Subject: [PATCH 100/143] feat(mobile): Allow users to set album cover from mobile app (#25515) * set album cover from asset * add to correct kebab group * add to album selection * add to legacy control bottom bar * add tests * format * analyze * Revert "add to legacy control bottom bar" This reverts commit 9d68e12a08d04e6c2888bbe223ff7b4436509930. * remove unnecessary event emission * lint * fix tests * fix: button order and remove unncessary check --------- Co-authored-by: Alex --- .../set_album_cover.widget.dart | 56 ++++++++ .../remote_album_bottom_sheet.widget.dart | 3 + .../infrastructure/action.provider.dart | 16 +++ mobile/lib/services/action.service.dart | 6 + mobile/lib/utils/action_button.utils.dart | 17 ++- .../test/utils/action_button_utils_test.dart | 124 ++++++++++++++++++ 6 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/set_album_cover.widget.dart diff --git a/mobile/lib/presentation/widgets/action_buttons/set_album_cover.widget.dart b/mobile/lib/presentation/widgets/action_buttons/set_album_cover.widget.dart new file mode 100644 index 0000000000..1d704aafe8 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/set_album_cover.widget.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +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'; + +class SetAlbumCoverActionButton extends ConsumerWidget { + final String albumId; + final ActionSource source; + final bool iconOnly; + final bool menuItem; + + const SetAlbumCoverActionButton({ + super.key, + required this.albumId, + required this.source, + this.iconOnly = false, + this.menuItem = false, + }); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).setAlbumCover(source, albumId); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'album_cover_updated'.t(context: context); + + 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, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.image_outlined, + label: 'set_as_album_cover'.t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context, ref), + maxWidth: 100, + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 2f2a2e0a4e..6848a07bb8 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_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/action_buttons/remove_from_album_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart'; @@ -113,6 +114,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState ], if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline), if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id), + if (ownsAlbum && multiselect.selectedAssets.length == 1) + SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id), ], slivers: ownsAlbum ? [ diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index f6d05277ab..c06bcabf26 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -343,6 +343,22 @@ class ActionNotifier extends Notifier { } } + Future setAlbumCover(ActionSource source, String albumId) async { + final assets = _getAssets(source); + final asset = assets.first; + if (asset is! RemoteAsset) { + return const ActionResult(count: 1, success: false, error: 'Asset must be remote'); + } + + try { + await _service.setAlbumCover(albumId, asset.id); + return const ActionResult(count: 1, success: true); + } catch (error, stack) { + _logger.severe('Failed to set album cover', error, stack); + return ActionResult(count: 1, success: false, error: error.toString()); + } + } + Future updateDescription(ActionSource source, String description) async { final ids = _getRemoteIdsForSource(source); if (ids.length != 1) { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 3d3ef1494c..c435bf9d79 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -240,6 +240,12 @@ class ActionService { return _downloadRepository.downloadAllAssets(assets); } + Future setAlbumCover(String albumId, String assetId) async { + final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, thumbnailAssetId: assetId); + await _remoteAlbumRepository.update(updatedAlbum); + return true; + } + Future _deleteLocalAssets(List localIds) async { final deletedIds = await _assetMediaRepository.deleteAll(localIds); if (deletedIds.isEmpty) { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index dccb765760..78df9b3d8a 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; @@ -42,6 +43,7 @@ class ActionButtonContext { final bool isCasting; final TimelineOrigin timelineOrigin; final ThemeData? originalTheme; + final int selectedCount; const ActionButtonContext({ required this.asset, @@ -56,6 +58,7 @@ class ActionButtonContext { this.isCasting = false, this.timelineOrigin = TimelineOrigin.main, this.originalTheme, + this.selectedCount = 1, }); } @@ -65,6 +68,7 @@ enum ActionButtonType { share, shareLink, cast, + setAlbumCover, similarPhotos, viewInTimeline, download, @@ -134,6 +138,11 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.currentAlbum != null, + ActionButtonType.setAlbumCover => + context.isOwner && // + !context.isInLockedView && // + context.currentAlbum != null && // + context.selectedCount == 1, ActionButtonType.unstack => context.isOwner && // !context.isInLockedView && // @@ -213,6 +222,12 @@ enum ActionButtonType { iconOnly: iconOnly, menuItem: menuItem, ), + ActionButtonType.setAlbumCover => SetAlbumCoverActionButton( + albumId: context.currentAlbum!.id, + source: context.source, + iconOnly: iconOnly, + menuItem: menuItem, + ), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.similarPhotos => SimilarPhotosActionButton( @@ -251,7 +266,7 @@ enum ActionButtonType { int get kebabMenuGroup => switch (this) { // 0: info ActionButtonType.openInfo => 0, - // 10: move,remove, and delete + // 10: move, remove, and delete ActionButtonType.trash => 10, ActionButtonType.deletePermanent => 10, ActionButtonType.removeFromLockFolder => 10, diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index 4152155d24..a713a4063c 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -637,6 +637,115 @@ void main() { }); }); + group('setAlbumCover button', () { + test('should show when owner, not locked, has album, and selectedCount is 1', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue); + }); + + test('should not show when not owner', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: false, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when in locked view', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when no current album', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 1, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when selectedCount is not 1', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 0, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + + test('should not show when selectedCount is greater than 1', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + selectedCount: 2, + ); + + expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse); + }); + }); + group('likeActivity button', () { test('should show when not locked, has album, activity enabled, and shared', () { final album = createRemoteAlbum(isActivityEnabled: true, isShared: true); @@ -846,6 +955,21 @@ void main() { ); final widget = buttonType.buildButton(contextWithAlbum); expect(widget, isA()); + } else if (buttonType == ActionButtonType.setAlbumCover) { + final album = createRemoteAlbum(); + final contextWithAlbum = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + final widget = buttonType.buildButton(contextWithAlbum); + expect(widget, isA()); } else if (buttonType == ActionButtonType.unstack) { final album = createRemoteAlbum(); final contextWithAlbum = ActionButtonContext( From f0cf3311d52ceddbb60c3debcefa40ea8b38ede5 Mon Sep 17 00:00:00 2001 From: Timon Date: Sun, 22 Feb 2026 07:02:33 +0100 Subject: [PATCH 101/143] feat(mobile): Allow users to set profile picture from asset viewer (#25517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init * fix * styling * temporary workaround for 500 error **Root cause:** The autogenerated Dart OpenAPI client (`UsersApi.createProfileImage()`) had two issues: 1. It set `Content-Type: multipart/form-data` without a boundary, which overrode the correct header that Dart's `MultipartRequest` would set (`multipart/form-data; boundary=...`). 2. It added the file to both `mp.fields` and `mp.files`, creating a duplicate text field. **Result:** Multer on the server failed to parse the multipart body, so `@UploadedFile()` was `undefined` → accessing `file.path` in `UserService.createProfileImage()` threw → **500 Internal Server Error**. **Workaround:** Bypass the autogenerated method in `UserApiRepository.createProfileImage()` and send the multipart request directly using the same `ApiClient` (basePath + auth), ensuring: - No manual `Content-Type` header (let `MultipartRequest` set it with boundary) - File only in `mp.files`, not `mp.fields` - Proper filename fallback * Revert "temporary workaround for 500 error" This reverts commit 8436cd402632ca7be9272a1c72fdaf0763dcefb6. * generate route for ProfilePictureCropPage * add route import * simplify * try this * Revert "try this" This reverts commit fcf37d2801055c49010ddb4fd271feb900ee645a. * try patching * Reapply "temporary workaround for 500 error" This reverts commit faeed810c21e4c9f0839dfff1f34aa6183469e56. * Revert "Reapply "temporary workaround for 500 error"" This reverts commit a14a0b76d14975af98ef91748576a79cef959635. * fix upload * Refactor image conversion logic by introducing a new utility function. Replace inline image-to-Uint8List conversion with the new utility in EditImagePage, DriftEditImagePage, and ProfilePictureCropPage. * use toast over snack * format * Revert "try patching" This reverts commit 68a616522a1eee88c4a9755a314c0017e6450c0f. * Enhance toast notification in ProfilePictureCropPage to include success type for better user feedback. * Revert "simplify" This reverts commit 8e85057a40678c25bfffa8578ddcc8fd7d1e143e. * format * add tests * refactor to use statefulwidget * format --------- Co-authored-by: Alex --- mobile/lib/pages/editing/edit.page.dart | 22 +-- .../pages/editing/drift_edit.page.dart | 21 +-- .../profile/profile_picture_crop.page.dart | 177 ++++++++++++++++++ ..._profile_picture_action_button.widget.dart | 35 ++++ .../upload_profile_image.provider.dart | 4 +- mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 38 ++++ mobile/lib/utils/action_button.utils.dart | 11 ++ mobile/lib/utils/image_converter.dart | 28 +++ .../test/utils/action_button_utils_test.dart | 70 +++++++ 10 files changed, 367 insertions(+), 41 deletions(-) create mode 100644 mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart create mode 100644 mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart create mode 100644 mobile/lib/utils/image_converter.dart diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index c9ab014456..2889785d0b 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -1,6 +1,4 @@ -import 'dart:async'; import 'dart:typed_data'; -import 'dart:ui'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -12,6 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/image_converter.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:path/path.dart' as p; @@ -30,27 +29,10 @@ class EditImagePage extends ConsumerWidget { final bool isEdited; const EditImagePage({super.key, required this.asset, required this.image, required this.isEdited}); - Future _imageToUint8List(Image image) async { - final Completer completer = Completer(); - image.image - .resolve(const ImageConfiguration()) - .addListener( - ImageStreamListener((ImageInfo info, bool _) { - info.image.toByteData(format: ImageByteFormat.png).then((byteData) { - if (byteData != null) { - completer.complete(byteData.buffer.asUint8List()); - } else { - completer.completeError('Failed to convert image to bytes'); - } - }); - }, onError: (exception, stackTrace) => completer.completeError(exception)), - ); - return completer.future; - } Future _saveEditedImage(BuildContext context, Asset asset, Image image, WidgetRef ref) async { try { - final Uint8List imageData = await _imageToUint8List(image); + final Uint8List imageData = await imageToUint8List(image); await ref .read(fileMediaRepositoryProvider) .saveImage(imageData, title: "${p.withoutExtension(asset.fileName)}_edited.jpg"); diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index 7e49348e19..a10202973d 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:ui'; import 'package:auto_route/auto_route.dart'; import 'package:cancellation_token_http/http.dart'; @@ -14,6 +13,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/utils/image_converter.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; @@ -33,23 +33,6 @@ class DriftEditImagePage extends ConsumerWidget { final bool isEdited; const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited}); - Future _imageToUint8List(Image image) async { - final Completer completer = Completer(); - image.image - .resolve(const ImageConfiguration()) - .addListener( - ImageStreamListener((ImageInfo info, bool _) { - info.image.toByteData(format: ImageByteFormat.png).then((byteData) { - if (byteData != null) { - completer.complete(byteData.buffer.asUint8List()); - } else { - completer.completeError('Failed to convert image to bytes'); - } - }); - }, onError: (exception, stackTrace) => completer.completeError(exception)), - ); - return completer.future; - } void _exitEditing(BuildContext context) { // this assumes that the only way to get to this page is from the AssetViewerRoute @@ -58,7 +41,7 @@ class DriftEditImagePage extends ConsumerWidget { Future _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async { try { - final Uint8List imageData = await _imageToUint8List(image); + final Uint8List imageData = await imageToUint8List(image); LocalAsset? localAsset; try { diff --git a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart new file mode 100644 index 0000000000..f460633cbb --- /dev/null +++ b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:crop_image/crop_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/image_converter.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_ui/immich_ui.dart'; + +@RoutePage() +class ProfilePictureCropPage extends ConsumerStatefulWidget { + final BaseAsset asset; + + const ProfilePictureCropPage({super.key, required this.asset}); + + @override + ConsumerState createState() => _ProfilePictureCropPageState(); +} + +class _ProfilePictureCropPageState extends ConsumerState { + late final CropController _cropController; + bool _isLoading = false; + bool _didInitCropController = false; + + @override + void initState() { + super.initState(); + _cropController = CropController(defaultCrop: const Rect.fromLTRB(0, 0, 1, 1)); + + // Lock aspect ratio to 1:1 for circular/square crop + // CropController depends on CropImage initializing its bitmap size. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _didInitCropController) { + return; + } + _didInitCropController = true; + + _cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); + _cropController.aspectRatio = 1.0; + }); + } + + @override + void dispose() { + _cropController.dispose(); + super.dispose(); + } + + Future _handleDone() async { + if (_isLoading) return; + + setState(() { + _isLoading = true; + }); + + try { + final croppedImage = await _cropController.croppedImage(); + final pngBytes = await imageToUint8List(croppedImage); + final xFile = XFile.fromData(pngBytes, mimeType: 'image/png'); + final success = await ref + .read(uploadProfileImageProvider.notifier) + .upload(xFile, fileName: 'profile-picture.png'); + + if (!context.mounted) return; + + if (success) { + final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath; + ref.read(authProvider.notifier).updateUserProfileImagePath(profileImagePath); + final user = ref.read(currentUserProvider); + if (user != null) { + unawaited(ref.read(currentUserProvider.notifier).refresh()); + } + unawaited(ref.read(backupProvider.notifier).updateDiskInfo()); + + ImmichToast.show( + context: context, + msg: 'profile_picture_set'.tr(), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.success, + ); + + if (context.mounted) { + unawaited(context.maybePop()); + } + } else { + ImmichToast.show( + context: context, + msg: 'errors.unable_to_set_profile_picture'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + } catch (e) { + if (!context.mounted) return; + + ImmichToast.show( + context: context, + msg: 'errors.unable_to_set_profile_picture'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + // Create Image widget from asset + final image = Image(image: getFullImageProvider(widget.asset)); + + return Scaffold( + appBar: AppBar( + backgroundColor: context.scaffoldBackgroundColor, + title: Text("set_profile_picture".tr()), + leading: _isLoading ? null : const ImmichCloseButton(), + actions: [ + if (_isLoading) + const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), + ) + else + ImmichIconButton( + icon: Icons.done_rounded, + color: ImmichColor.primary, + variant: ImmichVariant.ghost, + onPressed: _handleDone, + ), + ], + ), + backgroundColor: context.scaffoldBackgroundColor, + body: SafeArea( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(7)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + spreadRadius: 2, + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + child: CropImage(controller: _cropController, image: image, gridColor: Colors.white), + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart new file mode 100644 index 0000000000..c8dbb7cb1f --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart @@ -0,0 +1,35 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class SetProfilePictureActionButton extends ConsumerWidget { + final BaseAsset asset; + final bool iconOnly; + final bool menuItem; + + const SetProfilePictureActionButton({super.key, required this.asset, this.iconOnly = false, this.menuItem = false}); + + void _onTap(BuildContext context) { + if (!context.mounted) { + return; + } + + context.pushRoute(ProfilePictureCropRoute(asset: asset)); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.account_circle_outlined, + label: "set_as_profile_picture".t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context), + maxWidth: 100, + ); + } +} diff --git a/mobile/lib/providers/upload_profile_image.provider.dart b/mobile/lib/providers/upload_profile_image.provider.dart index 5aa924ed1c..a2b7a23f05 100644 --- a/mobile/lib/providers/upload_profile_image.provider.dart +++ b/mobile/lib/providers/upload_profile_image.provider.dart @@ -61,10 +61,10 @@ class UploadProfileImageNotifier extends StateNotifier final UserService _userService; - Future upload(XFile file) async { + Future upload(XFile file, {String? fileName}) async { state = state.copyWith(status: UploadProfileStatus.loading); - var profileImagePath = await _userService.createProfileImage(file.name, await file.readAsBytes()); + var profileImagePath = await _userService.createProfileImage(fileName ?? file.name, await file.readAsBytes()); if (profileImagePath != null) { dPrint(() => "Successfully upload profile image"); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 81616f8880..b385bcbf71 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -106,6 +106,7 @@ import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart'; +import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; @@ -198,6 +199,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: EditImageRoute.page), AutoRoute(page: CropImageRoute.page), AutoRoute(page: FilterImageRoute.page), + AutoRoute(page: ProfilePictureCropRoute.page), CustomRoute( page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 86c52d90dc..2d57c16573 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -2443,6 +2443,44 @@ class PlacesCollectionRouteArgs { } } +/// generated route for +/// [ProfilePictureCropPage] +class ProfilePictureCropRoute + extends PageRouteInfo { + ProfilePictureCropRoute({ + Key? key, + required BaseAsset asset, + List? children, + }) : super( + ProfilePictureCropRoute.name, + args: ProfilePictureCropRouteArgs(key: key, asset: asset), + initialChildren: children, + ); + + static const String name = 'ProfilePictureCropRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return ProfilePictureCropPage(key: args.key, asset: args.asset); + }, + ); +} + +class ProfilePictureCropRouteArgs { + const ProfilePictureCropRouteArgs({this.key, required this.asset}); + + final Key? key; + + final BaseAsset asset; + + @override + String toString() { + return 'ProfilePictureCropRouteArgs{key: $key, asset: $asset}'; + } +} + /// generated route for /// [RecentlyTakenPage] class RecentlyTakenRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 78df9b3d8a..2e26d8e80d 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -24,6 +24,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cove import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; @@ -70,6 +71,7 @@ enum ActionButtonType { cast, setAlbumCover, similarPhotos, + setProfilePicture, viewInTimeline, download, upload, @@ -155,6 +157,10 @@ enum ActionButtonType { ActionButtonType.similarPhotos => !context.isInLockedView && // context.asset is RemoteAsset, + ActionButtonType.setProfilePicture => + !context.isInLockedView && // + context.asset is RemoteAsset && // + context.isOwner, ActionButtonType.openInfo => true, ActionButtonType.viewInTimeline => context.timelineOrigin != TimelineOrigin.main && @@ -235,6 +241,11 @@ enum ActionButtonType { iconOnly: iconOnly, menuItem: menuItem, ), + ActionButtonType.setProfilePicture => SetProfilePictureActionButton( + asset: context.asset, + iconOnly: iconOnly, + menuItem: menuItem, + ), ActionButtonType.openInfo => BaseActionButton( label: 'info'.tr(), iconData: Icons.info_outline, diff --git a/mobile/lib/utils/image_converter.dart b/mobile/lib/utils/image_converter.dart new file mode 100644 index 0000000000..6711e2bd56 --- /dev/null +++ b/mobile/lib/utils/image_converter.dart @@ -0,0 +1,28 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +/// Converts a Flutter [Image] widget to a [Uint8List] in PNG format. +/// +/// This function resolves the image stream and converts it to byte data. +/// Returns a [Future] that completes with the image bytes or completes with an error +/// if the conversion fails. +Future imageToUint8List(Image image) async { + final Completer completer = Completer(); + image.image + .resolve(const ImageConfiguration()) + .addListener( + ImageStreamListener((ImageInfo info, bool _) { + info.image.toByteData(format: ImageByteFormat.png).then((byteData) { + if (byteData != null) { + completer.complete(byteData.buffer.asUint8List()); + } else { + completer.completeError('Failed to convert image to bytes'); + } + }); + }, onError: (exception, stackTrace) => completer.completeError(exception)), + ); + return completer.future; +} diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index a713a4063c..01ae50b6c4 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -637,6 +637,76 @@ void main() { }); }); + group('setProfilePicture button', () { + test('should show when owner, not locked, and asset is RemoteAsset', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isTrue); + }); + + test('should not show when not owner', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: false, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isFalse); + }); + + test('should not show when in locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isFalse); + }); + + test('should not show when asset is not RemoteAsset', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.setProfilePicture.shouldShow(context), isFalse); + }); + }); + group('setAlbumCover button', () { test('should show when owner, not locked, has album, and selectedCount is 1', () { final album = createRemoteAlbum(); From d0cb97f994eb411991cf5b837709928e72dafd6d Mon Sep 17 00:00:00 2001 From: Lauritz Tieste <84938977+Lauritz-Tieste@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:01:42 +0100 Subject: [PATCH 102/143] feat(mobile): Add slug support for shared links (#26441) * feat(mobile): add slug support for shared links * fix(mobile): ensure slug retains existing value when unchanged --- .../models/shared_link/shared_link.model.dart | 13 +++++-- .../shared_link/shared_link_edit.page.dart | 37 ++++++++++++++++++- mobile/lib/services/shared_link.service.dart | 5 +++ .../widgets/shared_link/shared_link_item.dart | 5 ++- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/mobile/lib/models/shared_link/shared_link.model.dart b/mobile/lib/models/shared_link/shared_link.model.dart index 57a1f441eb..4315cf616a 100644 --- a/mobile/lib/models/shared_link/shared_link.model.dart +++ b/mobile/lib/models/shared_link/shared_link.model.dart @@ -14,6 +14,7 @@ class SharedLink { final String key; final bool showMetadata; final SharedLinkSource type; + final String? slug; const SharedLink({ required this.id, @@ -27,6 +28,7 @@ class SharedLink { required this.key, required this.showMetadata, required this.type, + required this.slug, }); SharedLink copyWith({ @@ -41,6 +43,7 @@ class SharedLink { String? key, bool? showMetadata, SharedLinkSource? type, + String? slug, }) { return SharedLink( id: id ?? this.id, @@ -54,6 +57,7 @@ class SharedLink { key: key ?? this.key, showMetadata: showMetadata ?? this.showMetadata, type: type ?? this.type, + slug: slug ?? this.slug, ); } @@ -66,6 +70,7 @@ class SharedLink { expiresAt = dto.expiresAt, key = dto.key, showMetadata = dto.showMetadata, + slug = dto.slug, type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual, title = dto.type == SharedLinkType.ALBUM ? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE" @@ -78,7 +83,7 @@ class SharedLink { @override String toString() => - 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)'; + 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type, slug=$slug)'; @override bool operator ==(Object other) => @@ -94,7 +99,8 @@ class SharedLink { other.expiresAt == expiresAt && other.key == key && other.showMetadata == showMetadata && - other.type == type; + other.type == type && + other.slug == slug; @override int get hashCode => @@ -108,5 +114,6 @@ class SharedLink { expiresAt.hashCode ^ key.hashCode ^ showMetadata.hashCode ^ - type.hashCode; + type.hashCode ^ + slug.hashCode; } diff --git a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart index 1d7eaef080..47a3dd853d 100644 --- a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart @@ -29,6 +29,8 @@ class SharedLinkEditPage extends HookConsumerWidget { final descriptionController = useTextEditingController(text: existingLink?.description ?? ""); final descriptionFocusNode = useFocusNode(); final passwordController = useTextEditingController(text: existingLink?.password ?? ""); + final slugController = useTextEditingController(text: existingLink?.slug ?? ""); + final slugFocusNode = useFocusNode(); final showMetadata = useState(existingLink?.showMetadata ?? true); final allowDownload = useState(existingLink?.allowDownload ?? true); final allowUpload = useState(existingLink?.allowUpload ?? false); @@ -108,6 +110,26 @@ class SharedLinkEditPage extends HookConsumerWidget { ); } + Widget buildSlugField() { + return TextField( + controller: slugController, + enabled: newShareLink.value.isEmpty, + focusNode: slugFocusNode, + textInputAction: TextInputAction.done, + autofocus: false, + decoration: InputDecoration( + labelText: 'custom_url'.tr(), + labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary), + floatingLabelBehavior: FloatingLabelBehavior.always, + border: const OutlineInputBorder(), + hintText: 'custom_url'.tr(), + hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), + disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))), + ), + onTapOutside: (_) => slugFocusNode.unfocus(), + ); + } + Widget buildShowMetaButton() { return SwitchListTile.adaptive( value: showMetadata.value, @@ -261,6 +283,7 @@ class SharedLinkEditPage extends HookConsumerWidget { allowUpload: allowUpload.value, description: descriptionController.text.isEmpty ? null : descriptionController.text, password: passwordController.text.isEmpty ? null : passwordController.text, + slug: slugController.text.isEmpty ? null : slugController.text, expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(), ); ref.invalidate(sharedLinksStateProvider); @@ -274,7 +297,10 @@ class SharedLinkEditPage extends HookConsumerWidget { } if (newLink != null && serverUrl != null) { - newShareLink.value = "${serverUrl}share/${newLink.key}"; + final hasSlug = newLink.slug?.isNotEmpty == true; + final urlPath = hasSlug ? newLink.slug : newLink.key; + final basePath = hasSlug ? 's' : 'share'; + newShareLink.value = "$serverUrl$basePath/$urlPath"; copyLinkToClipboard(); } else if (newLink == null) { ImmichToast.show( @@ -292,6 +318,7 @@ class SharedLinkEditPage extends HookConsumerWidget { bool? meta; String? desc; String? password; + String? slug; DateTime? expiry; bool? changeExpiry; @@ -315,6 +342,12 @@ class SharedLinkEditPage extends HookConsumerWidget { password = passwordController.text; } + if (slugController.text != (existingLink!.slug ?? "")) { + slug = slugController.text.isEmpty ? null : slugController.text; + } else { + slug = existingLink!.slug; + } + if (editExpiry.value) { expiry = expiryAfter.value == 0 ? null : calculateExpiry(); changeExpiry = true; @@ -329,6 +362,7 @@ class SharedLinkEditPage extends HookConsumerWidget { allowUpload: upload, description: desc, password: password, + slug: slug, expiresAt: expiry, changeExpiry: changeExpiry, ); @@ -349,6 +383,7 @@ class SharedLinkEditPage extends HookConsumerWidget { Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()), Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()), Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()), + Padding(padding: const EdgeInsets.all(padding), child: buildSlugField()), Padding( padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding), child: buildShowMetaButton(), diff --git a/mobile/lib/services/shared_link.service.dart b/mobile/lib/services/shared_link.service.dart index 25151c234f..46e83f0fc4 100644 --- a/mobile/lib/services/shared_link.service.dart +++ b/mobile/lib/services/shared_link.service.dart @@ -37,6 +37,7 @@ class SharedLinkService { required bool allowUpload, String? description, String? password, + String? slug, String? albumId, List? assetIds, DateTime? expiresAt, @@ -54,6 +55,7 @@ class SharedLinkService { expiresAt: expiresAt, description: description, password: password, + slug: slug, ); } else if (assetIds != null) { dto = SharedLinkCreateDto( @@ -64,6 +66,7 @@ class SharedLinkService { expiresAt: expiresAt, description: description, password: password, + slug: slug, assetIds: assetIds, ); } @@ -88,6 +91,7 @@ class SharedLinkService { bool? changeExpiry = false, String? description, String? password, + String? slug, DateTime? expiresAt, }) async { try { @@ -100,6 +104,7 @@ class SharedLinkService { expiresAt: expiresAt, description: description, password: password, + slug: slug, changeExpiryTime: changeExpiry, ), ); diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart index cbd6e1f077..19da80b833 100644 --- a/mobile/lib/widgets/shared_link/shared_link_item.dart +++ b/mobile/lib/widgets/shared_link/shared_link_item.dart @@ -78,7 +78,10 @@ class SharedLinkItem extends ConsumerWidget { return; } - Clipboard.setData(ClipboardData(text: "${serverUrl}share/${sharedLink.key}")).then((_) { + final hasSlug = sharedLink.slug?.isNotEmpty == true; + final urlPath = hasSlug ? sharedLink.slug : sharedLink.key; + final basePath = hasSlug ? 's' : 'share'; + Clipboard.setData(ClipboardData(text: "$serverUrl$basePath/$urlPath")).then((_) { context.scaffoldMessenger.showSnackBar( SnackBar( content: Text( From 8b2e1509ff5bbaeac6318e1249eb75ed953d0f79 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:19:15 +0000 Subject: [PATCH 103/143] chore(mobile): simplify pop logic (#26410) We have all the information we need to decide on whether we should pop or not at the end of a drag. There's no need to track that separately, and update the value constantly. --- .../widgets/asset_viewer/asset_page.widget.dart | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 43b31b829c..4b8514941d 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -61,7 +61,6 @@ class _AssetPageState extends ConsumerState { DragStartDetails? _dragStart; _DragIntent _dragIntent = _DragIntent.none; Drag? _drag; - bool _shouldPopOnDrag = false; @override void initState() { @@ -120,7 +119,6 @@ class _AssetPageState extends ConsumerState { void _beginDrag(DragStartDetails details) { _dragStart = details; - _shouldPopOnDrag = false; _lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0; if (_viewController != null) { @@ -163,6 +161,7 @@ class _AssetPageState extends ConsumerState { void _endDrag(DragEndDetails details) { if (_dragStart == null) return; + final start = _dragStart; _dragStart = null; final intent = _dragIntent; @@ -178,7 +177,8 @@ class _AssetPageState extends ConsumerState { _drag?.end(details); _drag = null; case _DragIntent.dismiss: - if (_shouldPopOnDrag) { + const popThreshold = 75.0; + if (details.localPosition.dy - start!.localPosition.dy > popThreshold) { context.maybePop(); return; } @@ -211,12 +211,8 @@ class _AssetPageState extends ConsumerState { void _handleDragDown(BuildContext context, Offset delta) { const dragRatio = 0.2; - const popThreshold = 75.0; - - _shouldPopOnDrag = delta.dy > popThreshold; final distance = delta.dy.abs(); - final maxScaleDistance = context.height * 0.5; final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale; From 31a55aaa73a3a784f5f8adca0e288f48a1c647de Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:34:56 -0500 Subject: [PATCH 104/143] fix(web): storage template example (#26424) --- .../lib/components/admin-settings/SupportedDatetimePanel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte b/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte index e88734c7d9..de455380a9 100644 --- a/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte +++ b/web/src/lib/components/admin-settings/SupportedDatetimePanel.svelte @@ -23,7 +23,7 @@ {$t('admin.storage_template_date_time_description')} {$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-15T20:03:05.250+00:00' } })} From 1bd28c3e785ed23411d10988ad8108e46fcff82e Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:24:51 +0100 Subject: [PATCH 105/143] fix(web): prevent `state_unsafe_mutation` error on people page (#26438) --- web/src/lib/actions/focus-outside.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/lib/actions/focus-outside.ts b/web/src/lib/actions/focus-outside.ts index c302e33d4c..829497ccdb 100644 --- a/web/src/lib/actions/focus-outside.ts +++ b/web/src/lib/actions/focus-outside.ts @@ -1,3 +1,5 @@ +import { on } from 'svelte/events'; + interface Options { onFocusOut?: (event: FocusEvent) => void; } @@ -19,11 +21,11 @@ export function focusOutside(node: HTMLElement, options: Options = {}) { } }; - node.addEventListener('focusout', handleFocusOut); + const off = on(node, 'focusout', handleFocusOut); return { destroy() { - node.removeEventListener('focusout', handleFocusOut); + off(); }, }; } From caebe5166ab977a9bff03cd9beceb314f59fd7f1 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:48:25 +0000 Subject: [PATCH 106/143] chore(mobile): remove redundant assignment (#26404) The view controller is already assigned during page build. Reassigning it for every drag doesn't really make any sense. --- .../lib/presentation/widgets/asset_viewer/asset_page.widget.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 4b8514941d..125ad36f9a 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -197,7 +197,6 @@ class _AssetPageState extends ConsumerState { PhotoViewControllerBase controller, PhotoViewScaleStateController scaleStateController, ) { - _viewController = controller; if (!_showingDetails && _isZoomed) return; _beginDrag(details); } From 430638e129c1f0e615f7b1aef6e9bac4d6410e91 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 23 Feb 2026 08:16:28 -0500 Subject: [PATCH 107/143] feat: warn when losing transparency during thumbnail generation (#26243) * feat: preserve alpha * refactor: use isTransparent naming and separate getImageMetadata * warn instead of preserve --- server/src/repositories/media.repository.ts | 6 +- server/src/services/media.service.spec.ts | 53 ++++++++++++---- server/src/services/media.service.ts | 61 +++++++++++++------ server/src/utils/mime-types.spec.ts | 27 ++++++++ server/src/utils/mime-types.ts | 16 +++++ .../repositories/media.repository.mock.ts | 2 +- 6 files changed, 131 insertions(+), 34 deletions(-) diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 33025e73cf..e3e78b3238 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -309,9 +309,9 @@ export class MediaRepository { }); } - async getImageDimensions(input: string | Buffer): Promise { - const { width = 0, height = 0 } = await sharp(input).metadata(); - return { width, height }; + async getImageMetadata(input: string | Buffer): Promise { + const { width = 0, height = 0, hasAlpha = false } = await sharp(input).metadata(); + return { width, height, isTransparent: hasAlpha }; } private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 399eb5d6a0..368ece625c 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -348,6 +348,7 @@ describe(MediaService.name, () => { : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted ), ); + mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false }); }); it('should skip thumbnail generation if asset not found', async () => { @@ -857,7 +858,7 @@ describe(MediaService.name, () => { .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); @@ -871,12 +872,39 @@ describe(MediaService.name, () => { }); }); + it('should not check transparency metadata for raw files without extracted images', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + + await sut.handleGenerateThumbnails({ id: asset.id }); + + expect(mocks.media.getImageMetadata).not.toHaveBeenCalled(); + }); + + it('should not check transparency metadata for raw files with extracted images', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + + await sut.handleGenerateThumbnails({ id: asset.id }); + + expect(mocks.media.getImageMetadata).toHaveBeenCalledOnce(); + expect(mocks.media.getImageMetadata).toHaveBeenCalledWith(extractedBuffer); + }); + it('should resize original image if embedded image is too small', async () => { const asset = AssetFactory.from({ originalFileName: 'file.dng' }) .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); @@ -970,7 +998,7 @@ describe(MediaService.name, () => { image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1008,7 +1036,7 @@ describe(MediaService.name, () => { image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1056,7 +1084,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1100,7 +1128,7 @@ describe(MediaService.name, () => { it('should generate full-size preview from non-web-friendly images', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. const asset = AssetFactory.from({ originalFileName: 'image.hif' }) .exif({ @@ -1139,7 +1167,7 @@ describe(MediaService.name, () => { const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1162,7 +1190,7 @@ describe(MediaService.name, () => { it('should always generate full-size preview from non-web-friendly panoramas', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.media.copyTagGroup.mockResolvedValue(true); const asset = AssetFactory.from({ originalFileName: 'panorama.tif' }) @@ -1208,7 +1236,7 @@ describe(MediaService.name, () => { image: { fullsize: { enabled: true, format: ImageFormat.Webp, quality: 90 } }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. const asset = AssetFactory.from({ originalFileName: 'image.hif' }) .exif({ @@ -1248,7 +1276,7 @@ describe(MediaService.name, () => { image: { fullsize: { enabled: true, format: ImageFormat.Jpeg, progressive: true } }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); const asset = AssetFactory.from({ originalFileName: 'image.hif' }) .exif({ fileSizeInByte: 5000, @@ -1286,6 +1314,7 @@ describe(MediaService.name, () => { : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted ), ); + mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false }); }); it('should skip videos', async () => { @@ -1719,7 +1748,7 @@ describe(MediaService.name, () => { const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); mocks.media.decodeImage.mockResolvedValue({ data, info }); - mocks.media.getImageDimensions.mockResolvedValue(info); + mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1802,7 +1831,7 @@ describe(MediaService.name, () => { const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue(info); + mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 5fa72cf117..153083142d 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -280,14 +280,20 @@ export class MediaService extends BaseService { useEdits; const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`)); + const thumbSource = extracted ? extracted.buffer : asset.originalPath; const { data, info, colorspace } = await this.decodeImage( - extracted ? extracted.buffer : asset.originalPath, + thumbSource, // only specify orientation to extracted images which don't have EXIF orientation data // or it can double rotate the image extracted ? asset.exifInfo : { ...asset.exifInfo, orientation: null }, convertFullsize ? undefined : image.preview.size, ); + let isTransparent = false; + if (!extracted && mimeTypes.canBeTransparent(asset.originalPath)) { + ({ isTransparent } = await this.mediaRepository.getImageMetadata(asset.originalPath)); + } + return { extracted, data, @@ -295,50 +301,61 @@ export class MediaService extends BaseService { colorspace, convertFullsize, generateFullsize, + isTransparent, }; } private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) { + // Handle embedded preview extraction for RAW files + const extractedImage = await this.extractOriginalImage(asset, image, useEdits); + const { info, data, colorspace, generateFullsize, convertFullsize, extracted, isTransparent } = extractedImage; + + const previewFormat = image.preview.format; + this.warnOnTransparencyLoss(isTransparent, previewFormat, asset.id); + + const thumbnailFormat = image.thumbnail.format; + this.warnOnTransparencyLoss(isTransparent, thumbnailFormat, asset.id); + const previewFile = this.getImageFile(asset, { fileType: AssetFileType.Preview, - format: image.preview.format, + format: previewFormat, isEdited: useEdits, - isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp, + isProgressive: !!image.preview.progressive && previewFormat !== ImageFormat.Webp, }); const thumbnailFile = this.getImageFile(asset, { fileType: AssetFileType.Thumbnail, - format: image.thumbnail.format, + format: thumbnailFormat, isEdited: useEdits, - isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp, + isProgressive: !!image.thumbnail.progressive && thumbnailFormat !== ImageFormat.Webp, }); this.storageCore.ensureFolders(previewFile.path); - // Handle embedded preview extraction for RAW files - const extractedImage = await this.extractOriginalImage(asset, image, useEdits); - const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage; - // generate final images - const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; + const baseOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; + const thumbnailOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat }; + const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat }; const promises = [ - this.mediaRepository.generateThumbhash(data, thumbnailOptions), - this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path), - this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path), + this.mediaRepository.generateThumbhash(data, baseOptions), + this.mediaRepository.generateThumbnail(data, thumbnailOptions, thumbnailFile.path), + this.mediaRepository.generateThumbnail(data, previewOptions, previewFile.path), ]; let fullsizeFile: UpsertFileOptions | undefined; if (convertFullsize) { + const fullsizeFormat = image.fullsize.format; + this.warnOnTransparencyLoss(isTransparent, fullsizeFormat, asset.id); // convert a new fullsize image from the same source as the thumbnail fullsizeFile = this.getImageFile(asset, { fileType: AssetFileType.FullSize, - format: image.fullsize.format, + format: fullsizeFormat, isEdited: useEdits, - isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp, + isProgressive: !!image.fullsize.progressive && fullsizeFormat !== ImageFormat.Webp, }); const fullsizeOptions = { - format: image.fullsize.format, + ...baseOptions, + format: fullsizeFormat, quality: image.fullsize.quality, progressive: image.fullsize.progressive, - ...thumbnailOptions, }; promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path)); } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { @@ -758,7 +775,7 @@ export class MediaService extends BaseService { } private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) { - const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer); + const { width, height } = await this.mediaRepository.getImageMetadata(extractedPathOrBuffer); const extractedSize = Math.min(width, height); return extractedSize >= targetSize; } @@ -857,6 +874,14 @@ export class MediaService extends BaseService { return generated; } + private warnOnTransparencyLoss(isTransparent: boolean, format: ImageFormat, assetId: string) { + if (isTransparent && format === ImageFormat.Jpeg) { + this.logger.warn( + `Asset ${assetId} has transparency but the configured format is ${format} which does not support it, consider using a format that does, such as ${ImageFormat.Webp}`, + ); + } + } + private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) { const path = StorageCore.getImagePath(asset, options); return { diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index b0e31afe39..862ed310bc 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -153,6 +153,33 @@ describe('mimeTypes', () => { } }); + describe('canBeTransparent', () => { + for (const img of [ + 'a.avif', + 'a.bmp', + 'a.gif', + 'a.heic', + 'a.heif', + 'a.hif', + 'a.jxl', + 'a.png', + 'a.svg', + 'a.tif', + 'a.tiff', + 'a.webp', + ]) { + it(`should return true for ${img}`, () => { + expect(mimeTypes.canBeTransparent(img)).toBe(true); + }); + } + + for (const img of ['a.jpg', 'a.jpeg', 'a.jpe', 'a.insp', 'a.jp2', 'a.cr3', 'a.dng', 'a.nef', 'a.arw']) { + it(`should return false for ${img}`, () => { + expect(mimeTypes.canBeTransparent(img)).toBe(false); + }); + } + }); + describe('animated image', () => { for (const img of ['a.avif', 'a.gif', 'a.webp']) { it('should identify animated image mime types as such', () => { diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 4e91bbd7f1..43421e7937 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -77,6 +77,21 @@ const extensionOverrides: Record = { 'image/jpeg': '.jpg', }; +const transparentCapableExtensions = new Set([ + '.avif', + '.bmp', + '.gif', + '.heic', + '.heif', + '.hif', + '.jxl', + '.png', + '.svg', + '.tif', + '.tiff', + '.webp', +]); + const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp', '.svg']); const profile: Record = Object.fromEntries( Object.entries(image).filter(([key]) => profileExtensions.has(key)), @@ -134,6 +149,7 @@ export const mimeTypes = { isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), + canBeTransparent: (filename: string) => transparentCapableExtensions.has(extname(filename).toLowerCase()), isRaw: (filename: string) => isType(filename, raw), lookup, /** return an extension (including a leading `.`) for a mime-type */ diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index b6b1e82b52..bd8deb4b3a 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -12,6 +12,6 @@ export const newMediaRepositoryMock = (): Mocked Date: Mon, 23 Feb 2026 15:43:45 +0100 Subject: [PATCH 108/143] perf(mobile): optimized album sorting (#25179) * perf(mobile): optimized album sorting * refactor: add index & sql query * fix: migration * refactor: enum, ordering & list * test: update album service tests * chore: fix enums broken during merging main * chore: remove unnecessary tests * test: add tests for getSortedAlbumIds * test: added back stubs in service test --- mobile/lib/constants/enums.dart | 2 + .../domain/services/remote_album.service.dart | 61 ++-- .../repositories/remote_album.repository.dart | 42 ++- .../domain/services/album.service_test.dart | 48 +-- .../remote_album_repository_test.dart | 305 ++++++++++++++++++ 5 files changed, 368 insertions(+), 90 deletions(-) create mode 100644 mobile/test/infrastructure/repositories/remote_album_repository_test.dart diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 350f6b80fa..32ef9bbbed 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -18,3 +18,5 @@ enum ActionSource { timeline, viewer } enum CleanupStep { selectDate, scan, delete } enum AssetKeepType { none, photosOnly, videosOnly } + +enum AssetDateAggregation { start, end } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 0cf3f3e1c1..945ba8eb3f 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -43,8 +43,8 @@ class RemoteAlbumService { AlbumSortMode.title => albums.sortedBy((album) => album.name), AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), - AlbumSortMode.mostRecent => await _sortByNewestAsset(albums), - AlbumSortMode.mostOldest => await _sortByOldestAsset(albums), + AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end), + AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start), }; final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder; @@ -172,46 +172,25 @@ class RemoteAlbumService { return _repository.getAlbumsContainingAsset(assetId); } - Future> _sortByNewestAsset(List albums) async { - // map album IDs to their newest asset dates - final Map> assetTimestampFutures = {}; - for (final album in albums) { - assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id); + Future> _sortByAssetDate( + List albums, { + required AssetDateAggregation aggregation, + }) async { + if (albums.isEmpty) return []; + + final albumIds = albums.map((e) => e.id).toList(); + final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation); + + final albumMap = Map.fromEntries(albums.map((a) => MapEntry(a.id, a))); + + final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType().toList(); + + if (sortedAlbums.length < albums.length) { + final returnedIdSet = sortedIds.toSet(); + final emptyAlbums = albums.where((a) => !returnedIdSet.contains(a.id)); + sortedAlbums.addAll(emptyAlbums); } - // await all database queries - final entries = await Future.wait( - assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)), - ); - final assetTimestamps = Map.fromEntries(entries); - - final sorted = albums.sorted((a, b) { - final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - return aDate.compareTo(bDate); - }); - - return sorted; - } - - Future> _sortByOldestAsset(List albums) async { - // map album IDs to their oldest asset dates - final Map> assetTimestampFutures = { - for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id), - }; - - // await all database queries - final entries = await Future.wait( - assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)), - ); - final assetTimestamps = Map.fromEntries(entries); - - final sorted = albums.sorted((a, b) { - final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0); - return aDate.compareTo(bDate); - }); - - return sorted; + return sortedAlbums; } } diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index d7d4a250ad..a594647f19 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:convert'; import 'package:drift/drift.dart'; +import 'package:immich_mobile/constants/enums.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/domain/models/user.model.dart'; @@ -321,26 +323,32 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { }).watchSingleOrNull(); } - Future getNewestAssetTimestamp(String albumId) { - final query = _db.remoteAlbumAssetEntity.selectOnly() - ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) - ..addColumns([_db.remoteAssetEntity.localDateTime.max()]) - ..join([ - innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)), - ]); + Future> getSortedAlbumIds(List albumIds, {required AssetDateAggregation aggregation}) async { + if (albumIds.isEmpty) return []; - return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull(); - } + final jsonIds = jsonEncode(albumIds); + final sqlAgg = aggregation == AssetDateAggregation.start ? 'MIN' : 'MAX'; - Future getOldestAssetTimestamp(String albumId) { - final query = _db.remoteAlbumAssetEntity.selectOnly() - ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) - ..addColumns([_db.remoteAssetEntity.localDateTime.min()]) - ..join([ - innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)), - ]); + final rows = await _db + .customSelect( + ''' + SELECT + raae.album_id, + $sqlAgg(rae.local_date_time) AS asset_date + FROM json_each(?) ids + INNER JOIN remote_album_asset_entity raae + ON raae.album_id = ids.value + INNER JOIN remote_asset_entity rae + ON rae.id = raae.asset_id + GROUP BY raae.album_id + ORDER BY asset_date ASC + ''', + variables: [Variable(jsonIds)], + readsFrom: {_db.remoteAlbumAssetEntity, _db.remoteAssetEntity}, + ) + .get(); - return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull(); + return rows.map((row) => row.read('album_id')).toList(); } Future getCount() { diff --git a/mobile/test/domain/services/album.service_test.dart b/mobile/test/domain/services/album.service_test.dart index 1a36a811c3..9110a09471 100644 --- a/mobile/test/domain/services/album.service_test.dart +++ b/mobile/test/domain/services/album.service_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; @@ -13,38 +14,6 @@ void main() { late DriftRemoteAlbumRepository mockRemoteAlbumRepo; late DriftAlbumApiRepository mockAlbumApiRepo; - setUp(() { - mockRemoteAlbumRepo = MockRemoteAlbumRepository(); - mockAlbumApiRepo = MockDriftAlbumApiRepository(); - sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo); - - when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) { - // Simulate a timestamp for the newest asset in the album - final albumID = invocation.positionalArguments[0] as String; - - if (albumID == '1') { - return Future.value(DateTime(2023, 1, 1)); - } else if (albumID == '2') { - return Future.value(DateTime(2023, 2, 1)); - } - - return Future.value(DateTime.fromMillisecondsSinceEpoch(0)); - }); - - when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) { - // Simulate a timestamp for the oldest asset in the album - final albumID = invocation.positionalArguments[0] as String; - - if (albumID == '1') { - return Future.value(DateTime(2019, 1, 1)); - } else if (albumID == '2') { - return Future.value(DateTime(2019, 2, 1)); - } - - return Future.value(DateTime.fromMillisecondsSinceEpoch(0)); - }); - }); - final albumA = RemoteAlbum( id: '1', name: 'Album A', @@ -73,6 +42,21 @@ void main() { isShared: false, ); + setUp(() { + mockRemoteAlbumRepo = MockRemoteAlbumRepository(); + mockAlbumApiRepo = MockDriftAlbumApiRepository(); + + when( + () => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.end), + ).thenAnswer((_) async => ['1', '2']); + + when( + () => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.start), + ).thenAnswer((_) async => ['1', '2']); + + sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo); + }); + group('sortAlbums', () { test('should sort correctly based on name', () async { final albums = [albumB, albumA]; diff --git a/mobile/test/infrastructure/repositories/remote_album_repository_test.dart b/mobile/test/infrastructure/repositories/remote_album_repository_test.dart new file mode 100644 index 0000000000..bc39d7bf5e --- /dev/null +++ b/mobile/test/infrastructure/repositories/remote_album_repository_test.dart @@ -0,0 +1,305 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.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/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; + +void main() { + late Drift db; + late DriftRemoteAlbumRepository repository; + + setUp(() { + db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + repository = DriftRemoteAlbumRepository(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('getSortedAlbumIds', () { + Future createUser(String userId, String name) async { + await db + .into(db.userEntity) + .insert( + UserEntityCompanion( + id: Value(userId), + name: Value(name), + email: Value('$userId@test.com'), + avatarColor: const Value(AvatarColor.primary), + ), + ); + } + + Future createAlbum(String albumId, String ownerId, String name) async { + await db + .into(db.remoteAlbumEntity) + .insert( + RemoteAlbumEntityCompanion( + id: Value(albumId), + name: Value(name), + ownerId: Value(ownerId), + createdAt: Value(DateTime.now()), + updatedAt: Value(DateTime.now()), + description: const Value(''), + isActivityEnabled: const Value(false), + order: const Value(AlbumAssetOrder.asc), + ), + ); + } + + Future createAsset(String assetId, String ownerId, DateTime createdAt) async { + await db + .into(db.remoteAssetEntity) + .insert( + RemoteAssetEntityCompanion( + id: Value(assetId), + checksum: Value('checksum-$assetId'), + name: Value('asset-$assetId'), + ownerId: Value(ownerId), + type: const Value(AssetType.image), + createdAt: Value(createdAt), + updatedAt: Value(createdAt), + localDateTime: Value(createdAt), + durationInSeconds: const Value(0), + height: const Value(1080), + width: const Value(1920), + visibility: const Value(AssetVisibility.timeline), + ), + ); + } + + Future linkAssetToAlbum(String albumId, String assetId) async { + await db + .into(db.remoteAlbumAssetEntity) + .insert(RemoteAlbumAssetEntityCompanion(albumId: Value(albumId), assetId: Value(assetId))); + } + + test('returns empty list when albumIds is empty', () async { + final result = await repository.getSortedAlbumIds([], aggregation: AssetDateAggregation.start); + + expect(result, isEmpty); + }); + + test('returns single album when only one album exists', () async { + const userId = 'user1'; + const albumId = 'album1'; + + await createUser(userId, 'Test User'); + await createAlbum(albumId, userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2024, 1, 1)); + await linkAssetToAlbum(albumId, 'asset1'); + + final result = await repository.getSortedAlbumIds([albumId], aggregation: AssetDateAggregation.start); + + expect(result, [albumId]); + }); + + test('sorts albums by start date (MIN) ascending', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + // Album 1: Assets from Jan 10 to Jan 20 (start: Jan 10) + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2024, 1, 10)); + await createAsset('asset2', userId, DateTime(2024, 1, 20)); + await linkAssetToAlbum('album1', 'asset1'); + await linkAssetToAlbum('album1', 'asset2'); + + // Album 2: Assets from Jan 5 to Jan 15 (start: Jan 5) + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset3', userId, DateTime(2024, 1, 5)); + await createAsset('asset4', userId, DateTime(2024, 1, 15)); + await linkAssetToAlbum('album2', 'asset3'); + await linkAssetToAlbum('album2', 'asset4'); + + // Album 3: Assets from Jan 25 to Jan 30 (start: Jan 25) + await createAlbum('album3', userId, 'Album 3'); + await createAsset('asset5', userId, DateTime(2024, 1, 25)); + await createAsset('asset6', userId, DateTime(2024, 1, 30)); + await linkAssetToAlbum('album3', 'asset5'); + await linkAssetToAlbum('album3', 'asset6'); + + final result = await repository.getSortedAlbumIds([ + 'album1', + 'album2', + 'album3', + ], aggregation: AssetDateAggregation.start); + + // Expected order: album2 (Jan 5), album1 (Jan 10), album3 (Jan 25) + expect(result, ['album2', 'album1', 'album3']); + }); + + test('sorts albums by end date (MAX) ascending', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + // Album 1: Assets from Jan 10 to Jan 20 (end: Jan 20) + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2024, 1, 10)); + await createAsset('asset2', userId, DateTime(2024, 1, 20)); + await linkAssetToAlbum('album1', 'asset1'); + await linkAssetToAlbum('album1', 'asset2'); + + // Album 2: Assets from Jan 5 to Jan 15 (end: Jan 15) + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset3', userId, DateTime(2024, 1, 5)); + await createAsset('asset4', userId, DateTime(2024, 1, 15)); + await linkAssetToAlbum('album2', 'asset3'); + await linkAssetToAlbum('album2', 'asset4'); + + // Album 3: Assets from Jan 25 to Jan 30 (end: Jan 30) + await createAlbum('album3', userId, 'Album 3'); + await createAsset('asset5', userId, DateTime(2024, 1, 25)); + await createAsset('asset6', userId, DateTime(2024, 1, 30)); + await linkAssetToAlbum('album3', 'asset5'); + await linkAssetToAlbum('album3', 'asset6'); + + final result = await repository.getSortedAlbumIds([ + 'album1', + 'album2', + 'album3', + ], aggregation: AssetDateAggregation.end); + + // Expected order: album2 (Jan 15), album1 (Jan 20), album3 (Jan 30) + expect(result, ['album2', 'album1', 'album3']); + }); + + test('handles albums with single asset', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2024, 1, 15)); + await linkAssetToAlbum('album1', 'asset1'); + + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset2', userId, DateTime(2024, 1, 10)); + await linkAssetToAlbum('album2', 'asset2'); + + final result = await repository.getSortedAlbumIds(['album1', 'album2'], aggregation: AssetDateAggregation.start); + + expect(result, ['album2', 'album1']); + }); + + test('only returns requested album IDs in the result', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + // Create 3 albums + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2024, 1, 10)); + await linkAssetToAlbum('album1', 'asset1'); + + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset2', userId, DateTime(2024, 1, 5)); + await linkAssetToAlbum('album2', 'asset2'); + + await createAlbum('album3', userId, 'Album 3'); + await createAsset('asset3', userId, DateTime(2024, 1, 15)); + await linkAssetToAlbum('album3', 'asset3'); + + // Only request album1 and album3 + final result = await repository.getSortedAlbumIds(['album1', 'album3'], aggregation: AssetDateAggregation.start); + + // Should only return album1 and album3, not album2 + expect(result, ['album1', 'album3']); + }); + + test('handles albums with same date correctly', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + final sameDate = DateTime(2024, 1, 10); + + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, sameDate); + await linkAssetToAlbum('album1', 'asset1'); + + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset2', userId, sameDate); + await linkAssetToAlbum('album2', 'asset2'); + + final result = await repository.getSortedAlbumIds(['album1', 'album2'], aggregation: AssetDateAggregation.start); + + // Both albums have the same date, so both should be returned + expect(result, hasLength(2)); + expect(result, containsAll(['album1', 'album2'])); + }); + + test('handles albums across different years', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + await createAlbum('album1', userId, 'Album 1'); + await createAsset('asset1', userId, DateTime(2023, 12, 25)); + await linkAssetToAlbum('album1', 'asset1'); + + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset2', userId, DateTime(2024, 1, 5)); + await linkAssetToAlbum('album2', 'asset2'); + + await createAlbum('album3', userId, 'Album 3'); + await createAsset('asset3', userId, DateTime(2025, 1, 1)); + await linkAssetToAlbum('album3', 'asset3'); + + final result = await repository.getSortedAlbumIds([ + 'album1', + 'album2', + 'album3', + ], aggregation: AssetDateAggregation.start); + + expect(result, ['album1', 'album2', 'album3']); + }); + + test('handles album with multiple assets correctly', () async { + const userId = 'user1'; + + await createUser(userId, 'Test User'); + + await createAlbum('album1', userId, 'Album 1'); + // Album 1 has 5 assets from Jan 5 to Jan 25 + await createAsset('asset1', userId, DateTime(2024, 1, 5)); + await createAsset('asset2', userId, DateTime(2024, 1, 10)); + await createAsset('asset3', userId, DateTime(2024, 1, 15)); + await createAsset('asset4', userId, DateTime(2024, 1, 20)); + await createAsset('asset5', userId, DateTime(2024, 1, 25)); + await linkAssetToAlbum('album1', 'asset1'); + await linkAssetToAlbum('album1', 'asset2'); + await linkAssetToAlbum('album1', 'asset3'); + await linkAssetToAlbum('album1', 'asset4'); + await linkAssetToAlbum('album1', 'asset5'); + + await createAlbum('album2', userId, 'Album 2'); + await createAsset('asset6', userId, DateTime(2024, 1, 1)); + await linkAssetToAlbum('album2', 'asset6'); + + final resultStart = await repository.getSortedAlbumIds([ + 'album1', + 'album2', + ], aggregation: AssetDateAggregation.start); + + // album2 (Jan 1) should come before album1 (Jan 5) + expect(resultStart, ['album2', 'album1']); + + final resultEnd = await repository.getSortedAlbumIds(['album1', 'album2'], aggregation: AssetDateAggregation.end); + + // album2 (Jan 1) should come before album1 (Jan 25) + expect(resultEnd, ['album2', 'album1']); + }); + }); +} From a469d350be46934b66fc7dc26143a7c3e2894e28 Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 23 Feb 2026 15:45:05 +0100 Subject: [PATCH 109/143] feat(mobile): prompt when deleting from trash (#26392) * feat(mobile): prompt when deleting from trash * refactor: use existing strings * chore: use type-safe translations * chore: remove old translation function --- .../delete_trash_action_button.widget.dart | 13 +++++ .../asset_grid/trash_delete_dialog.dart | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 mobile/lib/widgets/asset_grid/trash_delete_dialog.dart diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart index cb0e7091c8..0d9bc41734 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; /// This delete action has the following behavior: @@ -22,6 +23,18 @@ class DeleteTrashActionButton extends ConsumerWidget { return; } + final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length)); + + final confirmDelete = + await showDialog( + context: context, + builder: (context) => TrashDeleteDialog(count: selectCount), + ) ?? + false; + if (!confirmDelete) { + return; + } + final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source); ref.read(multiSelectProvider.notifier).reset(); diff --git a/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart b/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart new file mode 100644 index 0000000000..2e0fae76a3 --- /dev/null +++ b/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class TrashDeleteDialog extends StatelessWidget { + const TrashDeleteDialog({super.key, required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + title: Text(context.t.permanently_delete), + content: ImmichHtmlText(context.t.permanently_delete_assets_prompt(count: count)), + actions: [ + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton( + onPressed: () => context.pop(false), + style: FilledButton.styleFrom( + backgroundColor: context.colorScheme.surfaceDim, + foregroundColor: context.primaryColor, + ), + child: Text(context.t.cancel, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + height: 48, + + child: FilledButton( + onPressed: () => context.pop(true), + style: FilledButton.styleFrom( + backgroundColor: context.colorScheme.errorContainer, + foregroundColor: context.colorScheme.onErrorContainer, + ), + child: Text(context.t.delete, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ); + } +} From a07d7b0c82ea00f86f8058d406a490e1264709bb Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:50:16 +0100 Subject: [PATCH 110/143] chore: migrate to sql-tools library (#26400) Co-authored-by: Jason Rasmussen --- pnpm-lock.yaml | 25 + server/package.json | 1 + server/src/bin/migrations.ts | 8 +- server/src/commands/schema-check.ts | 2 +- server/src/decorators.ts | 2 +- server/src/dtos/env.dto.ts | 11 +- server/src/enum.ts | 8 - server/src/main.ts | 4 +- server/src/repositories/config.repository.ts | 3 +- .../src/repositories/database.repository.ts | 5 +- server/src/schema/enums.ts | 2 +- server/src/schema/functions.ts | 2 +- server/src/schema/index.ts | 2 +- server/src/schema/tables/activity.table.ts | 12 +- .../schema/tables/album-asset-audit.table.ts | 2 +- server/src/schema/tables/album-asset.table.ts | 10 +- server/src/schema/tables/album-audit.table.ts | 2 +- .../schema/tables/album-user-audit.table.ts | 2 +- server/src/schema/tables/album-user.table.ts | 12 +- server/src/schema/tables/album.table.ts | 12 +- server/src/schema/tables/api-key.table.ts | 8 +- server/src/schema/tables/asset-audit.table.ts | 2 +- server/src/schema/tables/asset-edit.table.ts | 8 +- server/src/schema/tables/asset-exif.table.ts | 2 +- .../schema/tables/asset-face-audit.table.ts | 2 +- server/src/schema/tables/asset-face.table.ts | 14 +- server/src/schema/tables/asset-file.table.ts | 8 +- .../schema/tables/asset-job-status.table.ts | 2 +- .../tables/asset-metadata-audit.table.ts | 2 +- .../src/schema/tables/asset-metadata.table.ts | 10 +- server/src/schema/tables/asset-ocr.table.ts | 2 +- server/src/schema/tables/asset.table.ts | 16 +- server/src/schema/tables/audit.table.ts | 2 +- server/src/schema/tables/face-search.table.ts | 2 +- .../src/schema/tables/geodata-places.table.ts | 2 +- server/src/schema/tables/library.table.ts | 6 +- .../schema/tables/memory-asset-audit.table.ts | 2 +- .../src/schema/tables/memory-asset.table.ts | 10 +- .../src/schema/tables/memory-audit.table.ts | 2 +- server/src/schema/tables/memory.table.ts | 10 +- server/src/schema/tables/move.table.ts | 2 +- .../tables/natural-earth-countries.table.ts | 2 +- .../src/schema/tables/notification.table.ts | 8 +- server/src/schema/tables/ocr-search.table.ts | 2 +- .../src/schema/tables/partner-audit.table.ts | 2 +- server/src/schema/tables/partner.table.ts | 8 +- .../src/schema/tables/person-audit.table.ts | 2 +- server/src/schema/tables/person.table.ts | 10 +- server/src/schema/tables/plugin.table.ts | 4 +- server/src/schema/tables/session.table.ts | 6 +- .../schema/tables/shared-link-asset.table.ts | 2 +- server/src/schema/tables/shared-link.table.ts | 8 +- .../src/schema/tables/smart-search.table.ts | 2 +- server/src/schema/tables/stack-audit.table.ts | 2 +- server/src/schema/tables/stack.table.ts | 10 +- .../schema/tables/sync-checkpoint.table.ts | 8 +- .../schema/tables/system-metadata.table.ts | 2 +- server/src/schema/tables/tag-asset.table.ts | 2 +- server/src/schema/tables/tag-closure.table.ts | 2 +- server/src/schema/tables/tag.table.ts | 6 +- server/src/schema/tables/user-audit.table.ts | 2 +- .../tables/user-metadata-audit.table.ts | 2 +- .../src/schema/tables/user-metadata.table.ts | 10 +- server/src/schema/tables/user.table.ts | 10 +- .../schema/tables/version-history.table.ts | 2 +- server/src/schema/tables/workflow.table.ts | 8 +- server/src/services/cli.service.ts | 2 +- .../comparers/column.comparer.spec.ts | 99 --- .../sql-tools/comparers/column.comparer.ts | 108 --- .../comparers/constraint.comparer.spec.ts | 63 -- .../comparers/constraint.comparer.ts | 165 ----- .../sql-tools/comparers/enum.comparer.spec.ts | 54 -- .../src/sql-tools/comparers/enum.comparer.ts | 38 - .../comparers/extension.comparer.spec.ts | 37 - .../sql-tools/comparers/extension.comparer.ts | 22 - .../comparers/function.comparer.spec.ts | 53 -- .../sql-tools/comparers/function.comparer.ts | 32 - .../comparers/index.comparer.spec.ts | 72 -- .../src/sql-tools/comparers/index.comparer.ts | 62 -- .../comparers/override.comparer.spec.ts | 69 -- .../sql-tools/comparers/override.comparer.ts | 29 - .../comparers/parameter.comparer.spec.ts | 44 -- .../sql-tools/comparers/parameter.comparer.ts | 23 - .../comparers/table.comparer.spec.ts | 44 -- .../src/sql-tools/comparers/table.comparer.ts | 31 - .../comparers/trigger.comparer.spec.ts | 88 --- .../sql-tools/comparers/trigger.comparer.ts | 41 -- server/src/sql-tools/contexts/base-context.ts | 104 --- .../sql-tools/contexts/processor-context.ts | 71 -- .../src/sql-tools/contexts/reader-context.ts | 8 - .../decorators/after-delete.decorator.ts | 8 - .../decorators/after-insert.decorator.ts | 8 - .../decorators/before-update.decorator.ts | 8 - .../sql-tools/decorators/check.decorator.ts | 11 - .../sql-tools/decorators/column.decorator.ts | 32 - .../configuration-parameter.decorator.ts | 14 - .../create-date-column.decorator.ts | 9 - .../decorators/database.decorator.ts | 10 - .../delete-date-column.decorator.ts | 9 - .../decorators/extension.decorator.ts | 11 - .../decorators/extensions.decorator.ts | 15 - .../foreign-key-column.decorator.ts | 16 - .../foreign-key-constraint.decorator.ts | 23 - .../decorators/generated-column.decorator.ts | 37 - .../sql-tools/decorators/index.decorator.ts | 17 - .../decorators/primary-column.decorator.ts | 3 - .../primary-generated-column.decorator.ts | 4 - .../sql-tools/decorators/table.decorator.ts | 14 - .../decorators/trigger-function.decorator.ts | 10 - .../sql-tools/decorators/trigger.decorator.ts | 19 - .../sql-tools/decorators/unique.decorator.ts | 11 - .../update-date-column.decorator.ts | 9 - server/src/sql-tools/helpers.ts | 247 ------- server/src/sql-tools/index.ts | 1 - server/src/sql-tools/naming/default.naming.ts | 50 -- server/src/sql-tools/naming/hash.naming.ts | 51 -- .../src/sql-tools/naming/naming.interface.ts | 59 -- .../processors/check-constraint.processor.ts | 23 - .../sql-tools/processors/column.processor.ts | 55 -- .../configuration-parameter.processor.ts | 16 - .../processors/database.processor.ts | 9 - .../sql-tools/processors/enum.processor.ts | 8 - .../processors/extension.processor.ts | 16 - .../foreign-key-column.processor.ts | 67 -- .../foreign-key-constraint.processor.ts | 95 --- .../processors/function.processor.ts | 12 - .../sql-tools/processors/index.processor.ts | 89 --- server/src/sql-tools/processors/index.ts | 34 - .../processors/override.processor.ts | 50 -- .../primary-key-contraint.processor.ts | 30 - .../sql-tools/processors/table.processor.ts | 27 - .../sql-tools/processors/trigger.processor.ts | 37 - .../processors/unique-constraint.processor.ts | 60 -- server/src/sql-tools/public_api.ts | 31 - server/src/sql-tools/readers/column.reader.ts | 120 --- .../src/sql-tools/readers/comment.reader.ts | 36 - .../sql-tools/readers/constraint.reader.ts | 143 ---- .../src/sql-tools/readers/extension.reader.ts | 14 - .../src/sql-tools/readers/function.reader.ts | 27 - server/src/sql-tools/readers/index.reader.ts | 58 -- server/src/sql-tools/readers/index.ts | 26 - server/src/sql-tools/readers/name.reader.ts | 8 - .../src/sql-tools/readers/override.reader.ts | 19 - .../src/sql-tools/readers/parameter.reader.ts | 20 - server/src/sql-tools/readers/table.reader.ts | 22 - .../src/sql-tools/readers/trigger.reader.ts | 86 --- server/src/sql-tools/register-enum.ts | 20 - server/src/sql-tools/register-function.ts | 58 -- server/src/sql-tools/register-item.ts | 31 - server/src/sql-tools/register.ts | 11 - server/src/sql-tools/schema-diff.spec.ts | 689 ------------------ server/src/sql-tools/schema-diff.ts | 234 ------ server/src/sql-tools/schema-from-code.spec.ts | 57 -- server/src/sql-tools/schema-from-code.ts | 62 -- server/src/sql-tools/schema-from-database.ts | 36 - .../transformers/column.transformer.spec.ts | 147 ---- .../transformers/column.transformer.ts | 55 -- .../constraint.transformer.spec.ts | 99 --- .../transformers/constraint.transformer.ts | 58 -- .../transformers/enum.transformer.ts | 26 - .../extension.transformer.spec.ts | 34 - .../transformers/extension.transformer.ts | 26 - .../transformers/function.transformer.spec.ts | 19 - .../transformers/function.transformer.ts | 26 - .../transformers/index.transformer.spec.ts | 103 --- .../transformers/index.transformer.ts | 56 -- server/src/sql-tools/transformers/index.ts | 24 - .../transformers/override.transformer.ts | 37 - .../transformers/parameter.transformer.ts | 33 - .../transformers/table.transformer.spec.ts | 227 ------ .../transformers/table.transformer.ts | 62 -- .../transformers/trigger.transformer.spec.ts | 94 --- .../transformers/trigger.transformer.ts | 52 -- server/src/sql-tools/transformers/types.ts | 4 - server/src/sql-tools/types.ts | 538 -------------- server/src/types.ts | 18 - server/src/utils/database.spec.ts | 83 --- server/src/utils/database.ts | 81 +- .../check-constraint-default-name.stub.ts | 48 -- .../check-constraint-override-name.stub.ts | 48 -- .../test/sql-tools/column-create-date.stub.ts | 40 - .../sql-tools/column-default-array.stub.ts | 40 - .../sql-tools/column-default-boolean.stub.ts | 40 - .../sql-tools/column-default-date.stub.ts | 42 -- .../sql-tools/column-default-function.stub.ts | 40 - .../sql-tools/column-default-null.stub.ts | 39 - .../sql-tools/column-default-number.stub.ts | 40 - .../sql-tools/column-default-string.stub.ts | 40 - .../test/sql-tools/column-delete-date.stub.ts | 39 - .../test/sql-tools/column-enum-type.stub.ts | 53 -- .../sql-tools/column-generated-identity.ts | 48 -- .../sql-tools/column-generated-uuid.stub.ts | 48 -- .../sql-tools/column-index-name-default.ts | 47 -- server/test/sql-tools/column-index-name.ts | 47 -- .../column-inferred-nullable.stub.ts | 39 - .../sql-tools/column-name-default.stub.ts | 39 - .../sql-tools/column-name-override.stub.ts | 39 - .../test/sql-tools/column-name-string.stub.ts | 39 - server/test/sql-tools/column-nullable.stub.ts | 39 - .../sql-tools/column-string-length.stub.ts | 40 - ...umn-unique-constraint-name-default.stub.ts | 47 -- ...mn-unique-constraint-name-override.stub.ts | 47 -- .../test/sql-tools/column-update-date.stub.ts | 40 - .../errors/table-duplicate-decorator.stub.ts | 7 - ...oreign-key-constraint-column-order.stub.ts | 118 --- ...eign-key-constraint-missing-column.stub.ts | 72 -- ...onstraint-missing-reference-column.stub.ts | 72 -- ...constraint-missing-reference-table.stub.ts | 45 -- ...gn-key-constraint-multiple-columns.stub.ts | 114 --- .../foreign-key-constraint-no-index.stub.ts | 82 --- .../foreign-key-constraint-no-primary.stub.ts | 86 --- .../sql-tools/foreign-key-constraint.stub.ts | 90 --- .../foreign-key-inferred-type.stub.ts | 89 --- ...foreign-key-with-unique-constraint.stub.ts | 96 --- .../test/sql-tools/index-name-default.stub.ts | 48 -- .../sql-tools/index-name-override.stub.ts | 48 -- .../test/sql-tools/index-with-expression.ts | 48 -- .../test/sql-tools/index-with-where.stub.ts | 49 -- ...rimary-key-constraint-name-default.stub.ts | 47 -- ...imary-key-constraint-name-override.stub.ts | 47 -- .../test/sql-tools/table-name-default.stub.ts | 26 - .../sql-tools/table-name-override.stub.ts | 26 - .../table-name-string-option.stub.ts | 26 - .../sql-tools/trigger-after-delete.stub.ts | 47 -- .../sql-tools/trigger-before-update.stub.ts | 47 -- .../sql-tools/trigger-name-default.stub.ts | 42 -- .../sql-tools/trigger-name-override.stub.ts | 43 -- .../unique-constraint-name-default.stub.ts | 48 -- .../unique-constraint-name-override.stub.ts | 48 -- server/test/utils.ts | 13 +- server/tsconfig.json | 2 +- 231 files changed, 209 insertions(+), 9151 deletions(-) delete mode 100644 server/src/sql-tools/comparers/column.comparer.spec.ts delete mode 100644 server/src/sql-tools/comparers/column.comparer.ts delete mode 100644 server/src/sql-tools/comparers/constraint.comparer.spec.ts delete mode 100644 server/src/sql-tools/comparers/constraint.comparer.ts delete mode 100644 server/src/sql-tools/comparers/enum.comparer.spec.ts delete mode 100644 server/src/sql-tools/comparers/enum.comparer.ts delete mode 100644 server/src/sql-tools/comparers/extension.comparer.spec.ts delete mode 100644 server/src/sql-tools/comparers/extension.comparer.ts delete mode 100644 server/src/sql-tools/comparers/function.comparer.spec.ts delete mode 100644 server/src/sql-tools/comparers/function.comparer.ts delete mode 100644 server/src/sql-tools/comparers/index.comparer.spec.ts delete mode 100644 server/src/sql-tools/comparers/index.comparer.ts delete mode 100644 server/src/sql-tools/comparers/override.comparer.spec.ts delete mode 100644 server/src/sql-tools/comparers/override.comparer.ts delete mode 100644 server/src/sql-tools/comparers/parameter.comparer.spec.ts delete mode 100644 server/src/sql-tools/comparers/parameter.comparer.ts delete mode 100644 server/src/sql-tools/comparers/table.comparer.spec.ts delete mode 100644 server/src/sql-tools/comparers/table.comparer.ts delete mode 100644 server/src/sql-tools/comparers/trigger.comparer.spec.ts delete mode 100644 server/src/sql-tools/comparers/trigger.comparer.ts delete mode 100644 server/src/sql-tools/contexts/base-context.ts delete mode 100644 server/src/sql-tools/contexts/processor-context.ts delete mode 100644 server/src/sql-tools/contexts/reader-context.ts delete mode 100644 server/src/sql-tools/decorators/after-delete.decorator.ts delete mode 100644 server/src/sql-tools/decorators/after-insert.decorator.ts delete mode 100644 server/src/sql-tools/decorators/before-update.decorator.ts delete mode 100644 server/src/sql-tools/decorators/check.decorator.ts delete mode 100644 server/src/sql-tools/decorators/column.decorator.ts delete mode 100644 server/src/sql-tools/decorators/configuration-parameter.decorator.ts delete mode 100644 server/src/sql-tools/decorators/create-date-column.decorator.ts delete mode 100644 server/src/sql-tools/decorators/database.decorator.ts delete mode 100644 server/src/sql-tools/decorators/delete-date-column.decorator.ts delete mode 100644 server/src/sql-tools/decorators/extension.decorator.ts delete mode 100644 server/src/sql-tools/decorators/extensions.decorator.ts delete mode 100644 server/src/sql-tools/decorators/foreign-key-column.decorator.ts delete mode 100644 server/src/sql-tools/decorators/foreign-key-constraint.decorator.ts delete mode 100644 server/src/sql-tools/decorators/generated-column.decorator.ts delete mode 100644 server/src/sql-tools/decorators/index.decorator.ts delete mode 100644 server/src/sql-tools/decorators/primary-column.decorator.ts delete mode 100644 server/src/sql-tools/decorators/primary-generated-column.decorator.ts delete mode 100644 server/src/sql-tools/decorators/table.decorator.ts delete mode 100644 server/src/sql-tools/decorators/trigger-function.decorator.ts delete mode 100644 server/src/sql-tools/decorators/trigger.decorator.ts delete mode 100644 server/src/sql-tools/decorators/unique.decorator.ts delete mode 100644 server/src/sql-tools/decorators/update-date-column.decorator.ts delete mode 100644 server/src/sql-tools/helpers.ts delete mode 100644 server/src/sql-tools/index.ts delete mode 100644 server/src/sql-tools/naming/default.naming.ts delete mode 100644 server/src/sql-tools/naming/hash.naming.ts delete mode 100644 server/src/sql-tools/naming/naming.interface.ts delete mode 100644 server/src/sql-tools/processors/check-constraint.processor.ts delete mode 100644 server/src/sql-tools/processors/column.processor.ts delete mode 100644 server/src/sql-tools/processors/configuration-parameter.processor.ts delete mode 100644 server/src/sql-tools/processors/database.processor.ts delete mode 100644 server/src/sql-tools/processors/enum.processor.ts delete mode 100644 server/src/sql-tools/processors/extension.processor.ts delete mode 100644 server/src/sql-tools/processors/foreign-key-column.processor.ts delete mode 100644 server/src/sql-tools/processors/foreign-key-constraint.processor.ts delete mode 100644 server/src/sql-tools/processors/function.processor.ts delete mode 100644 server/src/sql-tools/processors/index.processor.ts delete mode 100644 server/src/sql-tools/processors/index.ts delete mode 100644 server/src/sql-tools/processors/override.processor.ts delete mode 100644 server/src/sql-tools/processors/primary-key-contraint.processor.ts delete mode 100644 server/src/sql-tools/processors/table.processor.ts delete mode 100644 server/src/sql-tools/processors/trigger.processor.ts delete mode 100644 server/src/sql-tools/processors/unique-constraint.processor.ts delete mode 100644 server/src/sql-tools/public_api.ts delete mode 100644 server/src/sql-tools/readers/column.reader.ts delete mode 100644 server/src/sql-tools/readers/comment.reader.ts delete mode 100644 server/src/sql-tools/readers/constraint.reader.ts delete mode 100644 server/src/sql-tools/readers/extension.reader.ts delete mode 100644 server/src/sql-tools/readers/function.reader.ts delete mode 100644 server/src/sql-tools/readers/index.reader.ts delete mode 100644 server/src/sql-tools/readers/index.ts delete mode 100644 server/src/sql-tools/readers/name.reader.ts delete mode 100644 server/src/sql-tools/readers/override.reader.ts delete mode 100644 server/src/sql-tools/readers/parameter.reader.ts delete mode 100644 server/src/sql-tools/readers/table.reader.ts delete mode 100644 server/src/sql-tools/readers/trigger.reader.ts delete mode 100644 server/src/sql-tools/register-enum.ts delete mode 100644 server/src/sql-tools/register-function.ts delete mode 100644 server/src/sql-tools/register-item.ts delete mode 100644 server/src/sql-tools/register.ts delete mode 100644 server/src/sql-tools/schema-diff.spec.ts delete mode 100644 server/src/sql-tools/schema-diff.ts delete mode 100644 server/src/sql-tools/schema-from-code.spec.ts delete mode 100644 server/src/sql-tools/schema-from-code.ts delete mode 100644 server/src/sql-tools/schema-from-database.ts delete mode 100644 server/src/sql-tools/transformers/column.transformer.spec.ts delete mode 100644 server/src/sql-tools/transformers/column.transformer.ts delete mode 100644 server/src/sql-tools/transformers/constraint.transformer.spec.ts delete mode 100644 server/src/sql-tools/transformers/constraint.transformer.ts delete mode 100644 server/src/sql-tools/transformers/enum.transformer.ts delete mode 100644 server/src/sql-tools/transformers/extension.transformer.spec.ts delete mode 100644 server/src/sql-tools/transformers/extension.transformer.ts delete mode 100644 server/src/sql-tools/transformers/function.transformer.spec.ts delete mode 100644 server/src/sql-tools/transformers/function.transformer.ts delete mode 100644 server/src/sql-tools/transformers/index.transformer.spec.ts delete mode 100644 server/src/sql-tools/transformers/index.transformer.ts delete mode 100644 server/src/sql-tools/transformers/index.ts delete mode 100644 server/src/sql-tools/transformers/override.transformer.ts delete mode 100644 server/src/sql-tools/transformers/parameter.transformer.ts delete mode 100644 server/src/sql-tools/transformers/table.transformer.spec.ts delete mode 100644 server/src/sql-tools/transformers/table.transformer.ts delete mode 100644 server/src/sql-tools/transformers/trigger.transformer.spec.ts delete mode 100644 server/src/sql-tools/transformers/trigger.transformer.ts delete mode 100644 server/src/sql-tools/transformers/types.ts delete mode 100644 server/src/sql-tools/types.ts delete mode 100644 server/src/utils/database.spec.ts delete mode 100644 server/test/sql-tools/check-constraint-default-name.stub.ts delete mode 100644 server/test/sql-tools/check-constraint-override-name.stub.ts delete mode 100644 server/test/sql-tools/column-create-date.stub.ts delete mode 100644 server/test/sql-tools/column-default-array.stub.ts delete mode 100644 server/test/sql-tools/column-default-boolean.stub.ts delete mode 100644 server/test/sql-tools/column-default-date.stub.ts delete mode 100644 server/test/sql-tools/column-default-function.stub.ts delete mode 100644 server/test/sql-tools/column-default-null.stub.ts delete mode 100644 server/test/sql-tools/column-default-number.stub.ts delete mode 100644 server/test/sql-tools/column-default-string.stub.ts delete mode 100644 server/test/sql-tools/column-delete-date.stub.ts delete mode 100644 server/test/sql-tools/column-enum-type.stub.ts delete mode 100644 server/test/sql-tools/column-generated-identity.ts delete mode 100644 server/test/sql-tools/column-generated-uuid.stub.ts delete mode 100644 server/test/sql-tools/column-index-name-default.ts delete mode 100644 server/test/sql-tools/column-index-name.ts delete mode 100644 server/test/sql-tools/column-inferred-nullable.stub.ts delete mode 100644 server/test/sql-tools/column-name-default.stub.ts delete mode 100644 server/test/sql-tools/column-name-override.stub.ts delete mode 100644 server/test/sql-tools/column-name-string.stub.ts delete mode 100644 server/test/sql-tools/column-nullable.stub.ts delete mode 100644 server/test/sql-tools/column-string-length.stub.ts delete mode 100644 server/test/sql-tools/column-unique-constraint-name-default.stub.ts delete mode 100644 server/test/sql-tools/column-unique-constraint-name-override.stub.ts delete mode 100644 server/test/sql-tools/column-update-date.stub.ts delete mode 100644 server/test/sql-tools/errors/table-duplicate-decorator.stub.ts delete mode 100644 server/test/sql-tools/foreign-key-constraint-column-order.stub.ts delete mode 100644 server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts delete mode 100644 server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts delete mode 100644 server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts delete mode 100644 server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts delete mode 100644 server/test/sql-tools/foreign-key-constraint-no-index.stub.ts delete mode 100644 server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts delete mode 100644 server/test/sql-tools/foreign-key-constraint.stub.ts delete mode 100644 server/test/sql-tools/foreign-key-inferred-type.stub.ts delete mode 100644 server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts delete mode 100644 server/test/sql-tools/index-name-default.stub.ts delete mode 100644 server/test/sql-tools/index-name-override.stub.ts delete mode 100644 server/test/sql-tools/index-with-expression.ts delete mode 100644 server/test/sql-tools/index-with-where.stub.ts delete mode 100644 server/test/sql-tools/primary-key-constraint-name-default.stub.ts delete mode 100644 server/test/sql-tools/primary-key-constraint-name-override.stub.ts delete mode 100644 server/test/sql-tools/table-name-default.stub.ts delete mode 100644 server/test/sql-tools/table-name-override.stub.ts delete mode 100644 server/test/sql-tools/table-name-string-option.stub.ts delete mode 100644 server/test/sql-tools/trigger-after-delete.stub.ts delete mode 100644 server/test/sql-tools/trigger-before-update.stub.ts delete mode 100644 server/test/sql-tools/trigger-name-default.stub.ts delete mode 100644 server/test/sql-tools/trigger-name-override.stub.ts delete mode 100644 server/test/sql-tools/unique-constraint-name-default.stub.ts delete mode 100644 server/test/sql-tools/unique-constraint-name-override.stub.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e8f0c84b3..9ce9caddf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -343,6 +343,9 @@ importers: '@extism/extism': specifier: 2.0.0-rc13 version: 2.0.0-rc13 + '@immich/sql-tools': + specifier: ^0.2.0 + version: 0.2.0 '@nestjs/bullmq': specifier: ^11.0.1 version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0) @@ -3013,6 +3016,9 @@ packages: '@immich/justified-layout-wasm@0.4.3': resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==} + '@immich/sql-tools@0.2.0': + resolution: {integrity: sha512-AH0GRIUYrckNKuid5uO33vgRbGaznhRtArdQ91K310A1oUFjaoNzOaZyZhXwEmft3WYeC1bx4fdgUeois2QH5A==} + '@immich/svelte-markdown-preprocess@0.2.1': resolution: {integrity: sha512-mbr/g75lO8Zh+ELCuYrZP0XB4gf2UbK8rJcGYMYxFJJzMMunV+sm9FqtV1dbwW2dpXzCZGz1XPCEZ6oo526TbA==} peerDependencies: @@ -8291,6 +8297,10 @@ packages: postgres: optional: true + kysely@0.28.11: + resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} + engines: {node: '>=20.0.0'} + kysely@0.28.2: resolution: {integrity: sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A==} engines: {node: '>=18.0.0'} @@ -14812,6 +14822,13 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} + '@immich/sql-tools@0.2.0': + dependencies: + kysely: 0.28.11 + kysely-postgres-js: 3.0.0(kysely@0.28.11)(postgres@3.4.8) + pg-connection-string: 2.11.0 + postgres: 3.4.8 + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.51.5)': dependencies: front-matter: 4.0.2 @@ -20822,12 +20839,20 @@ snapshots: type-is: 2.0.1 vary: 1.1.2 + kysely-postgres-js@3.0.0(kysely@0.28.11)(postgres@3.4.8): + dependencies: + kysely: 0.28.11 + optionalDependencies: + postgres: 3.4.8 + kysely-postgres-js@3.0.0(kysely@0.28.2)(postgres@3.4.8): dependencies: kysely: 0.28.2 optionalDependencies: postgres: 3.4.8 + kysely@0.28.11: {} + kysely@0.28.2: {} langium@3.3.1: diff --git a/server/package.json b/server/package.json index fa10f8bd1a..4095313a7c 100644 --- a/server/package.json +++ b/server/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@extism/extism": "2.0.0-rc13", + "@immich/sql-tools": "^0.2.0", "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", "@nestjs/core": "^11.0.4", diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts index 588f358023..bfa0f1733c 100644 --- a/server/src/bin/migrations.ts +++ b/server/src/bin/migrations.ts @@ -1,16 +1,15 @@ #!/usr/bin/env node process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich'; +import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools'; import { Kysely, sql } from 'kysely'; 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'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import 'src/schema'; -import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools'; -import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database'; +import { getKyselyConfig } from 'src/utils/database'; const main = async () => { const command = process.argv[2]; @@ -130,10 +129,9 @@ const create = (path: string, up: string[], down: string[]) => { const compare = async () => { const configRepository = new ConfigRepository(); const { database } = configRepository.getEnv(); - const db = postgres(asPostgresConnectionConfig(database.config)); const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); - const target = await schemaFromDatabase(db, {}); + const target = await schemaFromDatabase({ connection: database.config }); console.log(source.warnings.join('\n')); diff --git a/server/src/commands/schema-check.ts b/server/src/commands/schema-check.ts index c6e90fd9ca..e0ccae8469 100644 --- a/server/src/commands/schema-check.ts +++ b/server/src/commands/schema-check.ts @@ -1,7 +1,7 @@ +import { asHuman } from '@immich/sql-tools'; import { Command, CommandRunner } from 'nest-commander'; import { ErrorMessages } from 'src/constants'; import { CliService } from 'src/services/cli.service'; -import { asHuman } from 'src/sql-tools/schema-diff'; @Command({ name: 'schema-check', diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 87a3900a7f..695adb4a36 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,10 +1,10 @@ +import { BeforeUpdateTrigger, Column, ColumnOptions } from '@immich/sql-tools'; import { SetMetadata, applyDecorators } from '@nestjs/common'; import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; 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'; import { setUnion } from 'src/utils/set'; const GeneratedUuidV7Column = (options: Omit = {}) => diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index e088a33413..b04366c273 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -1,8 +1,17 @@ import { Transform, Type } from 'class-transformer'; import { IsEnum, IsInt, IsString, Matches } from 'class-validator'; -import { DatabaseSslMode, ImmichEnvironment, LogFormat, LogLevel } from 'src/enum'; +import { ImmichEnvironment, LogFormat, LogLevel } from 'src/enum'; import { IsIPRange, Optional, ValidateBoolean } from 'src/validation'; +// TODO import from sql-tools once the swagger plugin supports external enums +enum DatabaseSslMode { + Disable = 'disable', + Allow = 'allow', + Prefer = 'prefer', + Require = 'require', + VerifyFull = 'verify-full', +} + export class EnvDto { @IsInt() @Optional() diff --git a/server/src/enum.ts b/server/src/enum.ts index 8f509754da..44b2f564ab 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -821,14 +821,6 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretBasic = 'client_secret_basic', } -export enum DatabaseSslMode { - Disable = 'disable', - Allow = 'allow', - Prefer = 'prefer', - Require = 'require', - VerifyFull = 'verify-full', -} - export enum AssetVisibility { Archive = 'archive', Timeline = 'timeline', diff --git a/server/src/main.ts b/server/src/main.ts index a8e3178a43..f2491f07bc 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -52,9 +52,9 @@ class Workers { try { const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode); return value?.isMaintenanceMode || false; - } catch (error) { + } catch (error: Error | any) { // Table doesn't exist (migrations haven't run yet) - if (error instanceof PostgresError && error.code === '42P01') { + if ((error as PostgresError).code === '42P01') { return false; } diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 54a5d1987f..957a308e7d 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -1,3 +1,4 @@ +import { DatabaseConnectionParams } from '@immich/sql-tools'; import { RegisterQueueOptions } from '@nestjs/bullmq'; import { Inject, Injectable, Optional } from '@nestjs/common'; import { QueueOptions } from 'bullmq'; @@ -21,7 +22,7 @@ import { LogLevel, QueueName, } from 'src/enum'; -import { DatabaseConnectionParams, VectorExtension } from 'src/types'; +import { VectorExtension } from 'src/types'; import { setDifference } from 'src/utils/set'; export interface EnvData { diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 650820b18e..06bdef5abf 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -1,3 +1,4 @@ +import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools'; import { Injectable } from '@nestjs/common'; import AsyncLock from 'async-lock'; import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely'; @@ -21,7 +22,6 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode import { DB } from 'src/schema'; -import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools'; import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; import { vectorIndexQuery } from 'src/utils/database'; import { isValidInteger } from 'src/validation'; @@ -289,7 +289,8 @@ export class DatabaseRepository { async getSchemaDrift() { const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); - const target = await schemaFromDatabase(this.db, {}); + const { database } = this.configRepository.getEnv(); + const target = await schemaFromDatabase({ connection: database.config }); const drift = schemaDiff(source, target, { tables: { ignoreExtra: true }, diff --git a/server/src/schema/enums.ts b/server/src/schema/enums.ts index a1134df6bc..c68f152779 100644 --- a/server/src/schema/enums.ts +++ b/server/src/schema/enums.ts @@ -1,5 +1,5 @@ +import { registerEnum } from '@immich/sql-tools'; import { AssetStatus, AssetVisibility, SourceType } from 'src/enum'; -import { registerEnum } from 'src/sql-tools'; export const assets_status_enum = registerEnum({ name: 'assets_status_enum', diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index d7dabfef4c..6acfc45750 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -1,4 +1,4 @@ -import { registerFunction } from 'src/sql-tools'; +import { registerFunction } from '@immich/sql-tools'; export const immich_uuid_v7 = registerFunction({ name: 'immich_uuid_v7', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 4dc3d40312..790973785f 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -1,3 +1,4 @@ +import { Database, Extensions, Generated, Int8 } from '@immich/sql-tools'; import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; import { album_delete_audit, @@ -72,7 +73,6 @@ 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']) @Database({ name: 'immich' }) diff --git a/server/src/schema/tables/activity.table.ts b/server/src/schema/tables/activity.table.ts index dfa7c98e42..4a3cc196ee 100644 --- a/server/src/schema/tables/activity.table.ts +++ b/server/src/schema/tables/activity.table.ts @@ -1,8 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; -import { AlbumTable } from 'src/schema/tables/album.table'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { Check, Column, @@ -15,7 +10,12 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('activity') @UpdatedAtTrigger('activity_updatedAt') diff --git a/server/src/schema/tables/album-asset-audit.table.ts b/server/src/schema/tables/album-asset-audit.table.ts index ab8fd9ae89..176d32575a 100644 --- a/server/src/schema/tables/album-asset-audit.table.ts +++ b/server/src/schema/tables/album-asset-audit.table.ts @@ -1,6 +1,6 @@ +import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; import { AlbumTable } from 'src/schema/tables/album.table'; -import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('album_asset_audit') export class AlbumAssetAuditTable { diff --git a/server/src/schema/tables/album-asset.table.ts b/server/src/schema/tables/album-asset.table.ts index dea271239b..5853e846f1 100644 --- a/server/src/schema/tables/album-asset.table.ts +++ b/server/src/schema/tables/album-asset.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { album_asset_delete_audit } from 'src/schema/functions'; -import { AlbumTable } from 'src/schema/tables/album.table'; -import { AssetTable } from 'src/schema/tables/asset.table'; import { AfterDeleteTrigger, CreateDateColumn, @@ -10,7 +6,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { album_asset_delete_audit } from 'src/schema/functions'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; @Table({ name: 'album_asset' }) @UpdatedAtTrigger('album_asset_updatedAt') diff --git a/server/src/schema/tables/album-audit.table.ts b/server/src/schema/tables/album-audit.table.ts index 432c51c36a..7865f6bfa8 100644 --- a/server/src/schema/tables/album-audit.table.ts +++ b/server/src/schema/tables/album-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('album_audit') export class AlbumAuditTable { diff --git a/server/src/schema/tables/album-user-audit.table.ts b/server/src/schema/tables/album-user-audit.table.ts index 2259511bdd..d4798761e0 100644 --- a/server/src/schema/tables/album-user-audit.table.ts +++ b/server/src/schema/tables/album-user-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('album_user_audit') export class AlbumUserAuditTable { diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 761aabc1af..2e38041daf 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -1,8 +1,3 @@ -import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AlbumUserRole } from 'src/enum'; -import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions'; -import { AlbumTable } from 'src/schema/tables/album.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, AfterInsertTrigger, @@ -13,7 +8,12 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AlbumUserRole } from 'src/enum'; +import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table({ name: 'album_user' }) // Pre-existing indices from original album <--> user ManyToMany mapping diff --git a/server/src/schema/tables/album.table.ts b/server/src/schema/tables/album.table.ts index 5628db3d03..81b846c0f4 100644 --- a/server/src/schema/tables/album.table.ts +++ b/server/src/schema/tables/album.table.ts @@ -1,8 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AssetOrder } from 'src/enum'; -import { album_delete_audit } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, @@ -14,7 +9,12 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetOrder } from 'src/enum'; +import { album_delete_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table({ name: 'album' }) @UpdatedAtTrigger('album_updatedAt') diff --git a/server/src/schema/tables/api-key.table.ts b/server/src/schema/tables/api-key.table.ts index efbf18afaa..6cb4d5026e 100644 --- a/server/src/schema/tables/api-key.table.ts +++ b/server/src/schema/tables/api-key.table.ts @@ -1,6 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { Permission } from 'src/enum'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -10,7 +7,10 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { Permission } from 'src/enum'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('api_key') @UpdatedAtTrigger('api_key_updatedAt') diff --git a/server/src/schema/tables/asset-audit.table.ts b/server/src/schema/tables/asset-audit.table.ts index 86c3f6f28b..fee6dde59a 100644 --- a/server/src/schema/tables/asset-audit.table.ts +++ b/server/src/schema/tables/asset-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_audit') export class AssetAuditTable { diff --git a/server/src/schema/tables/asset-edit.table.ts b/server/src/schema/tables/asset-edit.table.ts index 886b62dc0b..51d3ed0a4a 100644 --- a/server/src/schema/tables/asset-edit.table.ts +++ b/server/src/schema/tables/asset-edit.table.ts @@ -1,6 +1,3 @@ -import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto'; -import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; import { AfterDeleteTrigger, AfterInsertTrigger, @@ -10,7 +7,10 @@ import { PrimaryGeneratedColumn, Table, Unique, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto'; +import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; @Table('asset_edit') @AfterInsertTrigger({ scope: 'statement', function: asset_edit_insert, referencingNewTableAs: 'inserted_edit' }) diff --git a/server/src/schema/tables/asset-exif.table.ts b/server/src/schema/tables/asset-exif.table.ts index 9dacb547cf..1ae8f731a9 100644 --- a/server/src/schema/tables/asset-exif.table.ts +++ b/server/src/schema/tables/asset-exif.table.ts @@ -1,7 +1,7 @@ +import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools'; import { LockableProperty } from 'src/database'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools'; @Table('asset_exif') @UpdatedAtTrigger('asset_exif_updatedAt') diff --git a/server/src/schema/tables/asset-face-audit.table.ts b/server/src/schema/tables/asset-face-audit.table.ts index 4f03c22aa0..2e61904800 100644 --- a/server/src/schema/tables/asset-face-audit.table.ts +++ b/server/src/schema/tables/asset-face-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_face_audit') export class AssetFaceAuditTable { diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 8a3b3ac611..b67e5e5dac 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -1,9 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { SourceType } from 'src/enum'; -import { asset_face_source_type } from 'src/schema/enums'; -import { asset_face_audit } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { PersonTable } from 'src/schema/tables/person.table'; import { AfterDeleteTrigger, Column, @@ -15,7 +9,13 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { SourceType } from 'src/enum'; +import { asset_face_source_type } from 'src/schema/enums'; +import { asset_face_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { PersonTable } from 'src/schema/tables/person.table'; @Table({ name: 'asset_face' }) @UpdatedAtTrigger('asset_face_updatedAt') diff --git a/server/src/schema/tables/asset-file.table.ts b/server/src/schema/tables/asset-file.table.ts index 73b5171a47..7fdde5fed1 100644 --- a/server/src/schema/tables/asset-file.table.ts +++ b/server/src/schema/tables/asset-file.table.ts @@ -1,6 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AssetFileType } from 'src/enum'; -import { AssetTable } from 'src/schema/tables/asset.table'; import { Column, CreateDateColumn, @@ -11,7 +8,10 @@ import { Timestamp, Unique, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetFileType } from 'src/enum'; +import { AssetTable } from 'src/schema/tables/asset.table'; @Table('asset_file') @Unique({ columns: ['assetId', 'type', 'isEdited'] }) diff --git a/server/src/schema/tables/asset-job-status.table.ts b/server/src/schema/tables/asset-job-status.table.ts index 62194825e5..4d889ade46 100644 --- a/server/src/schema/tables/asset-job-status.table.ts +++ b/server/src/schema/tables/asset-job-status.table.ts @@ -1,5 +1,5 @@ +import { Column, ForeignKeyColumn, Table, Timestamp } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Table, Timestamp } from 'src/sql-tools'; @Table('asset_job_status') export class AssetJobStatusTable { diff --git a/server/src/schema/tables/asset-metadata-audit.table.ts b/server/src/schema/tables/asset-metadata-audit.table.ts index 16272eacf7..15c0b47edc 100644 --- a/server/src/schema/tables/asset-metadata-audit.table.ts +++ b/server/src/schema/tables/asset-metadata-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_metadata_audit') export class AssetMetadataAuditTable { diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts index 8a7af1360f..53e3121a41 100644 --- a/server/src/schema/tables/asset-metadata.table.ts +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AssetMetadataKey } from 'src/enum'; -import { asset_metadata_audit } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; import { AfterDeleteTrigger, Column, @@ -11,7 +7,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetMetadataKey } from 'src/enum'; +import { asset_metadata_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; @UpdatedAtTrigger('asset_metadata_updated_at') @Table('asset_metadata') diff --git a/server/src/schema/tables/asset-ocr.table.ts b/server/src/schema/tables/asset-ocr.table.ts index b9b0838cbe..b58224a247 100644 --- a/server/src/schema/tables/asset-ocr.table.ts +++ b/server/src/schema/tables/asset-ocr.table.ts @@ -1,5 +1,5 @@ +import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; @Table('asset_ocr') export class AssetOcrTable { diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 765a2900e5..12e9c36125 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -1,10 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; -import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; -import { asset_delete_audit } from 'src/schema/functions'; -import { LibraryTable } from 'src/schema/tables/library.table'; -import { StackTable } from 'src/schema/tables/stack.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, @@ -17,7 +10,14 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; +import { asset_delete_audit } from 'src/schema/functions'; +import { LibraryTable } from 'src/schema/tables/library.table'; +import { StackTable } from 'src/schema/tables/stack.table'; +import { UserTable } from 'src/schema/tables/user.table'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; @Table('asset') diff --git a/server/src/schema/tables/audit.table.ts b/server/src/schema/tables/audit.table.ts index 15b4990814..78c9a57c09 100644 --- a/server/src/schema/tables/audit.table.ts +++ b/server/src/schema/tables/audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Index, PrimaryColumn, Table, Timestamp } from '@immich/sql-tools'; import { DatabaseAction, EntityType } from 'src/enum'; -import { Column, CreateDateColumn, Generated, Index, PrimaryColumn, Table, Timestamp } from 'src/sql-tools'; @Table('audit') @Index({ columns: ['ownerId', 'createdAt'] }) diff --git a/server/src/schema/tables/face-search.table.ts b/server/src/schema/tables/face-search.table.ts index ff63879404..7c585437c8 100644 --- a/server/src/schema/tables/face-search.table.ts +++ b/server/src/schema/tables/face-search.table.ts @@ -1,5 +1,5 @@ +import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Table({ name: 'face_search' }) @Index({ diff --git a/server/src/schema/tables/geodata-places.table.ts b/server/src/schema/tables/geodata-places.table.ts index eec2b240d0..101ddb759f 100644 --- a/server/src/schema/tables/geodata-places.table.ts +++ b/server/src/schema/tables/geodata-places.table.ts @@ -1,4 +1,4 @@ -import { Column, Index, PrimaryColumn, Table, Timestamp } from 'src/sql-tools'; +import { Column, Index, PrimaryColumn, Table, Timestamp } from '@immich/sql-tools'; @Table({ name: 'geodata_places', primaryConstraintName: 'geodata_places_pkey' }) @Index({ diff --git a/server/src/schema/tables/library.table.ts b/server/src/schema/tables/library.table.ts index 57ad144c8e..2f79a3e78d 100644 --- a/server/src/schema/tables/library.table.ts +++ b/server/src/schema/tables/library.table.ts @@ -1,5 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -10,7 +8,9 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('library') @UpdatedAtTrigger('library_updatedAt') diff --git a/server/src/schema/tables/memory-asset-audit.table.ts b/server/src/schema/tables/memory-asset-audit.table.ts index 218c2f19ff..67c434c45a 100644 --- a/server/src/schema/tables/memory-asset-audit.table.ts +++ b/server/src/schema/tables/memory-asset-audit.table.ts @@ -1,6 +1,6 @@ +import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; import { MemoryTable } from 'src/schema/tables/memory.table'; -import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('memory_asset_audit') export class MemoryAssetAuditTable { diff --git a/server/src/schema/tables/memory-asset.table.ts b/server/src/schema/tables/memory-asset.table.ts index b162000ca0..b44c78c3b9 100644 --- a/server/src/schema/tables/memory-asset.table.ts +++ b/server/src/schema/tables/memory-asset.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { memory_asset_delete_audit } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { MemoryTable } from 'src/schema/tables/memory.table'; import { AfterDeleteTrigger, CreateDateColumn, @@ -10,7 +6,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { memory_asset_delete_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { MemoryTable } from 'src/schema/tables/memory.table'; @Table('memory_asset') @UpdatedAtTrigger('memory_asset_updatedAt') diff --git a/server/src/schema/tables/memory-audit.table.ts b/server/src/schema/tables/memory-audit.table.ts index 167caf8e6e..6d278676b7 100644 --- a/server/src/schema/tables/memory-audit.table.ts +++ b/server/src/schema/tables/memory-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('memory_audit') export class MemoryAuditTable { diff --git a/server/src/schema/tables/memory.table.ts b/server/src/schema/tables/memory.table.ts index 408f7bca19..8b9867b4cc 100644 --- a/server/src/schema/tables/memory.table.ts +++ b/server/src/schema/tables/memory.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { MemoryType } from 'src/enum'; -import { memory_delete_audit } from 'src/schema/functions'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, @@ -13,7 +9,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { MemoryType } from 'src/enum'; +import { memory_delete_audit } from 'src/schema/functions'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('memory') @UpdatedAtTrigger('memory_updatedAt') diff --git a/server/src/schema/tables/move.table.ts b/server/src/schema/tables/move.table.ts index 1afda2767a..c7229431f7 100644 --- a/server/src/schema/tables/move.table.ts +++ b/server/src/schema/tables/move.table.ts @@ -1,5 +1,5 @@ +import { Column, Generated, PrimaryGeneratedColumn, Table, Unique } from '@immich/sql-tools'; import { PathType } from 'src/enum'; -import { Column, Generated, PrimaryGeneratedColumn, Table, Unique } from 'src/sql-tools'; @Table('move_history') // path lock (per entity) diff --git a/server/src/schema/tables/natural-earth-countries.table.ts b/server/src/schema/tables/natural-earth-countries.table.ts index c59d15fc21..06f189264e 100644 --- a/server/src/schema/tables/natural-earth-countries.table.ts +++ b/server/src/schema/tables/natural-earth-countries.table.ts @@ -1,4 +1,4 @@ -import { Column, Generated, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { Column, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools'; @Table({ name: 'naturalearth_countries', primaryConstraintName: 'naturalearth_countries_pkey' }) export class NaturalEarthCountriesTable { diff --git a/server/src/schema/tables/notification.table.ts b/server/src/schema/tables/notification.table.ts index 01a93a73e5..6bf65808f1 100644 --- a/server/src/schema/tables/notification.table.ts +++ b/server/src/schema/tables/notification.table.ts @@ -1,6 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { NotificationLevel, NotificationType } from 'src/enum'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -11,7 +8,10 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('notification') @UpdatedAtTrigger('notification_updatedAt') diff --git a/server/src/schema/tables/ocr-search.table.ts b/server/src/schema/tables/ocr-search.table.ts index 3449725adb..74aefb333b 100644 --- a/server/src/schema/tables/ocr-search.table.ts +++ b/server/src/schema/tables/ocr-search.table.ts @@ -1,5 +1,5 @@ +import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Table('ocr_search') @Index({ diff --git a/server/src/schema/tables/partner-audit.table.ts b/server/src/schema/tables/partner-audit.table.ts index fa2f0c27cc..3cfd1854e1 100644 --- a/server/src/schema/tables/partner-audit.table.ts +++ b/server/src/schema/tables/partner-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('partner_audit') export class PartnerAuditTable { diff --git a/server/src/schema/tables/partner.table.ts b/server/src/schema/tables/partner.table.ts index 8fc332cb12..408cac650f 100644 --- a/server/src/schema/tables/partner.table.ts +++ b/server/src/schema/tables/partner.table.ts @@ -1,6 +1,3 @@ -import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { partner_delete_audit } from 'src/schema/functions'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, @@ -10,7 +7,10 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { partner_delete_audit } from 'src/schema/functions'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('partner') @UpdatedAtTrigger('partner_updatedAt') diff --git a/server/src/schema/tables/person-audit.table.ts b/server/src/schema/tables/person-audit.table.ts index 8a899a1808..4fb55f1744 100644 --- a/server/src/schema/tables/person-audit.table.ts +++ b/server/src/schema/tables/person-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('person_audit') export class PersonAuditTable { diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 3b523a39d2..02fb85b757 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { person_delete_audit } from 'src/schema/functions'; -import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Check, @@ -13,7 +9,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { person_delete_audit } from 'src/schema/functions'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('person') @UpdatedAtTrigger('person_updatedAt') diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts index 3de7ca63c9..5f82807f23 100644 --- a/server/src/schema/tables/plugin.table.ts +++ b/server/src/schema/tables/plugin.table.ts @@ -1,4 +1,3 @@ -import { PluginContext } from 'src/enum'; import { Column, CreateDateColumn, @@ -9,7 +8,8 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { PluginContext } from 'src/enum'; import type { JSONSchema } from 'src/types/plugin-schema.types'; @Table('plugin') diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 466152d35d..396a847e7e 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -1,5 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -9,7 +7,9 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { UserTable } from 'src/schema/tables/user.table'; @Table({ name: 'session' }) @UpdatedAtTrigger('session_updatedAt') diff --git a/server/src/schema/tables/shared-link-asset.table.ts b/server/src/schema/tables/shared-link-asset.table.ts index 37e6a3d9f0..ff96f69980 100644 --- a/server/src/schema/tables/shared-link-asset.table.ts +++ b/server/src/schema/tables/shared-link-asset.table.ts @@ -1,6 +1,6 @@ +import { ForeignKeyColumn, Table } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; -import { ForeignKeyColumn, Table } from 'src/sql-tools'; @Table('shared_link_asset') export class SharedLinkAssetTable { diff --git a/server/src/schema/tables/shared-link.table.ts b/server/src/schema/tables/shared-link.table.ts index 80e2d7cdf4..d99520388a 100644 --- a/server/src/schema/tables/shared-link.table.ts +++ b/server/src/schema/tables/shared-link.table.ts @@ -1,6 +1,3 @@ -import { SharedLinkType } from 'src/enum'; -import { AlbumTable } from 'src/schema/tables/album.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -9,7 +6,10 @@ import { PrimaryGeneratedColumn, Table, Timestamp, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { SharedLinkType } from 'src/enum'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('shared_link') export class SharedLinkTable { diff --git a/server/src/schema/tables/smart-search.table.ts b/server/src/schema/tables/smart-search.table.ts index dc140efb2f..31071e6134 100644 --- a/server/src/schema/tables/smart-search.table.ts +++ b/server/src/schema/tables/smart-search.table.ts @@ -1,5 +1,5 @@ +import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Table({ name: 'smart_search' }) @Index({ diff --git a/server/src/schema/tables/stack-audit.table.ts b/server/src/schema/tables/stack-audit.table.ts index d46ff95e57..3a62545cd2 100644 --- a/server/src/schema/tables/stack-audit.table.ts +++ b/server/src/schema/tables/stack-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('stack_audit') export class StackAuditTable { diff --git a/server/src/schema/tables/stack.table.ts b/server/src/schema/tables/stack.table.ts index 9c9eb81373..3f903e065a 100644 --- a/server/src/schema/tables/stack.table.ts +++ b/server/src/schema/tables/stack.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { stack_delete_audit } from 'src/schema/functions'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, CreateDateColumn, @@ -11,7 +7,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { stack_delete_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('stack') @UpdatedAtTrigger('stack_updatedAt') diff --git a/server/src/schema/tables/sync-checkpoint.table.ts b/server/src/schema/tables/sync-checkpoint.table.ts index 6ad4c54a86..d9ada5aed0 100644 --- a/server/src/schema/tables/sync-checkpoint.table.ts +++ b/server/src/schema/tables/sync-checkpoint.table.ts @@ -1,6 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { SyncEntityType } from 'src/enum'; -import { SessionTable } from 'src/schema/tables/session.table'; import { Column, CreateDateColumn, @@ -10,7 +7,10 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { SyncEntityType } from 'src/enum'; +import { SessionTable } from 'src/schema/tables/session.table'; @Table('session_sync_checkpoint') @UpdatedAtTrigger('session_sync_checkpoint_updatedAt') diff --git a/server/src/schema/tables/system-metadata.table.ts b/server/src/schema/tables/system-metadata.table.ts index 8657768db6..9f21172505 100644 --- a/server/src/schema/tables/system-metadata.table.ts +++ b/server/src/schema/tables/system-metadata.table.ts @@ -1,5 +1,5 @@ +import { Column, PrimaryColumn, Table } from '@immich/sql-tools'; import { SystemMetadataKey } from 'src/enum'; -import { Column, PrimaryColumn, Table } from 'src/sql-tools'; import { SystemMetadata } from 'src/types'; @Table('system_metadata') diff --git a/server/src/schema/tables/tag-asset.table.ts b/server/src/schema/tables/tag-asset.table.ts index 3ea2361b4f..9d7ea026c6 100644 --- a/server/src/schema/tables/tag-asset.table.ts +++ b/server/src/schema/tables/tag-asset.table.ts @@ -1,6 +1,6 @@ +import { ForeignKeyColumn, Index, Table } from '@immich/sql-tools'; 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: ['assetId', 'tagId'] }) @Table('tag_asset') diff --git a/server/src/schema/tables/tag-closure.table.ts b/server/src/schema/tables/tag-closure.table.ts index aeb8c8cf11..2e1c83a20f 100644 --- a/server/src/schema/tables/tag-closure.table.ts +++ b/server/src/schema/tables/tag-closure.table.ts @@ -1,5 +1,5 @@ +import { ForeignKeyColumn, Table } from '@immich/sql-tools'; import { TagTable } from 'src/schema/tables/tag.table'; -import { ForeignKeyColumn, Table } from 'src/sql-tools'; @Table('tag_closure') export class TagClosureTable { diff --git a/server/src/schema/tables/tag.table.ts b/server/src/schema/tables/tag.table.ts index dc1fa2947b..2a07239d84 100644 --- a/server/src/schema/tables/tag.table.ts +++ b/server/src/schema/tables/tag.table.ts @@ -1,5 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserTable } from 'src/schema/tables/user.table'; import { Column, CreateDateColumn, @@ -10,7 +8,9 @@ import { Timestamp, Unique, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { UserTable } from 'src/schema/tables/user.table'; @Table('tag') @UpdatedAtTrigger('tag_updatedAt') diff --git a/server/src/schema/tables/user-audit.table.ts b/server/src/schema/tables/user-audit.table.ts index 084b42fb65..36f89dfa7d 100644 --- a/server/src/schema/tables/user-audit.table.ts +++ b/server/src/schema/tables/user-audit.table.ts @@ -1,5 +1,5 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('user_audit') export class UserAuditTable { diff --git a/server/src/schema/tables/user-metadata-audit.table.ts b/server/src/schema/tables/user-metadata-audit.table.ts index 63f503ab85..17dee673b4 100644 --- a/server/src/schema/tables/user-metadata-audit.table.ts +++ b/server/src/schema/tables/user-metadata-audit.table.ts @@ -1,6 +1,6 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; import { UserMetadataKey } from 'src/enum'; -import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('user_metadata_audit') export class UserMetadataAuditTable { diff --git a/server/src/schema/tables/user-metadata.table.ts b/server/src/schema/tables/user-metadata.table.ts index a453ec6677..6983ed3dda 100644 --- a/server/src/schema/tables/user-metadata.table.ts +++ b/server/src/schema/tables/user-metadata.table.ts @@ -1,7 +1,3 @@ -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserMetadataKey } from 'src/enum'; -import { user_metadata_audit } from 'src/schema/functions'; -import { UserTable } from 'src/schema/tables/user.table'; import { AfterDeleteTrigger, Column, @@ -11,7 +7,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { UserMetadataKey } from 'src/enum'; +import { user_metadata_audit } from 'src/schema/functions'; +import { UserTable } from 'src/schema/tables/user.table'; import { UserMetadata, UserMetadataItem } from 'src/types'; @UpdatedAtTrigger('user_metadata_updated_at') diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 46d6656382..3a340d976b 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -1,7 +1,3 @@ -import { ColumnType } from 'kysely'; -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserAvatarColor, UserStatus } from 'src/enum'; -import { user_delete_audit } from 'src/schema/functions'; import { AfterDeleteTrigger, Column, @@ -13,7 +9,11 @@ import { Table, Timestamp, UpdateDateColumn, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { ColumnType } from 'kysely'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { UserAvatarColor, UserStatus } from 'src/enum'; +import { user_delete_audit } from 'src/schema/functions'; @Table('user') @UpdatedAtTrigger('user_updatedAt') diff --git a/server/src/schema/tables/version-history.table.ts b/server/src/schema/tables/version-history.table.ts index 143852c527..12eab7fd69 100644 --- a/server/src/schema/tables/version-history.table.ts +++ b/server/src/schema/tables/version-history.table.ts @@ -1,4 +1,4 @@ -import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from 'src/sql-tools'; +import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from '@immich/sql-tools'; @Table('version_history') export class VersionHistoryTable { diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts index 62a5531d8e..163518e039 100644 --- a/server/src/schema/tables/workflow.table.ts +++ b/server/src/schema/tables/workflow.table.ts @@ -1,6 +1,3 @@ -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, @@ -10,7 +7,10 @@ import { PrimaryGeneratedColumn, Table, Timestamp, -} from 'src/sql-tools'; +} from '@immich/sql-tools'; +import { PluginTriggerType } from 'src/enum'; +import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; +import { UserTable } from 'src/schema/tables/user.table'; import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; @Table('workflow') diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 479fd130a6..22f06e2ed9 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -1,3 +1,4 @@ +import { schemaDiff } from '@immich/sql-tools'; import { Injectable } from '@nestjs/common'; import { isAbsolute, join } from 'node:path'; import { SALT_ROUNDS } from 'src/constants'; @@ -5,7 +6,6 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { schemaDiff } from 'src/sql-tools'; import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; diff --git a/server/src/sql-tools/comparers/column.comparer.spec.ts b/server/src/sql-tools/comparers/column.comparer.spec.ts deleted file mode 100644 index ef2afb348a..0000000000 --- a/server/src/sql-tools/comparers/column.comparer.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { compareColumns } from 'src/sql-tools/comparers/column.comparer'; -import { DatabaseColumn, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testColumn: DatabaseColumn = { - name: 'test', - tableName: 'table1', - primary: false, - nullable: false, - isArray: false, - type: 'character varying', - synchronize: true, -}; - -describe('compareColumns', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareColumns().onExtra(testColumn)).toEqual([ - { - tableName: 'table1', - columnName: 'test', - type: 'ColumnDrop', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareColumns().onMissing(testColumn)).toEqual([ - { - type: 'ColumnAdd', - column: testColumn, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareColumns().onCompare(testColumn, testColumn)).toEqual([]); - }); - - it('should detect a change in type', () => { - const source: DatabaseColumn = { ...testColumn }; - const target: DatabaseColumn = { ...testColumn, type: 'text' }; - const reason = 'column type is different (character varying vs text)'; - expect(compareColumns().onCompare(source, target)).toEqual([ - { - columnName: 'test', - tableName: 'table1', - type: 'ColumnDrop', - reason, - }, - { - type: 'ColumnAdd', - column: source, - reason, - }, - ]); - }); - - it('should detect a change in default', () => { - const source: DatabaseColumn = { ...testColumn, nullable: true }; - const target: DatabaseColumn = { ...testColumn, nullable: true, default: "''" }; - const reason = `default is different (null vs '')`; - expect(compareColumns().onCompare(source, target)).toEqual([ - { - columnName: 'test', - tableName: 'table1', - type: 'ColumnAlter', - changes: { - default: 'NULL', - }, - reason, - }, - ]); - }); - - it('should detect a comment change', () => { - const source: DatabaseColumn = { ...testColumn, comment: 'new comment' }; - const target: DatabaseColumn = { ...testColumn, comment: 'old comment' }; - const reason = 'comment is different (new comment vs old comment)'; - expect(compareColumns().onCompare(source, target)).toEqual([ - { - columnName: 'test', - tableName: 'table1', - type: 'ColumnAlter', - changes: { - comment: 'new comment', - }, - reason, - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/column.comparer.ts b/server/src/sql-tools/comparers/column.comparer.ts deleted file mode 100644 index 54ffb34ffa..0000000000 --- a/server/src/sql-tools/comparers/column.comparer.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { asRenameKey, getColumnType, isDefaultEqual } from 'src/sql-tools/helpers'; -import { Comparer, DatabaseColumn, Reason, SchemaDiff } from 'src/sql-tools/types'; - -export const compareColumns = () => - ({ - getRenameKey: (column) => { - return asRenameKey([ - column.tableName, - column.type, - column.nullable, - column.default, - column.storage, - column.primary, - column.isArray, - column.length, - column.identity, - column.enumName, - column.numericPrecision, - column.numericScale, - ]); - }, - onRename: (source, target) => [ - { - type: 'ColumnRename', - tableName: source.tableName, - oldName: target.name, - newName: source.name, - reason: Reason.Rename, - }, - ], - onMissing: (source) => [ - { - type: 'ColumnAdd', - column: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'ColumnDrop', - tableName: target.tableName, - columnName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - const sourceType = getColumnType(source); - const targetType = getColumnType(target); - - const isTypeChanged = sourceType !== targetType; - - if (isTypeChanged) { - // TODO: convert between types via UPDATE when possible - return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`); - } - - const items: SchemaDiff[] = []; - if (source.nullable !== target.nullable) { - items.push({ - type: 'ColumnAlter', - tableName: source.tableName, - columnName: source.name, - changes: { - nullable: source.nullable, - }, - reason: `nullable is different (${source.nullable} vs ${target.nullable})`, - }); - } - - if (!isDefaultEqual(source, target)) { - items.push({ - type: 'ColumnAlter', - tableName: source.tableName, - columnName: source.name, - changes: { - default: String(source.default ?? 'NULL'), - }, - reason: `default is different (${source.default ?? 'null'} vs ${target.default})`, - }); - } - - if (source.comment !== target.comment) { - items.push({ - type: 'ColumnAlter', - tableName: source.tableName, - columnName: source.name, - changes: { - comment: String(source.comment), - }, - reason: `comment is different (${source.comment} vs ${target.comment})`, - }); - } - - return items; - }, - }) satisfies Comparer; - -const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => { - return [ - { - type: 'ColumnDrop', - tableName: target.tableName, - columnName: target.name, - reason, - }, - { type: 'ColumnAdd', column: source, reason }, - ]; -}; diff --git a/server/src/sql-tools/comparers/constraint.comparer.spec.ts b/server/src/sql-tools/comparers/constraint.comparer.spec.ts deleted file mode 100644 index 216728f8c4..0000000000 --- a/server/src/sql-tools/comparers/constraint.comparer.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { compareConstraints } from 'src/sql-tools/comparers/constraint.comparer'; -import { ConstraintType, DatabaseConstraint, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testConstraint: DatabaseConstraint = { - type: ConstraintType.PRIMARY_KEY, - name: 'test', - tableName: 'table1', - columnNames: ['column1'], - synchronize: true, -}; - -describe('compareConstraints', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareConstraints().onExtra(testConstraint)).toEqual([ - { - type: 'ConstraintDrop', - constraintName: 'test', - tableName: 'table1', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareConstraints().onMissing(testConstraint)).toEqual([ - { - type: 'ConstraintAdd', - constraint: testConstraint, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareConstraints().onCompare(testConstraint, testConstraint)).toEqual([]); - }); - - it('should detect a change in type', () => { - const source: DatabaseConstraint = { ...testConstraint }; - const target: DatabaseConstraint = { ...testConstraint, columnNames: ['column1', 'column2'] }; - const reason = 'Primary key columns are different: (column1 vs column1,column2)'; - expect(compareConstraints().onCompare(source, target)).toEqual([ - { - constraintName: 'test', - tableName: 'table1', - type: 'ConstraintDrop', - reason, - }, - { - type: 'ConstraintAdd', - constraint: source, - reason, - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/constraint.comparer.ts b/server/src/sql-tools/comparers/constraint.comparer.ts deleted file mode 100644 index 03128878d5..0000000000 --- a/server/src/sql-tools/comparers/constraint.comparer.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { asRenameKey, haveEqualColumns } from 'src/sql-tools/helpers'; -import { - CompareFunction, - Comparer, - ConstraintType, - DatabaseCheckConstraint, - DatabaseConstraint, - DatabaseForeignKeyConstraint, - DatabasePrimaryKeyConstraint, - DatabaseUniqueConstraint, - Reason, - SchemaDiff, -} from 'src/sql-tools/types'; - -export const compareConstraints = (): Comparer => ({ - getRenameKey: (constraint) => { - switch (constraint.type) { - case ConstraintType.PRIMARY_KEY: - case ConstraintType.UNIQUE: { - return asRenameKey([constraint.type, constraint.tableName, ...constraint.columnNames.toSorted()]); - } - - case ConstraintType.FOREIGN_KEY: { - return asRenameKey([ - constraint.type, - constraint.tableName, - ...constraint.columnNames.toSorted(), - constraint.referenceTableName, - ...constraint.referenceColumnNames.toSorted(), - ]); - } - - case ConstraintType.CHECK: { - const expression = constraint.expression.replaceAll('(', '').replaceAll(')', ''); - return asRenameKey([constraint.type, constraint.tableName, expression]); - } - } - }, - onRename: (source, target) => [ - { - type: 'ConstraintRename', - tableName: target.tableName, - oldName: target.name, - newName: source.name, - reason: Reason.Rename, - }, - ], - onMissing: (source) => [ - { - type: 'ConstraintAdd', - constraint: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'ConstraintDrop', - tableName: target.tableName, - constraintName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - switch (source.type) { - case ConstraintType.PRIMARY_KEY: { - return comparePrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint); - } - - case ConstraintType.FOREIGN_KEY: { - return compareForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint); - } - - case ConstraintType.UNIQUE: { - return compareUniqueConstraint(source, target as DatabaseUniqueConstraint); - } - - case ConstraintType.CHECK: { - return compareCheckConstraint(source, target as DatabaseCheckConstraint); - } - - default: { - return []; - } - } - }, -}); - -const comparePrimaryKeyConstraint: CompareFunction = (source, target) => { - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - return dropAndRecreateConstraint( - source, - target, - `Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`, - ); - } - - return []; -}; - -const compareForeignKeyConstraint: CompareFunction = (source, target) => { - let reason = ''; - - const sourceDeleteAction = source.onDelete ?? 'NO ACTION'; - const targetDeleteAction = target.onDelete ?? 'NO ACTION'; - - const sourceUpdateAction = source.onUpdate ?? 'NO ACTION'; - const targetUpdateAction = target.onUpdate ?? 'NO ACTION'; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) { - reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`; - } else if (source.referenceTableName !== target.referenceTableName) { - reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`; - } else if (sourceDeleteAction !== targetDeleteAction) { - reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`; - } else if (sourceUpdateAction !== targetUpdateAction) { - reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`; - } - - if (reason) { - return dropAndRecreateConstraint(source, target, reason); - } - - return []; -}; - -const compareUniqueConstraint: CompareFunction = (source, target) => { - let reason = ''; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } - - if (reason) { - return dropAndRecreateConstraint(source, target, reason); - } - - return []; -}; - -const compareCheckConstraint: CompareFunction = (source, target) => { - if (source.expression !== target.expression) { - // comparing expressions is hard because postgres reconstructs it with different formatting - // for now if the constraint exists with the same name, we will just skip it - } - - return []; -}; - -const dropAndRecreateConstraint = ( - source: DatabaseConstraint, - target: DatabaseConstraint, - reason: string, -): SchemaDiff[] => { - return [ - { - type: 'ConstraintDrop', - tableName: target.tableName, - constraintName: target.name, - reason, - }, - { type: 'ConstraintAdd', constraint: source, reason }, - ]; -}; diff --git a/server/src/sql-tools/comparers/enum.comparer.spec.ts b/server/src/sql-tools/comparers/enum.comparer.spec.ts deleted file mode 100644 index d788c7cd71..0000000000 --- a/server/src/sql-tools/comparers/enum.comparer.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { compareEnums } from 'src/sql-tools/comparers/enum.comparer'; -import { DatabaseEnum, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testEnum: DatabaseEnum = { name: 'test', values: ['foo', 'bar'], synchronize: true }; - -describe('compareEnums', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareEnums().onExtra(testEnum)).toEqual([ - { - enumName: 'test', - type: 'EnumDrop', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareEnums().onMissing(testEnum)).toEqual([ - { - type: 'EnumCreate', - enum: testEnum, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareEnums().onCompare(testEnum, testEnum)).toEqual([]); - }); - - it('should drop and recreate when values list is different', () => { - const source = { name: 'test', values: ['foo', 'bar'], synchronize: true }; - const target = { name: 'test', values: ['foo', 'bar', 'world'], synchronize: true }; - expect(compareEnums().onCompare(source, target)).toEqual([ - { - enumName: 'test', - type: 'EnumDrop', - reason: 'enum values has changed (foo,bar vs foo,bar,world)', - }, - { - type: 'EnumCreate', - enum: source, - reason: 'enum values has changed (foo,bar vs foo,bar,world)', - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/enum.comparer.ts b/server/src/sql-tools/comparers/enum.comparer.ts deleted file mode 100644 index efc08ae727..0000000000 --- a/server/src/sql-tools/comparers/enum.comparer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Comparer, DatabaseEnum, Reason } from 'src/sql-tools/types'; - -export const compareEnums = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'EnumCreate', - enum: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'EnumDrop', - enumName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - if (source.values.toString() !== target.values.toString()) { - // TODO add or remove values if the lists are different or the order has changed - const reason = `enum values has changed (${source.values} vs ${target.values})`; - return [ - { - type: 'EnumDrop', - enumName: source.name, - reason, - }, - { - type: 'EnumCreate', - enum: source, - reason, - }, - ]; - } - - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/extension.comparer.spec.ts b/server/src/sql-tools/comparers/extension.comparer.spec.ts deleted file mode 100644 index df70ccc761..0000000000 --- a/server/src/sql-tools/comparers/extension.comparer.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { compareExtensions } from 'src/sql-tools/comparers/extension.comparer'; -import { Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testExtension = { name: 'test', synchronize: true }; - -describe('compareExtensions', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareExtensions().onExtra(testExtension)).toEqual([ - { - extensionName: 'test', - type: 'ExtensionDrop', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareExtensions().onMissing(testExtension)).toEqual([ - { - type: 'ExtensionCreate', - extension: testExtension, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareExtensions().onCompare(testExtension, testExtension)).toEqual([]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/extension.comparer.ts b/server/src/sql-tools/comparers/extension.comparer.ts deleted file mode 100644 index 3cb70dadc4..0000000000 --- a/server/src/sql-tools/comparers/extension.comparer.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Comparer, DatabaseExtension, Reason } from 'src/sql-tools/types'; - -export const compareExtensions = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'ExtensionCreate', - extension: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'ExtensionDrop', - extensionName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: () => { - // if the name matches they are the same - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/function.comparer.spec.ts b/server/src/sql-tools/comparers/function.comparer.spec.ts deleted file mode 100644 index 3d18aaf50a..0000000000 --- a/server/src/sql-tools/comparers/function.comparer.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { compareFunctions } from 'src/sql-tools/comparers/function.comparer'; -import { DatabaseFunction, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testFunction: DatabaseFunction = { - name: 'test', - expression: 'CREATE FUNCTION something something something', - synchronize: true, -}; - -describe('compareFunctions', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareFunctions().onExtra(testFunction)).toEqual([ - { - functionName: 'test', - type: 'FunctionDrop', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareFunctions().onMissing(testFunction)).toEqual([ - { - type: 'FunctionCreate', - function: testFunction, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should ignore functions with the same hash', () => { - expect(compareFunctions().onCompare(testFunction, testFunction)).toEqual([]); - }); - - it('should report differences if functions have different hashes', () => { - const source: DatabaseFunction = { ...testFunction, expression: 'SELECT 1' }; - const target: DatabaseFunction = { ...testFunction, expression: 'SELECT 2' }; - expect(compareFunctions().onCompare(source, target)).toEqual([ - { - type: 'FunctionCreate', - reason: 'function expression has changed (SELECT 1 vs SELECT 2)', - function: source, - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/function.comparer.ts b/server/src/sql-tools/comparers/function.comparer.ts deleted file mode 100644 index c6217ee708..0000000000 --- a/server/src/sql-tools/comparers/function.comparer.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Comparer, DatabaseFunction, Reason } from 'src/sql-tools/types'; - -export const compareFunctions = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'FunctionCreate', - function: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'FunctionDrop', - functionName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - if (source.expression !== target.expression) { - const reason = `function expression has changed (${source.expression} vs ${target.expression})`; - return [ - { - type: 'FunctionCreate', - function: source, - reason, - }, - ]; - } - - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/index.comparer.spec.ts b/server/src/sql-tools/comparers/index.comparer.spec.ts deleted file mode 100644 index 9ae7f34f04..0000000000 --- a/server/src/sql-tools/comparers/index.comparer.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { compareIndexes } from 'src/sql-tools/comparers/index.comparer'; -import { DatabaseIndex, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testIndex: DatabaseIndex = { - name: 'test', - tableName: 'table1', - columnNames: ['column1', 'column2'], - unique: false, - synchronize: true, -}; - -describe('compareIndexes', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareIndexes().onExtra(testIndex)).toEqual([ - { - type: 'IndexDrop', - indexName: 'test', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareIndexes().onMissing(testIndex)).toEqual([ - { - type: 'IndexCreate', - index: testIndex, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareIndexes().onCompare(testIndex, testIndex)).toEqual([]); - }); - - it('should drop and recreate when column list is different', () => { - const source = { - name: 'test', - tableName: 'table1', - columnNames: ['column1'], - unique: true, - synchronize: true, - }; - const target = { - name: 'test', - tableName: 'table1', - columnNames: ['column1', 'column2'], - unique: true, - synchronize: true, - }; - expect(compareIndexes().onCompare(source, target)).toEqual([ - { - indexName: 'test', - type: 'IndexDrop', - reason: 'columns are different (column1 vs column1,column2)', - }, - { - type: 'IndexCreate', - index: source, - reason: 'columns are different (column1 vs column1,column2)', - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/index.comparer.ts b/server/src/sql-tools/comparers/index.comparer.ts deleted file mode 100644 index e474302c6e..0000000000 --- a/server/src/sql-tools/comparers/index.comparer.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { asRenameKey, haveEqualColumns } from 'src/sql-tools/helpers'; -import { Comparer, DatabaseIndex, Reason } from 'src/sql-tools/types'; - -export const compareIndexes = (): Comparer => ({ - getRenameKey: (index) => { - if (index.override) { - return index.override.value.sql.replace(index.name, 'INDEX_NAME'); - } - - return asRenameKey([index.tableName, ...(index.columnNames || []), index.unique]); - }, - onRename: (source, target) => [ - { - type: 'IndexRename', - tableName: source.tableName, - oldName: target.name, - newName: source.name, - reason: Reason.Rename, - }, - ], - onMissing: (source) => [ - { - type: 'IndexCreate', - index: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'IndexDrop', - indexName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - const sourceUsing = source.using ?? 'btree'; - const targetUsing = target.using ?? 'btree'; - - let reason = ''; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } else if (source.unique !== target.unique) { - reason = `uniqueness is different (${source.unique} vs ${target.unique})`; - } else if (sourceUsing !== targetUsing) { - reason = `using method is different (${source.using} vs ${target.using})`; - } else if (source.where !== target.where) { - reason = `where clause is different (${source.where} vs ${target.where})`; - } else if (source.expression !== target.expression) { - reason = `expression is different (${source.expression} vs ${target.expression})`; - } - - if (reason) { - return [ - { type: 'IndexDrop', indexName: target.name, reason }, - { type: 'IndexCreate', index: source, reason }, - ]; - } - - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/override.comparer.spec.ts b/server/src/sql-tools/comparers/override.comparer.spec.ts deleted file mode 100644 index dfa6fa4455..0000000000 --- a/server/src/sql-tools/comparers/override.comparer.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { compareOverrides } from 'src/sql-tools/comparers/override.comparer'; -import { DatabaseOverride, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testOverride: DatabaseOverride = { - name: 'test', - value: { type: 'function', name: 'test_func', sql: 'func implementation' }, - synchronize: true, -}; - -describe('compareOverrides', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareOverrides().onExtra(testOverride)).toEqual([ - { - type: 'OverrideDrop', - overrideName: 'test', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareOverrides().onMissing(testOverride)).toEqual([ - { - type: 'OverrideCreate', - override: testOverride, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareOverrides().onCompare(testOverride, testOverride)).toEqual([]); - }); - - it('should drop and recreate when the value changes', () => { - const source: DatabaseOverride = { - name: 'test', - value: { - type: 'function', - name: 'test_func', - sql: 'func implementation', - }, - synchronize: true, - }; - const target: DatabaseOverride = { - name: 'test', - value: { - type: 'function', - name: 'test_func', - sql: 'func implementation2', - }, - synchronize: true, - }; - expect(compareOverrides().onCompare(source, target)).toEqual([ - { - override: source, - type: 'OverrideUpdate', - reason: expect.stringContaining('value is different'), - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/override.comparer.ts b/server/src/sql-tools/comparers/override.comparer.ts deleted file mode 100644 index 999770bf69..0000000000 --- a/server/src/sql-tools/comparers/override.comparer.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Comparer, DatabaseOverride, Reason } from 'src/sql-tools/types'; - -export const compareOverrides = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'OverrideCreate', - override: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'OverrideDrop', - overrideName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - if (source.value.name !== target.value.name || source.value.sql !== target.value.sql) { - const sourceValue = JSON.stringify(source.value); - const targetValue = JSON.stringify(target.value); - return [ - { type: 'OverrideUpdate', override: source, reason: `value is different (${sourceValue} vs ${targetValue})` }, - ]; - } - - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/parameter.comparer.spec.ts b/server/src/sql-tools/comparers/parameter.comparer.spec.ts deleted file mode 100644 index 23e6c78118..0000000000 --- a/server/src/sql-tools/comparers/parameter.comparer.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { compareParameters } from 'src/sql-tools/comparers/parameter.comparer'; -import { DatabaseParameter, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testParameter: DatabaseParameter = { - name: 'test', - databaseName: 'immich', - value: 'on', - scope: 'database', - synchronize: true, -}; - -describe('compareParameters', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareParameters().onExtra(testParameter)).toEqual([ - { - type: 'ParameterReset', - databaseName: 'immich', - parameterName: 'test', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareParameters().onMissing(testParameter)).toEqual([ - { - type: 'ParameterSet', - parameter: testParameter, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareParameters().onCompare(testParameter, testParameter)).toEqual([]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/parameter.comparer.ts b/server/src/sql-tools/comparers/parameter.comparer.ts deleted file mode 100644 index 41d0508d70..0000000000 --- a/server/src/sql-tools/comparers/parameter.comparer.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Comparer, DatabaseParameter, Reason } from 'src/sql-tools/types'; - -export const compareParameters = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'ParameterSet', - parameter: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'ParameterReset', - databaseName: target.databaseName, - parameterName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: () => { - // TODO - return []; - }, -}); diff --git a/server/src/sql-tools/comparers/table.comparer.spec.ts b/server/src/sql-tools/comparers/table.comparer.spec.ts deleted file mode 100644 index 909db26ea9..0000000000 --- a/server/src/sql-tools/comparers/table.comparer.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { compareTables } from 'src/sql-tools/comparers/table.comparer'; -import { DatabaseTable, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testTable: DatabaseTable = { - name: 'test', - columns: [], - constraints: [], - indexes: [], - triggers: [], - synchronize: true, -}; - -describe('compareParameters', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareTables({}).onExtra(testTable)).toEqual([ - { - type: 'TableDrop', - tableName: 'test', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareTables({}).onMissing(testTable)).toEqual([ - { - type: 'TableCreate', - table: testTable, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareTables({}).onCompare(testTable, testTable)).toEqual([]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/table.comparer.ts b/server/src/sql-tools/comparers/table.comparer.ts deleted file mode 100644 index 6576dce1b1..0000000000 --- a/server/src/sql-tools/comparers/table.comparer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { compareColumns } from 'src/sql-tools/comparers/column.comparer'; -import { compareConstraints } from 'src/sql-tools/comparers/constraint.comparer'; -import { compareIndexes } from 'src/sql-tools/comparers/index.comparer'; -import { compareTriggers } from 'src/sql-tools/comparers/trigger.comparer'; -import { compare } from 'src/sql-tools/helpers'; -import { Comparer, DatabaseTable, Reason, SchemaDiffOptions } from 'src/sql-tools/types'; - -export const compareTables = (options: SchemaDiffOptions): Comparer => ({ - onMissing: (source) => [ - { - type: 'TableCreate', - table: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'TableDrop', - tableName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - return [ - ...compare(source.columns, target.columns, options.columns, compareColumns()), - ...compare(source.indexes, target.indexes, options.indexes, compareIndexes()), - ...compare(source.constraints, target.constraints, options.constraints, compareConstraints()), - ...compare(source.triggers, target.triggers, options.triggers, compareTriggers()), - ]; - }, -}); diff --git a/server/src/sql-tools/comparers/trigger.comparer.spec.ts b/server/src/sql-tools/comparers/trigger.comparer.spec.ts deleted file mode 100644 index c80b0d2273..0000000000 --- a/server/src/sql-tools/comparers/trigger.comparer.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { compareTriggers } from 'src/sql-tools/comparers/trigger.comparer'; -import { DatabaseTrigger, Reason } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const testTrigger: DatabaseTrigger = { - name: 'test', - tableName: 'table1', - timing: 'before', - actions: ['delete'], - scope: 'row', - functionName: 'my_trigger_function', - synchronize: true, -}; - -describe('compareTriggers', () => { - describe('onExtra', () => { - it('should work', () => { - expect(compareTriggers().onExtra(testTrigger)).toEqual([ - { - type: 'TriggerDrop', - tableName: 'table1', - triggerName: 'test', - reason: Reason.MissingInSource, - }, - ]); - }); - }); - - describe('onMissing', () => { - it('should work', () => { - expect(compareTriggers().onMissing(testTrigger)).toEqual([ - { - type: 'TriggerCreate', - trigger: testTrigger, - reason: Reason.MissingInTarget, - }, - ]); - }); - }); - - describe('onCompare', () => { - it('should work', () => { - expect(compareTriggers().onCompare(testTrigger, testTrigger)).toEqual([]); - }); - - it('should detect a change in function name', () => { - const source: DatabaseTrigger = { ...testTrigger, functionName: 'my_new_name' }; - const target: DatabaseTrigger = { ...testTrigger, functionName: 'my_old_name' }; - const reason = `function is different (my_new_name vs my_old_name)`; - expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); - }); - - it('should detect a change in actions', () => { - const source: DatabaseTrigger = { ...testTrigger, actions: ['delete'] }; - const target: DatabaseTrigger = { ...testTrigger, actions: ['delete', 'insert'] }; - const reason = `action is different (delete vs delete,insert)`; - expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); - }); - - it('should detect a change in timing', () => { - const source: DatabaseTrigger = { ...testTrigger, timing: 'before' }; - const target: DatabaseTrigger = { ...testTrigger, timing: 'after' }; - const reason = `timing method is different (before vs after)`; - expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); - }); - - it('should detect a change in scope', () => { - const source: DatabaseTrigger = { ...testTrigger, scope: 'row' }; - const target: DatabaseTrigger = { ...testTrigger, scope: 'statement' }; - const reason = `scope is different (row vs statement)`; - expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); - }); - - it('should detect a change in new table reference', () => { - const source: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: 'new_table' }; - const target: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: undefined }; - const reason = `new table reference is different (new_table vs undefined)`; - expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); - }); - - it('should detect a change in old table reference', () => { - const source: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: 'old_table' }; - const target: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: undefined }; - const reason = `old table reference is different (old_table vs undefined)`; - expect(compareTriggers().onCompare(source, target)).toEqual([{ type: 'TriggerCreate', trigger: source, reason }]); - }); - }); -}); diff --git a/server/src/sql-tools/comparers/trigger.comparer.ts b/server/src/sql-tools/comparers/trigger.comparer.ts deleted file mode 100644 index 4ba2d5dba3..0000000000 --- a/server/src/sql-tools/comparers/trigger.comparer.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Comparer, DatabaseTrigger, Reason } from 'src/sql-tools/types'; - -export const compareTriggers = (): Comparer => ({ - onMissing: (source) => [ - { - type: 'TriggerCreate', - trigger: source, - reason: Reason.MissingInTarget, - }, - ], - onExtra: (target) => [ - { - type: 'TriggerDrop', - tableName: target.tableName, - triggerName: target.name, - reason: Reason.MissingInSource, - }, - ], - onCompare: (source, target) => { - let reason = ''; - if (source.functionName !== target.functionName) { - reason = `function is different (${source.functionName} vs ${target.functionName})`; - } else if (source.actions.join(' OR ') !== target.actions.join(' OR ')) { - reason = `action is different (${source.actions} vs ${target.actions})`; - } else if (source.timing !== target.timing) { - reason = `timing method is different (${source.timing} vs ${target.timing})`; - } else if (source.scope !== target.scope) { - reason = `scope is different (${source.scope} vs ${target.scope})`; - } else if (source.referencingNewTableAs !== target.referencingNewTableAs) { - reason = `new table reference is different (${source.referencingNewTableAs} vs ${target.referencingNewTableAs})`; - } else if (source.referencingOldTableAs !== target.referencingOldTableAs) { - reason = `old table reference is different (${source.referencingOldTableAs} vs ${target.referencingOldTableAs})`; - } - - if (reason) { - return [{ type: 'TriggerCreate', trigger: source, reason }]; - } - - return []; - }, -}); diff --git a/server/src/sql-tools/contexts/base-context.ts b/server/src/sql-tools/contexts/base-context.ts deleted file mode 100644 index 0fa7230a00..0000000000 --- a/server/src/sql-tools/contexts/base-context.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { DefaultNamingStrategy } from 'src/sql-tools/naming/default.naming'; -import { HashNamingStrategy } from 'src/sql-tools/naming/hash.naming'; -import { NamingInterface, NamingItem } from 'src/sql-tools/naming/naming.interface'; -import { - BaseContextOptions, - DatabaseEnum, - DatabaseExtension, - DatabaseFunction, - DatabaseOverride, - DatabaseParameter, - DatabaseSchema, - DatabaseTable, -} from 'src/sql-tools/types'; - -const asOverrideKey = (type: string, name: string) => `${type}:${name}`; - -const isNamingInterface = (strategy: any): strategy is NamingInterface => { - return typeof strategy === 'object' && typeof strategy.getName === 'function'; -}; - -const asNamingStrategy = (strategy: 'hash' | 'default' | NamingInterface): NamingInterface => { - if (isNamingInterface(strategy)) { - return strategy; - } - - switch (strategy) { - case 'hash': { - return new HashNamingStrategy(); - } - - default: { - return new DefaultNamingStrategy(); - } - } -}; - -export class BaseContext { - databaseName: string; - schemaName: string; - overrideTableName: string; - - tables: DatabaseTable[] = []; - functions: DatabaseFunction[] = []; - enums: DatabaseEnum[] = []; - extensions: DatabaseExtension[] = []; - parameters: DatabaseParameter[] = []; - overrides: DatabaseOverride[] = []; - warnings: string[] = []; - - private namingStrategy: NamingInterface; - - constructor(options: BaseContextOptions) { - this.databaseName = options.databaseName ?? 'postgres'; - this.schemaName = options.schemaName ?? 'public'; - this.overrideTableName = options.overrideTableName ?? 'migration_overrides'; - this.namingStrategy = asNamingStrategy(options.namingStrategy ?? 'hash'); - } - - getNameFor(item: NamingItem) { - return this.namingStrategy.getName(item); - } - - getTableByName(name: string) { - return this.tables.find((table) => table.name === name); - } - - warn(context: string, message: string) { - this.warnings.push(`[${context}] ${message}`); - } - - build(): DatabaseSchema { - const overrideMap = new Map(); - for (const override of this.overrides) { - const { type, name } = override.value; - overrideMap.set(asOverrideKey(type, name), override); - } - - for (const func of this.functions) { - func.override = overrideMap.get(asOverrideKey('function', func.name)); - } - - for (const { indexes, triggers } of this.tables) { - for (const index of indexes) { - index.override = overrideMap.get(asOverrideKey('index', index.name)); - } - - for (const trigger of triggers) { - trigger.override = overrideMap.get(asOverrideKey('trigger', trigger.name)); - } - } - - return { - databaseName: this.databaseName, - schemaName: this.schemaName, - tables: this.tables, - functions: this.functions, - enums: this.enums, - extensions: this.extensions, - parameters: this.parameters, - overrides: this.overrides, - warnings: this.warnings, - }; - } -} diff --git a/server/src/sql-tools/contexts/processor-context.ts b/server/src/sql-tools/contexts/processor-context.ts deleted file mode 100644 index 3ab196b0af..0000000000 --- a/server/src/sql-tools/contexts/processor-context.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; -import { TableOptions } from 'src/sql-tools/decorators/table.decorator'; -import { DatabaseColumn, DatabaseTable, SchemaFromCodeOptions } from 'src/sql-tools/types'; - -type TableMetadata = { options: TableOptions; object: Function; methodToColumn: Map }; - -export class ProcessorContext extends BaseContext { - constructor(public options: SchemaFromCodeOptions) { - options.createForeignKeyIndexes = options.createForeignKeyIndexes ?? true; - options.overrides = options.overrides ?? false; - super(options); - } - - classToTable: WeakMap = new WeakMap(); - tableToMetadata: WeakMap = new WeakMap(); - - getTableByObject(object: Function) { - return this.classToTable.get(object); - } - - getTableMetadata(table: DatabaseTable) { - const metadata = this.tableToMetadata.get(table); - if (!metadata) { - throw new Error(`Table metadata not found for table: ${table.name}`); - } - return metadata; - } - - addTable(table: DatabaseTable, options: TableOptions, object: Function) { - this.tables.push(table); - this.classToTable.set(object, table); - this.tableToMetadata.set(table, { options, object, methodToColumn: new Map() }); - } - - getColumnByObjectAndPropertyName( - object: object, - propertyName: string | symbol, - ): { table?: DatabaseTable; column?: DatabaseColumn } { - const table = this.getTableByObject(object.constructor); - if (!table) { - return {}; - } - - const tableMetadata = this.tableToMetadata.get(table); - if (!tableMetadata) { - return {}; - } - - const column = tableMetadata.methodToColumn.get(propertyName); - - return { table, column }; - } - - addColumn(table: DatabaseTable, column: DatabaseColumn, options: ColumnOptions, propertyName: string | symbol) { - table.columns.push(column); - const tableMetadata = this.getTableMetadata(table); - tableMetadata.methodToColumn.set(propertyName, column); - } - - warnMissingTable(context: string, object: object, propertyName?: symbol | string) { - const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); - this.warn(context, `Unable to find table (${label})`); - } - - warnMissingColumn(context: string, object: object, propertyName?: symbol | string) { - const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); - this.warn(context, `Unable to find column (${label})`); - } -} diff --git a/server/src/sql-tools/contexts/reader-context.ts b/server/src/sql-tools/contexts/reader-context.ts deleted file mode 100644 index 94f5c82fc1..0000000000 --- a/server/src/sql-tools/contexts/reader-context.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { SchemaFromDatabaseOptions } from 'src/sql-tools/types'; - -export class ReaderContext extends BaseContext { - constructor(public options: SchemaFromDatabaseOptions) { - super(options); - } -} diff --git a/server/src/sql-tools/decorators/after-delete.decorator.ts b/server/src/sql-tools/decorators/after-delete.decorator.ts deleted file mode 100644 index 181bfab6c8..0000000000 --- a/server/src/sql-tools/decorators/after-delete.decorator.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/decorators/trigger-function.decorator'; - -export const AfterDeleteTrigger = (options: Omit) => - TriggerFunction({ - timing: 'after', - actions: ['delete'], - ...options, - }); diff --git a/server/src/sql-tools/decorators/after-insert.decorator.ts b/server/src/sql-tools/decorators/after-insert.decorator.ts deleted file mode 100644 index c302a5cebe..0000000000 --- a/server/src/sql-tools/decorators/after-insert.decorator.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/decorators/trigger-function.decorator'; - -export const AfterInsertTrigger = (options: Omit) => - TriggerFunction({ - timing: 'after', - actions: ['insert'], - ...options, - }); diff --git a/server/src/sql-tools/decorators/before-update.decorator.ts b/server/src/sql-tools/decorators/before-update.decorator.ts deleted file mode 100644 index 2119e29c9b..0000000000 --- a/server/src/sql-tools/decorators/before-update.decorator.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/decorators/trigger-function.decorator'; - -export const BeforeUpdateTrigger = (options: Omit) => - TriggerFunction({ - timing: 'before', - actions: ['update'], - ...options, - }); diff --git a/server/src/sql-tools/decorators/check.decorator.ts b/server/src/sql-tools/decorators/check.decorator.ts deleted file mode 100644 index 56fe1ecc3f..0000000000 --- a/server/src/sql-tools/decorators/check.decorator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { register } from 'src/sql-tools/register'; - -export type CheckOptions = { - name?: string; - expression: string; - synchronize?: boolean; -}; -export const Check = (options: CheckOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } }); -}; diff --git a/server/src/sql-tools/decorators/column.decorator.ts b/server/src/sql-tools/decorators/column.decorator.ts deleted file mode 100644 index e5a0eb52f8..0000000000 --- a/server/src/sql-tools/decorators/column.decorator.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { asOptions } from 'src/sql-tools/helpers'; -import { register } from 'src/sql-tools/register'; -import { ColumnStorage, ColumnType, DatabaseEnum } from 'src/sql-tools/types'; - -export type ColumnValue = null | boolean | string | number | Array | object | Date | (() => string); - -export type ColumnBaseOptions = { - name?: string; - primary?: boolean; - type?: ColumnType; - nullable?: boolean; - length?: number; - default?: ColumnValue; - comment?: string; - synchronize?: boolean; - storage?: ColumnStorage; - identity?: boolean; - index?: boolean; - indexName?: string; - unique?: boolean; - uniqueConstraintName?: string; -}; - -export type ColumnOptions = ColumnBaseOptions & { - enum?: DatabaseEnum; - array?: boolean; -}; - -export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => { - return (object: object, propertyName: string | symbol) => - void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } }); -}; diff --git a/server/src/sql-tools/decorators/configuration-parameter.decorator.ts b/server/src/sql-tools/decorators/configuration-parameter.decorator.ts deleted file mode 100644 index 953027d25c..0000000000 --- a/server/src/sql-tools/decorators/configuration-parameter.decorator.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ColumnValue } from 'src/sql-tools/decorators/column.decorator'; -import { register } from 'src/sql-tools/register'; -import { ParameterScope } from 'src/sql-tools/types'; - -export type ConfigurationParameterOptions = { - name: string; - value: ColumnValue; - scope: ParameterScope; - synchronize?: boolean; -}; -export const ConfigurationParameter = (options: ConfigurationParameterOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'configurationParameter', item: { object, options } }); -}; diff --git a/server/src/sql-tools/decorators/create-date-column.decorator.ts b/server/src/sql-tools/decorators/create-date-column.decorator.ts deleted file mode 100644 index 1a3362a614..0000000000 --- a/server/src/sql-tools/decorators/create-date-column.decorator.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Column, ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; - -export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - default: () => 'now()', - ...options, - }); -}; diff --git a/server/src/sql-tools/decorators/database.decorator.ts b/server/src/sql-tools/decorators/database.decorator.ts deleted file mode 100644 index 17b2460df6..0000000000 --- a/server/src/sql-tools/decorators/database.decorator.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { register } from 'src/sql-tools/register'; - -export type DatabaseOptions = { - name?: string; - synchronize?: boolean; -}; -export const Database = (options: DatabaseOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'database', item: { object, options } }); -}; diff --git a/server/src/sql-tools/decorators/delete-date-column.decorator.ts b/server/src/sql-tools/decorators/delete-date-column.decorator.ts deleted file mode 100644 index ca5427c27f..0000000000 --- a/server/src/sql-tools/decorators/delete-date-column.decorator.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Column, ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; - -export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - nullable: true, - ...options, - }); -}; diff --git a/server/src/sql-tools/decorators/extension.decorator.ts b/server/src/sql-tools/decorators/extension.decorator.ts deleted file mode 100644 index d431cbfd02..0000000000 --- a/server/src/sql-tools/decorators/extension.decorator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { asOptions } from 'src/sql-tools/helpers'; -import { register } from 'src/sql-tools/register'; - -export type ExtensionOptions = { - name: string; - synchronize?: boolean; -}; -export const Extension = (options: string | ExtensionOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'extension', item: { object, options: asOptions(options) } }); -}; diff --git a/server/src/sql-tools/decorators/extensions.decorator.ts b/server/src/sql-tools/decorators/extensions.decorator.ts deleted file mode 100644 index 724446c5fa..0000000000 --- a/server/src/sql-tools/decorators/extensions.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { asOptions } from 'src/sql-tools/helpers'; -import { register } from 'src/sql-tools/register'; - -export type ExtensionsOptions = { - name: string; - synchronize?: boolean; -}; -export const Extensions = (options: Array): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => { - for (const option of options) { - register({ type: 'extension', item: { object, options: asOptions(option) } }); - } - }; -}; diff --git a/server/src/sql-tools/decorators/foreign-key-column.decorator.ts b/server/src/sql-tools/decorators/foreign-key-column.decorator.ts deleted file mode 100644 index c9c83f010d..0000000000 --- a/server/src/sql-tools/decorators/foreign-key-column.decorator.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { ForeignKeyAction } from 'src/sql-tools//decorators/foreign-key-constraint.decorator'; -import { ColumnBaseOptions } from 'src/sql-tools/decorators/column.decorator'; -import { register } from 'src/sql-tools/register'; - -export type ForeignKeyColumnOptions = ColumnBaseOptions & { - onUpdate?: ForeignKeyAction; - onDelete?: ForeignKeyAction; - constraintName?: string; -}; - -export const ForeignKeyColumn = (target: () => Function, options: ForeignKeyColumnOptions): PropertyDecorator => { - return (object: object, propertyName: string | symbol) => { - register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } }); - }; -}; diff --git a/server/src/sql-tools/decorators/foreign-key-constraint.decorator.ts b/server/src/sql-tools/decorators/foreign-key-constraint.decorator.ts deleted file mode 100644 index e5d2f513dc..0000000000 --- a/server/src/sql-tools/decorators/foreign-key-constraint.decorator.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { register } from 'src/sql-tools/register'; - -export type ForeignKeyAction = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; - -export type ForeignKeyConstraintOptions = { - name?: string; - index?: boolean; - indexName?: string; - columns: string[]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - referenceTable: () => Function; - referenceColumns?: string[]; - onUpdate?: ForeignKeyAction; - onDelete?: ForeignKeyAction; - synchronize?: boolean; -}; - -export const ForeignKeyConstraint = (options: ForeignKeyConstraintOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (target: Function) => { - register({ type: 'foreignKeyConstraint', item: { object: target, options } }); - }; -}; diff --git a/server/src/sql-tools/decorators/generated-column.decorator.ts b/server/src/sql-tools/decorators/generated-column.decorator.ts deleted file mode 100644 index 4338b4146c..0000000000 --- a/server/src/sql-tools/decorators/generated-column.decorator.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Column, ColumnOptions, ColumnValue } from 'src/sql-tools/decorators/column.decorator'; -import { ColumnType } from 'src/sql-tools/types'; - -export type GeneratedColumnStrategy = 'uuid' | 'identity'; - -export type GenerateColumnOptions = Omit & { - strategy?: GeneratedColumnStrategy; -}; - -export const GeneratedColumn = ({ strategy = 'uuid', ...options }: GenerateColumnOptions): PropertyDecorator => { - let columnType: ColumnType | undefined; - let columnDefault: ColumnValue | undefined; - - switch (strategy) { - case 'uuid': { - columnType = 'uuid'; - columnDefault = () => 'uuid_generate_v4()'; - break; - } - - case 'identity': { - columnType = 'integer'; - options.identity = true; - break; - } - - default: { - throw new Error(`Unsupported strategy for @GeneratedColumn ${strategy}`); - } - } - - return Column({ - type: columnType, - default: columnDefault, - ...options, - }); -}; diff --git a/server/src/sql-tools/decorators/index.decorator.ts b/server/src/sql-tools/decorators/index.decorator.ts deleted file mode 100644 index 1b6d38e390..0000000000 --- a/server/src/sql-tools/decorators/index.decorator.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { asOptions } from 'src/sql-tools/helpers'; -import { register } from 'src/sql-tools/register'; - -export type IndexOptions = { - name?: string; - unique?: boolean; - expression?: string; - using?: string; - with?: string; - where?: string; - columns?: string[]; - synchronize?: boolean; -}; -export const Index = (options: string | IndexOptions = {}): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } }); -}; diff --git a/server/src/sql-tools/decorators/primary-column.decorator.ts b/server/src/sql-tools/decorators/primary-column.decorator.ts deleted file mode 100644 index e605b4be5d..0000000000 --- a/server/src/sql-tools/decorators/primary-column.decorator.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Column, ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; - -export const PrimaryColumn = (options: Omit = {}) => Column({ ...options, primary: true }); diff --git a/server/src/sql-tools/decorators/primary-generated-column.decorator.ts b/server/src/sql-tools/decorators/primary-generated-column.decorator.ts deleted file mode 100644 index 25e125ebf6..0000000000 --- a/server/src/sql-tools/decorators/primary-generated-column.decorator.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { GenerateColumnOptions, GeneratedColumn } from 'src/sql-tools/decorators/generated-column.decorator'; - -export const PrimaryGeneratedColumn = (options: Omit = {}) => - GeneratedColumn({ ...options, primary: true }); diff --git a/server/src/sql-tools/decorators/table.decorator.ts b/server/src/sql-tools/decorators/table.decorator.ts deleted file mode 100644 index 7ea5882147..0000000000 --- a/server/src/sql-tools/decorators/table.decorator.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { asOptions } from 'src/sql-tools/helpers'; -import { register } from 'src/sql-tools/register'; - -export type TableOptions = { - name?: string; - primaryConstraintName?: string; - synchronize?: boolean; -}; - -/** Table comments here */ -export const Table = (options: string | TableOptions = {}): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } }); -}; diff --git a/server/src/sql-tools/decorators/trigger-function.decorator.ts b/server/src/sql-tools/decorators/trigger-function.decorator.ts deleted file mode 100644 index 17016f7946..0000000000 --- a/server/src/sql-tools/decorators/trigger-function.decorator.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Trigger, TriggerOptions } from 'src/sql-tools/decorators/trigger.decorator'; -import { DatabaseFunction } from 'src/sql-tools/types'; - -export type TriggerFunctionOptions = Omit & { function: DatabaseFunction }; -export const TriggerFunction = (options: TriggerFunctionOptions) => - Trigger({ - name: options.function.name, - ...options, - functionName: options.function.name, - }); diff --git a/server/src/sql-tools/decorators/trigger.decorator.ts b/server/src/sql-tools/decorators/trigger.decorator.ts deleted file mode 100644 index ce9a5c17f7..0000000000 --- a/server/src/sql-tools/decorators/trigger.decorator.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { register } from 'src/sql-tools/register'; -import { TriggerAction, TriggerScope, TriggerTiming } from 'src/sql-tools/types'; - -export type TriggerOptions = { - name?: string; - timing: TriggerTiming; - actions: TriggerAction[]; - scope: TriggerScope; - functionName: string; - referencingNewTableAs?: string; - referencingOldTableAs?: string; - when?: string; - synchronize?: boolean; -}; - -export const Trigger = (options: TriggerOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'trigger', item: { object, options } }); -}; diff --git a/server/src/sql-tools/decorators/unique.decorator.ts b/server/src/sql-tools/decorators/unique.decorator.ts deleted file mode 100644 index 1f61fccb6f..0000000000 --- a/server/src/sql-tools/decorators/unique.decorator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { register } from 'src/sql-tools/register'; - -export type UniqueOptions = { - name?: string; - columns: string[]; - synchronize?: boolean; -}; -export const Unique = (options: UniqueOptions): ClassDecorator => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } }); -}; diff --git a/server/src/sql-tools/decorators/update-date-column.decorator.ts b/server/src/sql-tools/decorators/update-date-column.decorator.ts deleted file mode 100644 index 68dd50c617..0000000000 --- a/server/src/sql-tools/decorators/update-date-column.decorator.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Column, ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; - -export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - default: () => 'now()', - ...options, - }); -}; diff --git a/server/src/sql-tools/helpers.ts b/server/src/sql-tools/helpers.ts deleted file mode 100644 index e0daf8262f..0000000000 --- a/server/src/sql-tools/helpers.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { createHash } from 'node:crypto'; -import { ColumnValue } from 'src/sql-tools/decorators/column.decorator'; -import { Comparer, DatabaseColumn, DatabaseOverride, IgnoreOptions, SchemaDiff } from 'src/sql-tools/types'; - -export const asOptions = (options: string | T): T => { - if (typeof options === 'string') { - return { name: options } as T; - } - - return options; -}; - -export const sha1 = (value: string) => createHash('sha1').update(value).digest('hex'); - -export const fromColumnValue = (columnValue?: ColumnValue) => { - if (columnValue === undefined) { - return; - } - - if (typeof columnValue === 'function') { - return columnValue() as string; - } - - const value = columnValue; - - if (value === null) { - return value; - } - - if (typeof value === 'number') { - return String(value); - } - - if (typeof value === 'boolean') { - return value ? 'true' : 'false'; - } - - if (value instanceof Date) { - return `'${value.toISOString()}'`; - } - - if (Array.isArray(value)) { - return "'{}'"; - } - - return `'${String(value)}'`; -}; - -export const setIsEqual = (source: Set, target: Set) => - source.size === target.size && [...source].every((x) => target.has(x)); - -export const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => { - return setIsEqual(new Set(sourceColumns), new Set(targetColumns)); -}; - -export const haveEqualOverrides = (source: T, target: T) => { - if (!source.override || !target.override) { - return false; - } - - const sourceValue = source.override.value; - const targetValue = target.override.value; - - return sourceValue.name === targetValue.name && sourceValue.sql === targetValue.sql; -}; - -export const compare = ( - sources: T[], - targets: T[], - options: IgnoreOptions | undefined, - comparer: Comparer, -) => { - options = options || {}; - const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table])); - const targetMap = Object.fromEntries(targets.map((table) => [table.name, table])); - const items: SchemaDiff[] = []; - - const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); - const missingKeys = new Set(); - const extraKeys = new Set(); - - // common keys - for (const key of keys) { - const source = sourceMap[key]; - const target = targetMap[key]; - - if (isIgnored(source, target, options ?? true)) { - continue; - } - - if (isSynchronizeDisabled(source, target)) { - continue; - } - - if (source && !target) { - missingKeys.add(key); - continue; - } - - if (!source && target) { - extraKeys.add(key); - continue; - } - - if ( - haveEqualOverrides( - source as unknown as { override?: DatabaseOverride }, - target as unknown as { override?: DatabaseOverride }, - ) - ) { - continue; - } - - items.push(...comparer.onCompare(source, target)); - } - - // renames - if (comparer.getRenameKey && comparer.onRename) { - const renameMap: Record = {}; - for (const sourceKey of missingKeys) { - const source = sourceMap[sourceKey]; - const renameKey = comparer.getRenameKey(source); - renameMap[renameKey] = sourceKey; - } - - for (const targetKey of extraKeys) { - const target = targetMap[targetKey]; - const renameKey = comparer.getRenameKey(target); - const sourceKey = renameMap[renameKey]; - if (!sourceKey) { - continue; - } - - const source = sourceMap[sourceKey]; - - items.push(...comparer.onRename(source, target)); - - missingKeys.delete(sourceKey); - extraKeys.delete(targetKey); - } - } - - // missing - for (const key of missingKeys) { - items.push(...comparer.onMissing(sourceMap[key])); - } - - // extra - for (const key of extraKeys) { - items.push(...comparer.onExtra(targetMap[key])); - } - - return items; -}; - -const isIgnored = ( - source: { synchronize?: boolean } | undefined, - target: { synchronize?: boolean } | undefined, - options: IgnoreOptions, -) => { - if (typeof options === 'boolean') { - return !options; - } - return (options.ignoreExtra && !source) || (options.ignoreMissing && !target); -}; - -const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => { - return source?.synchronize === false || target?.synchronize === false; -}; - -export const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) => { - if (source.default === target.default) { - return true; - } - - if (source.default === undefined || target.default === undefined) { - return false; - } - - if ( - withTypeCast(source.default, getColumnType(source)) === target.default || - withTypeCast(target.default, getColumnType(target)) === source.default - ) { - return true; - } - - return false; -}; - -export const getColumnType = (column: DatabaseColumn) => { - let type = column.enumName || column.type; - if (column.isArray) { - type += `[${column.length ?? ''}]`; - } else if (column.length !== undefined) { - type += `(${column.length})`; - } - - return type; -}; - -const withTypeCast = (value: string, type: string) => { - if (!value.startsWith(`'`)) { - value = `'${value}'`; - } - return `${value}::${type}`; -}; - -export const getColumnModifiers = (column: DatabaseColumn) => { - const modifiers: string[] = []; - - if (!column.nullable) { - modifiers.push('NOT NULL'); - } - - if (column.default) { - modifiers.push(`DEFAULT ${column.default}`); - } - if (column.identity) { - modifiers.push(`GENERATED ALWAYS AS IDENTITY`); - } - - return modifiers.length === 0 ? '' : ' ' + modifiers.join(' '); -}; - -export const asColumnComment = (tableName: string, columnName: string, comment: string): string => { - return `COMMENT ON COLUMN "${tableName}"."${columnName}" IS '${comment}';`; -}; - -export const asColumnList = (columns: string[]) => columns.map((column) => `"${column}"`).join(', '); - -export const asJsonString = (value: unknown): string => { - return `'${escape(JSON.stringify(value))}'::jsonb`; -}; - -const escape = (value: string) => { - return value - .replaceAll("'", "''") - .replaceAll(/[\\]/g, '\\\\') - .replaceAll(/[\b]/g, String.raw`\b`) - .replaceAll(/[\f]/g, String.raw`\f`) - .replaceAll(/[\n]/g, String.raw`\n`) - .replaceAll(/[\r]/g, String.raw`\r`) - .replaceAll(/[\t]/g, String.raw`\t`); -}; - -export const asRenameKey = (values: Array) => - values.map((value) => value ?? '').join('|'); diff --git a/server/src/sql-tools/index.ts b/server/src/sql-tools/index.ts deleted file mode 100644 index 0d3e53df51..0000000000 --- a/server/src/sql-tools/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'src/sql-tools/public_api'; diff --git a/server/src/sql-tools/naming/default.naming.ts b/server/src/sql-tools/naming/default.naming.ts deleted file mode 100644 index 807580169d..0000000000 --- a/server/src/sql-tools/naming/default.naming.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { sha1 } from 'src/sql-tools/helpers'; -import { NamingItem } from 'src/sql-tools/naming/naming.interface'; - -const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); - -export class DefaultNamingStrategy { - getName(item: NamingItem): string { - switch (item.type) { - case 'database': { - return asSnakeCase(item.name); - } - - case 'table': { - return asSnakeCase(item.name); - } - - case 'column': { - return item.name; - } - - case 'primaryKey': { - return `${item.tableName}_pkey`; - } - - case 'foreignKey': { - return `${item.tableName}_${item.columnNames.join('_')}_fkey`; - } - - case 'check': { - return `${item.tableName}_${sha1(item.expression).slice(0, 8)}_chk`; - } - - case 'unique': { - return `${item.tableName}_${item.columnNames.join('_')}_uq`; - } - - case 'index': { - if (item.columnNames) { - return `${item.tableName}_${item.columnNames.join('_')}_idx`; - } - - return `${item.tableName}_${sha1(item.expression || item.where || '').slice(0, 8)}_idx`; - } - - case 'trigger': { - return `${item.tableName}_${item.functionName}`; - } - } - } -} diff --git a/server/src/sql-tools/naming/hash.naming.ts b/server/src/sql-tools/naming/hash.naming.ts deleted file mode 100644 index 575d0f1239..0000000000 --- a/server/src/sql-tools/naming/hash.naming.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { sha1 } from 'src/sql-tools/helpers'; -import { DefaultNamingStrategy } from 'src/sql-tools/naming/default.naming'; -import { NamingInterface, NamingItem } from 'src/sql-tools/naming/naming.interface'; - -const fallback = new DefaultNamingStrategy(); - -const asKey = (prefix: string, tableName: string, values: string[]) => - (prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30); - -export class HashNamingStrategy implements NamingInterface { - getName(item: NamingItem): string { - switch (item.type) { - case 'primaryKey': { - return asKey('PK_', item.tableName, item.columnNames); - } - - case 'foreignKey': { - return asKey('FK_', item.tableName, item.columnNames); - } - - case 'check': { - return asKey('CHK_', item.tableName, [item.expression]); - } - - case 'unique': { - return asKey('UQ_', item.tableName, item.columnNames); - } - - case 'index': { - const items: string[] = []; - for (const columnName of item.columnNames ?? []) { - items.push(columnName); - } - - if (item.where) { - items.push(item.where); - } - - return asKey('IDX_', item.tableName, items); - } - - case 'trigger': { - return asKey('TR_', item.tableName, [...item.actions, item.scope, item.timing, item.functionName]); - } - - default: { - return fallback.getName(item); - } - } - } -} diff --git a/server/src/sql-tools/naming/naming.interface.ts b/server/src/sql-tools/naming/naming.interface.ts deleted file mode 100644 index f331a22c46..0000000000 --- a/server/src/sql-tools/naming/naming.interface.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { TriggerAction, TriggerScope, TriggerTiming } from 'src/sql-tools/types'; - -export type NamingItem = - | { - type: 'database'; - name: string; - } - | { - type: 'table'; - name: string; - } - | { - type: 'column'; - name: string; - } - | { - type: 'primaryKey'; - tableName: string; - columnNames: string[]; - } - | { - type: 'foreignKey'; - tableName: string; - columnNames: string[]; - referenceTableName: string; - referenceColumnNames: string[]; - } - | { - type: 'check'; - tableName: string; - expression: string; - } - | { - type: 'unique'; - tableName: string; - columnNames: string[]; - } - | { - type: 'index'; - tableName: string; - columnNames?: string[]; - expression?: string; - where?: string; - } - | { - type: 'trigger'; - tableName: string; - functionName: string; - actions: TriggerAction[]; - scope: TriggerScope; - timing: TriggerTiming; - columnNames?: string[]; - expression?: string; - where?: string; - }; - -export interface NamingInterface { - getName(item: NamingItem): string; -} diff --git a/server/src/sql-tools/processors/check-constraint.processor.ts b/server/src/sql-tools/processors/check-constraint.processor.ts deleted file mode 100644 index 5eba1015bf..0000000000 --- a/server/src/sql-tools/processors/check-constraint.processor.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ConstraintType, Processor } from 'src/sql-tools/types'; - -export const processCheckConstraints: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'checkConstraint')) { - const table = ctx.getTableByObject(object); - if (!table) { - ctx.warnMissingTable('@Check', object); - continue; - } - - const tableName = table.name; - - table.constraints.push({ - type: ConstraintType.CHECK, - name: options.name || ctx.getNameFor({ type: 'check', tableName, expression: options.expression }), - tableName, - expression: options.expression, - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/processors/column.processor.ts b/server/src/sql-tools/processors/column.processor.ts deleted file mode 100644 index 9b499b380b..0000000000 --- a/server/src/sql-tools/processors/column.processor.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; -import { fromColumnValue } from 'src/sql-tools/helpers'; -import { Processor } from 'src/sql-tools/types'; - -export const processColumns: Processor = (ctx, items) => { - for (const { - type, - item: { object, propertyName, options }, - } of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) { - const table = ctx.getTableByObject(object.constructor); - if (!table) { - ctx.warnMissingTable(type === 'column' ? '@Column' : '@ForeignKeyColumn', object, propertyName); - continue; - } - - const columnName = options.name ?? ctx.getNameFor({ type: 'column', name: String(propertyName) }); - const existingColumn = table.columns.find((column) => column.name === columnName); - if (existingColumn) { - // TODO log warnings if column name is not unique - continue; - } - - let defaultValue = fromColumnValue(options.default); - let nullable = options.nullable ?? false; - - // map `{ default: null }` to `{ nullable: true }` - if (defaultValue === null) { - nullable = true; - defaultValue = undefined; - } - - const isEnum = !!(options as ColumnOptions).enum; - - ctx.addColumn( - table, - { - name: columnName, - tableName: table.name, - primary: options.primary ?? false, - default: defaultValue, - nullable, - isArray: (options as ColumnOptions).array ?? false, - length: options.length, - type: isEnum ? 'enum' : options.type || 'character varying', - enumName: isEnum ? (options as ColumnOptions).enum!.name : undefined, - comment: options.comment, - storage: options.storage, - identity: options.identity, - synchronize: options.synchronize ?? true, - }, - options, - propertyName, - ); - } -}; diff --git a/server/src/sql-tools/processors/configuration-parameter.processor.ts b/server/src/sql-tools/processors/configuration-parameter.processor.ts deleted file mode 100644 index dbb5cd4636..0000000000 --- a/server/src/sql-tools/processors/configuration-parameter.processor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { fromColumnValue } from 'src/sql-tools/helpers'; -import { Processor } from 'src/sql-tools/types'; - -export const processConfigurationParameters: Processor = (ctx, items) => { - for (const { - item: { options }, - } of items.filter((item) => item.type === 'configurationParameter')) { - ctx.parameters.push({ - databaseName: ctx.databaseName, - name: options.name, - value: fromColumnValue(options.value), - scope: options.scope, - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/processors/database.processor.ts b/server/src/sql-tools/processors/database.processor.ts deleted file mode 100644 index 9f2e847fd6..0000000000 --- a/server/src/sql-tools/processors/database.processor.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processDatabases: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'database')) { - ctx.databaseName = options.name || ctx.getNameFor({ type: 'database', name: object.name }); - } -}; diff --git a/server/src/sql-tools/processors/enum.processor.ts b/server/src/sql-tools/processors/enum.processor.ts deleted file mode 100644 index 1ef65231c9..0000000000 --- a/server/src/sql-tools/processors/enum.processor.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processEnums: Processor = (ctx, items) => { - for (const { item } of items.filter((item) => item.type === 'enum')) { - // TODO log warnings if enum name is not unique - ctx.enums.push(item); - } -}; diff --git a/server/src/sql-tools/processors/extension.processor.ts b/server/src/sql-tools/processors/extension.processor.ts deleted file mode 100644 index 068c66883c..0000000000 --- a/server/src/sql-tools/processors/extension.processor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processExtensions: Processor = (ctx, items) => { - if (ctx.options.extensions === false) { - return; - } - - for (const { - item: { options }, - } of items.filter((item) => item.type === 'extension')) { - ctx.extensions.push({ - name: options.name, - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/processors/foreign-key-column.processor.ts b/server/src/sql-tools/processors/foreign-key-column.processor.ts deleted file mode 100644 index 6d147a78eb..0000000000 --- a/server/src/sql-tools/processors/foreign-key-column.processor.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ActionType, ConstraintType, Processor } from 'src/sql-tools/types'; - -export const processForeignKeyColumns: Processor = (ctx, items) => { - for (const { - item: { object, propertyName, options, target }, - } of items.filter((item) => item.type === 'foreignKeyColumn')) { - const { table, column } = ctx.getColumnByObjectAndPropertyName(object, propertyName); - if (!table) { - ctx.warnMissingTable('@ForeignKeyColumn', object); - continue; - } - - if (!column) { - // should be impossible since they are pre-created in `column.processor.ts` - ctx.warnMissingColumn('@ForeignKeyColumn', object, propertyName); - continue; - } - - const referenceTable = ctx.getTableByObject(target()); - if (!referenceTable) { - ctx.warnMissingTable('@ForeignKeyColumn', object, propertyName); - continue; - } - - const columnNames = [column.name]; - const referenceColumns = referenceTable.columns.filter((column) => column.primary); - - // infer FK column type from reference table - if (referenceColumns.length === 1) { - column.type = referenceColumns[0].type; - } - - const referenceTableName = referenceTable.name; - const referenceColumnNames = referenceColumns.map((column) => column.name); - const name = - options.constraintName || - ctx.getNameFor({ - type: 'foreignKey', - tableName: table.name, - columnNames, - referenceTableName, - referenceColumnNames, - }); - - table.constraints.push({ - name, - tableName: table.name, - columnNames, - type: ConstraintType.FOREIGN_KEY, - referenceTableName, - referenceColumnNames, - onUpdate: options.onUpdate as ActionType, - onDelete: options.onDelete as ActionType, - synchronize: options.synchronize ?? true, - }); - - if (options.unique || options.uniqueConstraintName) { - table.constraints.push({ - name: options.uniqueConstraintName || ctx.getNameFor({ type: 'unique', tableName: table.name, columnNames }), - tableName: table.name, - columnNames, - type: ConstraintType.UNIQUE, - synchronize: options.synchronize ?? true, - }); - } - } -}; diff --git a/server/src/sql-tools/processors/foreign-key-constraint.processor.ts b/server/src/sql-tools/processors/foreign-key-constraint.processor.ts deleted file mode 100644 index 39d7508d11..0000000000 --- a/server/src/sql-tools/processors/foreign-key-constraint.processor.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { ActionType, ConstraintType, Processor } from 'src/sql-tools/types'; - -export const processForeignKeyConstraints: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'foreignKeyConstraint')) { - const table = ctx.getTableByObject(object); - if (!table) { - ctx.warnMissingTable('@ForeignKeyConstraint', { name: 'referenceTable' }); - continue; - } - - const referenceTable = ctx.getTableByObject(options.referenceTable()); - if (!referenceTable) { - const referenceTableName = options.referenceTable()?.name; - ctx.warn( - '@ForeignKeyConstraint.referenceTable', - `Unable to find table` + (referenceTableName ? ` (${referenceTableName})` : ''), - ); - continue; - } - - let missingColumn = false; - - for (const columnName of options.columns) { - if (!table.columns.some(({ name }) => name === columnName)) { - const metadata = ctx.getTableMetadata(table); - ctx.warn('@ForeignKeyConstraint.columns', `Unable to find column (${metadata.object.name}.${columnName})`); - missingColumn = true; - } - } - - for (const columnName of options.referenceColumns || []) { - if (!referenceTable.columns.some(({ name }) => name === columnName)) { - const metadata = ctx.getTableMetadata(referenceTable); - ctx.warn( - '@ForeignKeyConstraint.referenceColumns', - `Unable to find column (${metadata.object.name}.${columnName})`, - ); - missingColumn = true; - } - } - - if (missingColumn) { - continue; - } - - const referenceTableName = referenceTable.name; - const referenceColumnNames = - options.referenceColumns || referenceTable.columns.filter(({ primary }) => primary).map(({ name }) => name); - - const name = - options.name || - ctx.getNameFor({ - type: 'foreignKey', - tableName: table.name, - columnNames: options.columns, - referenceTableName, - referenceColumnNames, - }); - - table.constraints.push({ - type: ConstraintType.FOREIGN_KEY, - name, - tableName: table.name, - columnNames: options.columns, - referenceTableName, - referenceColumnNames, - onUpdate: options.onUpdate as ActionType, - onDelete: options.onDelete as ActionType, - synchronize: options.synchronize ?? true, - }); - - if (options.index === false) { - continue; - } - - if (options.index || options.indexName || ctx.options.createForeignKeyIndexes) { - const indexName = - options.indexName || - ctx.getNameFor({ - type: 'index', - tableName: table.name, - columnNames: options.columns, - }); - table.indexes.push({ - name: indexName, - tableName: table.name, - columnNames: options.columns, - unique: false, - synchronize: options.synchronize ?? true, - }); - } - } -}; diff --git a/server/src/sql-tools/processors/function.processor.ts b/server/src/sql-tools/processors/function.processor.ts deleted file mode 100644 index 9b351b77f7..0000000000 --- a/server/src/sql-tools/processors/function.processor.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processFunctions: Processor = (ctx, items) => { - if (ctx.options.functions === false) { - return; - } - - for (const { item } of items.filter((item) => item.type === 'function')) { - // TODO log warnings if function name is not unique - ctx.functions.push(item); - } -}; diff --git a/server/src/sql-tools/processors/index.processor.ts b/server/src/sql-tools/processors/index.processor.ts deleted file mode 100644 index 766e83fe8b..0000000000 --- a/server/src/sql-tools/processors/index.processor.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processIndexes: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'index')) { - const table = ctx.getTableByObject(object); - if (!table) { - ctx.warnMissingTable('@Check', object); - continue; - } - - const indexName = - options.name || - ctx.getNameFor({ - type: 'index', - tableName: table.name, - columnNames: options.columns, - where: options.where, - }); - - table.indexes.push({ - name: indexName, - tableName: table.name, - unique: options.unique ?? false, - expression: options.expression, - using: options.using, - with: options.with, - where: options.where, - columnNames: options.columns, - synchronize: options.synchronize ?? true, - }); - } - - // column indexes - for (const { - type, - item: { object, propertyName, options }, - } of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) { - const { table, column } = ctx.getColumnByObjectAndPropertyName(object, propertyName); - if (!table) { - ctx.warnMissingTable('@Column', object); - continue; - } - - if (!column) { - // should be impossible since they are created in `column.processor.ts` - ctx.warnMissingColumn('@Column', object, propertyName); - continue; - } - - if (options.index === false) { - continue; - } - - const isIndexRequested = - options.indexName || options.index || (type === 'foreignKeyColumn' && ctx.options.createForeignKeyIndexes); - if (!isIndexRequested) { - continue; - } - - const indexName = - options.indexName || - ctx.getNameFor({ - type: 'index', - tableName: table.name, - columnNames: [column.name], - }); - - const isIndexPresent = table.indexes.some((index) => index.name === indexName); - if (isIndexPresent) { - continue; - } - - const isOnlyPrimaryColumn = options.primary && table.columns.filter(({ primary }) => primary === true).length === 1; - if (isOnlyPrimaryColumn) { - // will have an index created by the primary key constraint - continue; - } - - table.indexes.push({ - name: indexName, - tableName: table.name, - unique: false, - columnNames: [column.name], - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/processors/index.ts b/server/src/sql-tools/processors/index.ts deleted file mode 100644 index feb0a82f05..0000000000 --- a/server/src/sql-tools/processors/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { processCheckConstraints } from 'src/sql-tools/processors/check-constraint.processor'; -import { processColumns } from 'src/sql-tools/processors/column.processor'; -import { processConfigurationParameters } from 'src/sql-tools/processors/configuration-parameter.processor'; -import { processDatabases } from 'src/sql-tools/processors/database.processor'; -import { processEnums } from 'src/sql-tools/processors/enum.processor'; -import { processExtensions } from 'src/sql-tools/processors/extension.processor'; -import { processForeignKeyColumns } from 'src/sql-tools/processors/foreign-key-column.processor'; -import { processForeignKeyConstraints } from 'src/sql-tools/processors/foreign-key-constraint.processor'; -import { processFunctions } from 'src/sql-tools/processors/function.processor'; -import { processIndexes } from 'src/sql-tools/processors/index.processor'; -import { processOverrides } from 'src/sql-tools/processors/override.processor'; -import { processPrimaryKeyConstraints } from 'src/sql-tools/processors/primary-key-contraint.processor'; -import { processTables } from 'src/sql-tools/processors/table.processor'; -import { processTriggers } from 'src/sql-tools/processors/trigger.processor'; -import { processUniqueConstraints } from 'src/sql-tools/processors/unique-constraint.processor'; -import { Processor } from 'src/sql-tools/types'; - -export const processors: Processor[] = [ - processDatabases, - processConfigurationParameters, - processEnums, - processExtensions, - processFunctions, - processTables, - processColumns, - processForeignKeyColumns, - processForeignKeyConstraints, - processUniqueConstraints, - processCheckConstraints, - processPrimaryKeyConstraints, - processIndexes, - processTriggers, - processOverrides, -]; diff --git a/server/src/sql-tools/processors/override.processor.ts b/server/src/sql-tools/processors/override.processor.ts deleted file mode 100644 index 67b92fbd40..0000000000 --- a/server/src/sql-tools/processors/override.processor.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { asFunctionCreate } from 'src/sql-tools/transformers/function.transformer'; -import { asIndexCreate } from 'src/sql-tools/transformers/index.transformer'; -import { asTriggerCreate } from 'src/sql-tools/transformers/trigger.transformer'; -import { Processor } from 'src/sql-tools/types'; - -export const processOverrides: Processor = (ctx) => { - if (ctx.options.overrides === false) { - return; - } - - for (const func of ctx.functions) { - if (!func.synchronize) { - continue; - } - - ctx.overrides.push({ - name: `function_${func.name}`, - value: { type: 'function', name: func.name, sql: asFunctionCreate(func) }, - synchronize: true, - }); - } - - for (const { triggers, indexes } of ctx.tables) { - for (const trigger of triggers) { - if (!trigger.synchronize) { - continue; - } - - ctx.overrides.push({ - name: `trigger_${trigger.name}`, - value: { type: 'trigger', name: trigger.name, sql: asTriggerCreate(trigger) }, - synchronize: true, - }); - } - - for (const index of indexes) { - if (!index.synchronize) { - continue; - } - - if (index.expression || index.using || index.with || index.where) { - ctx.overrides.push({ - name: `index_${index.name}`, - value: { type: 'index', name: index.name, sql: asIndexCreate(index) }, - synchronize: true, - }); - } - } - } -}; diff --git a/server/src/sql-tools/processors/primary-key-contraint.processor.ts b/server/src/sql-tools/processors/primary-key-contraint.processor.ts deleted file mode 100644 index 0971bfc337..0000000000 --- a/server/src/sql-tools/processors/primary-key-contraint.processor.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ConstraintType, Processor } from 'src/sql-tools/types'; - -export const processPrimaryKeyConstraints: Processor = (ctx) => { - for (const table of ctx.tables) { - const columnNames: string[] = []; - - for (const column of table.columns) { - if (column.primary) { - columnNames.push(column.name); - } - } - - if (columnNames.length > 0) { - const tableMetadata = ctx.getTableMetadata(table); - table.constraints.push({ - type: ConstraintType.PRIMARY_KEY, - name: - tableMetadata.options.primaryConstraintName || - ctx.getNameFor({ - type: 'primaryKey', - tableName: table.name, - columnNames, - }), - tableName: table.name, - columnNames, - synchronize: tableMetadata.options.synchronize ?? true, - }); - } - } -}; diff --git a/server/src/sql-tools/processors/table.processor.ts b/server/src/sql-tools/processors/table.processor.ts deleted file mode 100644 index 993c9ec45d..0000000000 --- a/server/src/sql-tools/processors/table.processor.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processTables: Processor = (ctx, items) => { - for (const { - item: { options, object }, - } of items.filter((item) => item.type === 'table')) { - const test = ctx.getTableByObject(object); - if (test) { - throw new Error( - `Table ${test.name} has already been registered. Does ${object.name} have two @Table() decorators?`, - ); - } - - ctx.addTable( - { - name: options.name || ctx.getNameFor({ type: 'table', name: object.name }), - columns: [], - constraints: [], - indexes: [], - triggers: [], - synchronize: options.synchronize ?? true, - }, - options, - object, - ); - } -}; diff --git a/server/src/sql-tools/processors/trigger.processor.ts b/server/src/sql-tools/processors/trigger.processor.ts deleted file mode 100644 index b50b42cc49..0000000000 --- a/server/src/sql-tools/processors/trigger.processor.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Processor } from 'src/sql-tools/types'; - -export const processTriggers: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'trigger')) { - const table = ctx.getTableByObject(object); - if (!table) { - ctx.warnMissingTable('@Trigger', object); - continue; - } - - const triggerName = - options.name || - ctx.getNameFor({ - type: 'trigger', - tableName: table.name, - actions: options.actions, - scope: options.scope, - timing: options.timing, - functionName: options.functionName, - }); - - table.triggers.push({ - name: triggerName, - tableName: table.name, - timing: options.timing, - actions: options.actions, - when: options.when, - scope: options.scope, - referencingNewTableAs: options.referencingNewTableAs, - referencingOldTableAs: options.referencingOldTableAs, - functionName: options.functionName, - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/processors/unique-constraint.processor.ts b/server/src/sql-tools/processors/unique-constraint.processor.ts deleted file mode 100644 index 0cbfc26a70..0000000000 --- a/server/src/sql-tools/processors/unique-constraint.processor.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ConstraintType, Processor } from 'src/sql-tools/types'; - -export const processUniqueConstraints: Processor = (ctx, items) => { - for (const { - item: { object, options }, - } of items.filter((item) => item.type === 'uniqueConstraint')) { - const table = ctx.getTableByObject(object); - if (!table) { - ctx.warnMissingTable('@Unique', object); - continue; - } - - const tableName = table.name; - const columnNames = options.columns; - - table.constraints.push({ - type: ConstraintType.UNIQUE, - name: options.name || ctx.getNameFor({ type: 'unique', tableName, columnNames }), - tableName, - columnNames, - synchronize: options.synchronize ?? true, - }); - } - - // column level constraints - for (const { - type, - item: { object, propertyName, options }, - } of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) { - const { table, column } = ctx.getColumnByObjectAndPropertyName(object, propertyName); - if (!table) { - ctx.warnMissingTable('@Column', object); - continue; - } - - if (!column) { - // should be impossible since they are created in `column.processor.ts` - ctx.warnMissingColumn('@Column', object, propertyName); - continue; - } - - if (type === 'column' && !options.primary && (options.unique || options.uniqueConstraintName)) { - const uniqueConstraintName = - options.uniqueConstraintName || - ctx.getNameFor({ - type: 'unique', - tableName: table.name, - columnNames: [column.name], - }); - - table.constraints.push({ - type: ConstraintType.UNIQUE, - name: uniqueConstraintName, - tableName: table.name, - columnNames: [column.name], - synchronize: options.synchronize ?? true, - }); - } - } -}; diff --git a/server/src/sql-tools/public_api.ts b/server/src/sql-tools/public_api.ts deleted file mode 100644 index 9e7983383e..0000000000 --- a/server/src/sql-tools/public_api.ts +++ /dev/null @@ -1,31 +0,0 @@ -export * from 'src/sql-tools/decorators/after-delete.decorator'; -export * from 'src/sql-tools/decorators/after-insert.decorator'; -export * from 'src/sql-tools/decorators/before-update.decorator'; -export * from 'src/sql-tools/decorators/check.decorator'; -export * from 'src/sql-tools/decorators/column.decorator'; -export * from 'src/sql-tools/decorators/configuration-parameter.decorator'; -export * from 'src/sql-tools/decorators/create-date-column.decorator'; -export * from 'src/sql-tools/decorators/database.decorator'; -export * from 'src/sql-tools/decorators/delete-date-column.decorator'; -export * from 'src/sql-tools/decorators/extension.decorator'; -export * from 'src/sql-tools/decorators/extensions.decorator'; -export * from 'src/sql-tools/decorators/foreign-key-column.decorator'; -export * from 'src/sql-tools/decorators/foreign-key-constraint.decorator'; -export * from 'src/sql-tools/decorators/generated-column.decorator'; -export * from 'src/sql-tools/decorators/index.decorator'; -export * from 'src/sql-tools/decorators/primary-column.decorator'; -export * from 'src/sql-tools/decorators/primary-generated-column.decorator'; -export * from 'src/sql-tools/decorators/table.decorator'; -export * from 'src/sql-tools/decorators/trigger-function.decorator'; -export * from 'src/sql-tools/decorators/trigger.decorator'; -export * from 'src/sql-tools/decorators/unique.decorator'; -export * from 'src/sql-tools/decorators/update-date-column.decorator'; -export * from 'src/sql-tools/naming/default.naming'; -export * from 'src/sql-tools/naming/hash.naming'; -export * from 'src/sql-tools/naming/naming.interface'; -export * from 'src/sql-tools/register-enum'; -export * from 'src/sql-tools/register-function'; -export { schemaDiff, schemaDiffToSql } from 'src/sql-tools/schema-diff'; -export { schemaFromCode } from 'src/sql-tools/schema-from-code'; -export { schemaFromDatabase } from 'src/sql-tools/schema-from-database'; -export * from 'src/sql-tools/types'; diff --git a/server/src/sql-tools/readers/column.reader.ts b/server/src/sql-tools/readers/column.reader.ts deleted file mode 100644 index 249bd77f2c..0000000000 --- a/server/src/sql-tools/readers/column.reader.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { sql } from 'kysely'; -import { jsonArrayFrom } from 'kysely/helpers/postgres'; -import { ColumnType, DatabaseColumn, Reader } from 'src/sql-tools/types'; - -export const readColumns: Reader = async (ctx, db) => { - const columns = await db - .selectFrom('information_schema.columns as c') - .leftJoin('information_schema.element_types as o', (join) => - join - .onRef('c.table_catalog', '=', 'o.object_catalog') - .onRef('c.table_schema', '=', 'o.object_schema') - .onRef('c.table_name', '=', 'o.object_name') - .on('o.object_type', '=', sql.lit('TABLE')) - .onRef('c.dtd_identifier', '=', 'o.collection_type_identifier'), - ) - .leftJoin('pg_type as t', (join) => - join.onRef('t.typname', '=', 'c.udt_name').on('c.data_type', '=', sql.lit('USER-DEFINED')), - ) - .leftJoin('pg_enum as e', (join) => join.onRef('e.enumtypid', '=', 't.oid')) - .select([ - 'c.table_name', - 'c.column_name', - - // is ARRAY, USER-DEFINED, or data type - 'c.data_type', - 'c.column_default', - 'c.is_nullable', - 'c.character_maximum_length', - - // number types - 'c.numeric_precision', - 'c.numeric_scale', - - // date types - 'c.datetime_precision', - - // user defined type - 'c.udt_catalog', - 'c.udt_schema', - 'c.udt_name', - - // data type for ARRAYs - 'o.data_type as array_type', - ]) - .where('table_schema', '=', ctx.schemaName) - .execute(); - - const enumRaw = await db - .selectFrom('pg_type') - .innerJoin('pg_namespace', (join) => - join.onRef('pg_namespace.oid', '=', 'pg_type.typnamespace').on('pg_namespace.nspname', '=', ctx.schemaName), - ) - .where('typtype', '=', sql.lit('e')) - .select((eb) => [ - 'pg_type.typname as name', - jsonArrayFrom( - eb.selectFrom('pg_enum as e').select(['e.enumlabel as value']).whereRef('e.enumtypid', '=', 'pg_type.oid'), - ).as('values'), - ]) - .execute(); - - const enums = enumRaw.map((item) => ({ name: item.name, values: item.values.map(({ value }) => value) })); - for (const { name, values } of enums) { - ctx.enums.push({ name, values, synchronize: true }); - } - - const enumMap = Object.fromEntries(enums.map((e) => [e.name, e.values])); - // add columns to tables - for (const column of columns) { - const table = ctx.getTableByName(column.table_name); - if (!table) { - continue; - } - - const columnName = column.column_name; - - const item: DatabaseColumn = { - type: column.data_type as ColumnType, - // TODO infer this from PK constraints - primary: false, - name: columnName, - tableName: column.table_name, - nullable: column.is_nullable === 'YES', - isArray: column.array_type !== null, - numericPrecision: column.numeric_precision ?? undefined, - numericScale: column.numeric_scale ?? undefined, - length: column.character_maximum_length ?? undefined, - default: column.column_default ?? undefined, - synchronize: true, - }; - - const columnLabel = `${table.name}.${columnName}`; - - switch (column.data_type) { - // array types - case 'ARRAY': { - if (!column.array_type) { - ctx.warnings.push(`Unable to find type for ${columnLabel} (ARRAY)`); - continue; - } - item.type = column.array_type as ColumnType; - break; - } - - // enum types - case 'USER-DEFINED': { - if (!enumMap[column.udt_name]) { - ctx.warnings.push(`Unable to find type for ${columnLabel} (ENUM)`); - continue; - } - - item.type = 'enum'; - item.enumName = column.udt_name; - break; - } - } - - table.columns.push(item); - } -}; diff --git a/server/src/sql-tools/readers/comment.reader.ts b/server/src/sql-tools/readers/comment.reader.ts deleted file mode 100644 index 05cc91e7a9..0000000000 --- a/server/src/sql-tools/readers/comment.reader.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Reader } from 'src/sql-tools/types'; - -export const readComments: Reader = async (ctx, db) => { - const comments = await db - .selectFrom('pg_description as d') - .innerJoin('pg_class as c', 'd.objoid', 'c.oid') - .leftJoin('pg_attribute as a', (join) => - join.onRef('a.attrelid', '=', 'c.oid').onRef('a.attnum', '=', 'd.objsubid'), - ) - .select([ - 'c.relname as object_name', - 'c.relkind as object_type', - 'd.description as value', - 'a.attname as column_name', - ]) - .where('d.description', 'is not', null) - .orderBy('object_type') - .orderBy('object_name') - .execute(); - - for (const comment of comments) { - if (comment.object_type === 'r') { - const table = ctx.getTableByName(comment.object_name); - if (!table) { - continue; - } - - if (comment.column_name) { - const column = table.columns.find(({ name }) => name === comment.column_name); - if (column) { - column.comment = comment.value; - } - } - } - } -}; diff --git a/server/src/sql-tools/readers/constraint.reader.ts b/server/src/sql-tools/readers/constraint.reader.ts deleted file mode 100644 index 662c6f414a..0000000000 --- a/server/src/sql-tools/readers/constraint.reader.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { sql } from 'kysely'; -import { ActionType, ConstraintType, Reader } from 'src/sql-tools/types'; - -export const readConstraints: Reader = async (ctx, db) => { - const constraints = await db - .selectFrom('pg_constraint') - .innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_constraint.connamespace') // namespace - .innerJoin('pg_class as source_table', (join) => - join.onRef('source_table.oid', '=', 'pg_constraint.conrelid').on('source_table.relkind', 'in', [ - // ordinary table - sql.lit('r'), - // partitioned table - sql.lit('p'), - // foreign table - sql.lit('f'), - ]), - ) // table - .leftJoin('pg_class as reference_table', 'reference_table.oid', 'pg_constraint.confrelid') // reference table - .select((eb) => [ - 'pg_constraint.contype as constraint_type', - 'pg_constraint.conname as constraint_name', - 'source_table.relname as table_name', - 'reference_table.relname as reference_table_name', - 'pg_constraint.confupdtype as update_action', - 'pg_constraint.confdeltype as delete_action', - // 'pg_constraint.oid as constraint_id', - eb - .selectFrom('pg_attribute') - // matching table for PK, FK, and UQ - .whereRef('pg_attribute.attrelid', '=', 'pg_constraint.conrelid') - .whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."conkey")`) - .select((eb) => eb.fn('json_agg', ['pg_attribute.attname']).as('column_name')) - .as('column_names'), - eb - .selectFrom('pg_attribute') - // matching foreign table for FK - .whereRef('pg_attribute.attrelid', '=', 'pg_constraint.confrelid') - .whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."confkey")`) - .select((eb) => eb.fn('json_agg', ['pg_attribute.attname']).as('column_name')) - .as('reference_column_names'), - eb.fn('pg_get_constraintdef', ['pg_constraint.oid']).as('expression'), - ]) - .where('pg_namespace.nspname', '=', ctx.schemaName) - .execute(); - - for (const constraint of constraints) { - const table = ctx.getTableByName(constraint.table_name); - if (!table) { - continue; - } - - const constraintName = constraint.constraint_name; - - switch (constraint.constraint_type) { - // primary key constraint - case 'p': { - if (!constraint.column_names) { - ctx.warnings.push(`Skipping CONSTRAINT "${constraintName}", no columns found`); - continue; - } - table.constraints.push({ - type: ConstraintType.PRIMARY_KEY, - name: constraintName, - tableName: constraint.table_name, - columnNames: constraint.column_names, - synchronize: true, - }); - break; - } - - // foreign key constraint - case 'f': { - if (!constraint.column_names || !constraint.reference_table_name || !constraint.reference_column_names) { - ctx.warnings.push( - `Skipping CONSTRAINT "${constraintName}", missing either columns, referenced table, or referenced columns,`, - ); - continue; - } - - table.constraints.push({ - type: ConstraintType.FOREIGN_KEY, - name: constraintName, - tableName: constraint.table_name, - columnNames: constraint.column_names, - referenceTableName: constraint.reference_table_name, - referenceColumnNames: constraint.reference_column_names, - onUpdate: asDatabaseAction(constraint.update_action), - onDelete: asDatabaseAction(constraint.delete_action), - synchronize: true, - }); - break; - } - - // unique constraint - case 'u': { - table.constraints.push({ - type: ConstraintType.UNIQUE, - name: constraintName, - tableName: constraint.table_name, - columnNames: constraint.column_names as string[], - synchronize: true, - }); - break; - } - - // check constraint - case 'c': { - table.constraints.push({ - type: ConstraintType.CHECK, - name: constraint.constraint_name, - tableName: constraint.table_name, - expression: constraint.expression.replace('CHECK ', ''), - synchronize: true, - }); - break; - } - } - } -}; - -const asDatabaseAction = (action: string) => { - switch (action) { - case 'a': { - return ActionType.NO_ACTION; - } - case 'c': { - return ActionType.CASCADE; - } - case 'r': { - return ActionType.RESTRICT; - } - case 'n': { - return ActionType.SET_NULL; - } - case 'd': { - return ActionType.SET_DEFAULT; - } - - default: { - return ActionType.NO_ACTION; - } - } -}; diff --git a/server/src/sql-tools/readers/extension.reader.ts b/server/src/sql-tools/readers/extension.reader.ts deleted file mode 100644 index aa33f4d21e..0000000000 --- a/server/src/sql-tools/readers/extension.reader.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Reader } from 'src/sql-tools/types'; - -export const readExtensions: Reader = async (ctx, db) => { - const extensions = await db - .selectFrom('pg_catalog.pg_extension') - // .innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_catalog.pg_extension.extnamespace') - // .where('pg_namespace.nspname', '=', schemaName) - .select(['extname as name', 'extversion as version']) - .execute(); - - for (const { name } of extensions) { - ctx.extensions.push({ name, synchronize: true }); - } -}; diff --git a/server/src/sql-tools/readers/function.reader.ts b/server/src/sql-tools/readers/function.reader.ts deleted file mode 100644 index 4696747f52..0000000000 --- a/server/src/sql-tools/readers/function.reader.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { sql } from 'kysely'; -import { Reader } from 'src/sql-tools/types'; - -export const readFunctions: Reader = async (ctx, db) => { - const routines = await db - .selectFrom('pg_proc as p') - .innerJoin('pg_namespace', 'pg_namespace.oid', 'p.pronamespace') - .leftJoin('pg_depend as d', (join) => join.onRef('d.objid', '=', 'p.oid').on('d.deptype', '=', sql.lit('e'))) - .where('d.objid', 'is', sql.lit(null)) - .where('p.prokind', '=', sql.lit('f')) - .where('pg_namespace.nspname', '=', ctx.schemaName) - .select((eb) => [ - 'p.proname as name', - eb.fn('pg_get_function_identity_arguments', ['p.oid']).as('arguments'), - eb.fn('pg_get_functiondef', ['p.oid']).as('expression'), - ]) - .execute(); - - for (const { name, expression } of routines) { - ctx.functions.push({ - name, - // TODO read expression from the overrides table - expression, - synchronize: true, - }); - } -}; diff --git a/server/src/sql-tools/readers/index.reader.ts b/server/src/sql-tools/readers/index.reader.ts deleted file mode 100644 index 26b17a0d19..0000000000 --- a/server/src/sql-tools/readers/index.reader.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { sql } from 'kysely'; -import { Reader } from 'src/sql-tools/types'; - -export const readIndexes: Reader = async (ctx, db) => { - const indexes = await db - .selectFrom('pg_index as ix') - // matching index, which has column information - .innerJoin('pg_class as i', 'ix.indexrelid', 'i.oid') - .innerJoin('pg_am as a', 'i.relam', 'a.oid') - // matching table - .innerJoin('pg_class as t', 'ix.indrelid', 't.oid') - // namespace - .innerJoin('pg_namespace', 'pg_namespace.oid', 'i.relnamespace') - // PK and UQ constraints automatically have indexes, so we can ignore those - .leftJoin('pg_constraint', (join) => - join - .onRef('pg_constraint.conindid', '=', 'i.oid') - .on('pg_constraint.contype', 'in', [sql.lit('p'), sql.lit('u')]), - ) - .where('pg_constraint.oid', 'is', null) - .select((eb) => [ - 'i.relname as index_name', - 't.relname as table_name', - 'ix.indisunique as unique', - 'a.amname as using', - eb.fn('pg_get_expr', ['ix.indexprs', 'ix.indrelid']).as('expression'), - eb.fn('pg_get_expr', ['ix.indpred', 'ix.indrelid']).as('where'), - eb - .selectFrom('pg_attribute as a') - .where('t.relkind', '=', sql.lit('r')) - .whereRef('a.attrelid', '=', 't.oid') - // list of columns numbers in the index - .whereRef('a.attnum', '=', sql`any("ix"."indkey")`) - .select((eb) => eb.fn('json_agg', ['a.attname']).as('column_name')) - .as('column_names'), - ]) - .where('pg_namespace.nspname', '=', ctx.schemaName) - .where('ix.indisprimary', '=', sql.lit(false)) - .execute(); - - for (const index of indexes) { - const table = ctx.getTableByName(index.table_name); - if (!table) { - continue; - } - - table.indexes.push({ - name: index.index_name, - tableName: index.table_name, - columnNames: index.column_names ?? undefined, - expression: index.expression ?? undefined, - using: index.using, - where: index.where ?? undefined, - unique: index.unique, - synchronize: true, - }); - } -}; diff --git a/server/src/sql-tools/readers/index.ts b/server/src/sql-tools/readers/index.ts deleted file mode 100644 index 354f99c7ca..0000000000 --- a/server/src/sql-tools/readers/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { readColumns } from 'src/sql-tools/readers/column.reader'; -import { readComments } from 'src/sql-tools/readers/comment.reader'; -import { readConstraints } from 'src/sql-tools/readers/constraint.reader'; -import { readExtensions } from 'src/sql-tools/readers/extension.reader'; -import { readFunctions } from 'src/sql-tools/readers/function.reader'; -import { readIndexes } from 'src/sql-tools/readers/index.reader'; -import { readName } from 'src/sql-tools/readers/name.reader'; -import { readOverrides } from 'src/sql-tools/readers/override.reader'; -import { readParameters } from 'src/sql-tools/readers/parameter.reader'; -import { readTables } from 'src/sql-tools/readers/table.reader'; -import { readTriggers } from 'src/sql-tools/readers/trigger.reader'; -import { Reader } from 'src/sql-tools/types'; - -export const readers: Reader[] = [ - readName, - readParameters, - readExtensions, - readFunctions, - readTables, - readColumns, - readIndexes, - readConstraints, - readTriggers, - readComments, - readOverrides, -]; diff --git a/server/src/sql-tools/readers/name.reader.ts b/server/src/sql-tools/readers/name.reader.ts deleted file mode 100644 index de4f1af3a6..0000000000 --- a/server/src/sql-tools/readers/name.reader.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { QueryResult, sql } from 'kysely'; -import { Reader } from 'src/sql-tools/types'; - -export const readName: Reader = async (ctx, db) => { - const result = (await sql`SELECT current_database() as name`.execute(db)) as QueryResult<{ name: string }>; - - ctx.databaseName = result.rows[0].name; -}; diff --git a/server/src/sql-tools/readers/override.reader.ts b/server/src/sql-tools/readers/override.reader.ts deleted file mode 100644 index 34f0004f95..0000000000 --- a/server/src/sql-tools/readers/override.reader.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { sql } from 'kysely'; -import { OverrideType, Reader } from 'src/sql-tools/types'; - -export const readOverrides: Reader = async (ctx, db) => { - try { - const result = await sql - .raw<{ - name: string; - value: { type: OverrideType; name: string; sql: string }; - }>(`SELECT name, value FROM "${ctx.overrideTableName}"`) - .execute(db); - - for (const { name, value } of result.rows) { - ctx.overrides.push({ name, value, synchronize: true }); - } - } catch (error) { - ctx.warn('Overrides', `Error reading override table: ${error}`); - } -}; diff --git a/server/src/sql-tools/readers/parameter.reader.ts b/server/src/sql-tools/readers/parameter.reader.ts deleted file mode 100644 index c5f36591a3..0000000000 --- a/server/src/sql-tools/readers/parameter.reader.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { sql } from 'kysely'; -import { ParameterScope, Reader } from 'src/sql-tools/types'; - -export const readParameters: Reader = async (ctx, db) => { - const parameters = await db - .selectFrom('pg_settings') - .where('source', 'in', [sql.lit('database'), sql.lit('user')]) - .select(['name', 'setting as value', 'source as scope']) - .execute(); - - for (const parameter of parameters) { - ctx.parameters.push({ - name: parameter.name, - value: parameter.value, - databaseName: ctx.databaseName, - scope: parameter.scope as ParameterScope, - synchronize: true, - }); - } -}; diff --git a/server/src/sql-tools/readers/table.reader.ts b/server/src/sql-tools/readers/table.reader.ts deleted file mode 100644 index 4570179bbf..0000000000 --- a/server/src/sql-tools/readers/table.reader.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { sql } from 'kysely'; -import { Reader } from 'src/sql-tools/types'; - -export const readTables: Reader = async (ctx, db) => { - const tables = await db - .selectFrom('information_schema.tables') - .where('table_schema', '=', ctx.schemaName) - .where('table_type', '=', sql.lit('BASE TABLE')) - .selectAll() - .execute(); - - for (const table of tables) { - ctx.tables.push({ - name: table.table_name, - columns: [], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }); - } -}; diff --git a/server/src/sql-tools/readers/trigger.reader.ts b/server/src/sql-tools/readers/trigger.reader.ts deleted file mode 100644 index 92fb1d12bf..0000000000 --- a/server/src/sql-tools/readers/trigger.reader.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Reader, TriggerAction, TriggerScope, TriggerTiming } from 'src/sql-tools/types'; - -export const readTriggers: Reader = async (ctx, db) => { - const triggers = await db - .selectFrom('pg_trigger as t') - .innerJoin('pg_proc as p', 't.tgfoid', 'p.oid') - .innerJoin('pg_namespace as n', 'p.pronamespace', 'n.oid') - .innerJoin('pg_class as c', 't.tgrelid', 'c.oid') - .select((eb) => [ - 't.tgname as name', - 't.tgenabled as enabled', - 't.tgtype as type', - 't.tgconstraint as _constraint', - 't.tgdeferrable as is_deferrable', - 't.tginitdeferred as is_initially_deferred', - 't.tgargs as arguments', - 't.tgoldtable as referencing_old_table_as', - 't.tgnewtable as referencing_new_table_as', - eb.fn('pg_get_expr', ['t.tgqual', 't.tgrelid']).as('when_expression'), - 'p.proname as function_name', - 'c.relname as table_name', - ]) - .where('t.tgisinternal', '=', false) // Exclude internal system triggers - .where('n.nspname', '=', ctx.schemaName) - .execute(); - - // add triggers to tables - for (const trigger of triggers) { - const table = ctx.getTableByName(trigger.table_name); - if (!table) { - continue; - } - - table.triggers.push({ - name: trigger.name, - tableName: trigger.table_name, - functionName: trigger.function_name, - referencingNewTableAs: trigger.referencing_new_table_as ?? undefined, - referencingOldTableAs: trigger.referencing_old_table_as ?? undefined, - when: trigger.when_expression, - synchronize: true, - ...parseTriggerType(trigger.type), - }); - } -}; - -export const hasMask = (input: number, mask: number) => (input & mask) === mask; - -export const parseTriggerType = (type: number) => { - // eslint-disable-next-line unicorn/prefer-math-trunc - const scope: TriggerScope = hasMask(type, 1 << 0) ? 'row' : 'statement'; - - let timing: TriggerTiming = 'after'; - const timingMasks: Array<{ mask: number; value: TriggerTiming }> = [ - { mask: 1 << 1, value: 'before' }, - { mask: 1 << 6, value: 'instead of' }, - ]; - - for (const { mask, value } of timingMasks) { - if (hasMask(type, mask)) { - timing = value; - break; - } - } - - const actions: TriggerAction[] = []; - const actionMasks: Array<{ mask: number; value: TriggerAction }> = [ - { mask: 1 << 2, value: 'insert' }, - { mask: 1 << 3, value: 'delete' }, - { mask: 1 << 4, value: 'update' }, - { mask: 1 << 5, value: 'truncate' }, - ]; - - for (const { mask, value } of actionMasks) { - if (hasMask(type, mask)) { - actions.push(value); - break; - } - } - - if (actions.length === 0) { - throw new Error(`Unable to parse trigger type ${type}`); - } - - return { actions, timing, scope }; -}; diff --git a/server/src/sql-tools/register-enum.ts b/server/src/sql-tools/register-enum.ts deleted file mode 100644 index 5e9b41adcb..0000000000 --- a/server/src/sql-tools/register-enum.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { register } from 'src/sql-tools/register'; -import { DatabaseEnum } from 'src/sql-tools/types'; - -export type EnumOptions = { - name: string; - values: string[]; - synchronize?: boolean; -}; - -export const registerEnum = (options: EnumOptions) => { - const item: DatabaseEnum = { - name: options.name, - values: options.values, - synchronize: options.synchronize ?? true, - }; - - register({ type: 'enum', item }); - - return item; -}; diff --git a/server/src/sql-tools/register-function.ts b/server/src/sql-tools/register-function.ts deleted file mode 100644 index 9f1c84c4fa..0000000000 --- a/server/src/sql-tools/register-function.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { register } from 'src/sql-tools/register'; -import { ColumnType, DatabaseFunction } from 'src/sql-tools/types'; - -export type FunctionOptions = { - name: string; - arguments?: string[]; - returnType: ColumnType | string; - language?: 'SQL' | 'PLPGSQL'; - behavior?: 'immutable' | 'stable' | 'volatile'; - parallel?: 'safe' | 'unsafe' | 'restricted'; - strict?: boolean; - synchronize?: boolean; -} & ({ body: string } | { return: string }); - -export const registerFunction = (options: FunctionOptions) => { - const name = options.name; - const expression = asFunctionExpression(options); - - const item: DatabaseFunction = { - name, - expression, - synchronize: options.synchronize ?? true, - }; - - register({ type: 'function', item }); - - return item; -}; - -const asFunctionExpression = (options: FunctionOptions) => { - const name = options.name; - const sql: string[] = [ - `CREATE OR REPLACE FUNCTION ${name}(${(options.arguments || []).join(', ')})`, - `RETURNS ${options.returnType}`, - ]; - - const flags = [ - options.parallel ? `PARALLEL ${options.parallel.toUpperCase()}` : undefined, - options.strict ? 'STRICT' : undefined, - options.behavior ? options.behavior.toUpperCase() : undefined, - `LANGUAGE ${options.language ?? 'SQL'}`, - ].filter((x) => x !== undefined); - - if (flags.length > 0) { - sql.push(flags.join(' ')); - } - - if ('return' in options) { - sql.push(` RETURN ${options.return}`); - } - - if ('body' in options) { - const body = options.body; - sql.push(...(body.includes('\n') ? [`AS $$`, ' ' + body.trim(), `$$;`] : [`AS $$${body}$$;`])); - } - - return sql.join('\n ').trim(); -}; diff --git a/server/src/sql-tools/register-item.ts b/server/src/sql-tools/register-item.ts deleted file mode 100644 index fede281a1b..0000000000 --- a/server/src/sql-tools/register-item.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { CheckOptions } from 'src/sql-tools/decorators/check.decorator'; -import { ColumnOptions } from 'src/sql-tools/decorators/column.decorator'; -import { ConfigurationParameterOptions } from 'src/sql-tools/decorators/configuration-parameter.decorator'; -import { DatabaseOptions } from 'src/sql-tools/decorators/database.decorator'; -import { ExtensionOptions } from 'src/sql-tools/decorators/extension.decorator'; -import { ForeignKeyColumnOptions } from 'src/sql-tools/decorators/foreign-key-column.decorator'; -import { ForeignKeyConstraintOptions } from 'src/sql-tools/decorators/foreign-key-constraint.decorator'; -import { IndexOptions } from 'src/sql-tools/decorators/index.decorator'; -import { TableOptions } from 'src/sql-tools/decorators/table.decorator'; -import { TriggerOptions } from 'src/sql-tools/decorators/trigger.decorator'; -import { UniqueOptions } from 'src/sql-tools/decorators/unique.decorator'; -import { DatabaseEnum, DatabaseFunction } from 'src/sql-tools/types'; - -export type ClassBased = { object: Function } & T; -export type PropertyBased = { object: object; propertyName: string | symbol } & T; -export type RegisterItem = - | { type: 'database'; item: ClassBased<{ options: DatabaseOptions }> } - | { type: 'table'; item: ClassBased<{ options: TableOptions }> } - | { type: 'index'; item: ClassBased<{ options: IndexOptions }> } - | { type: 'uniqueConstraint'; item: ClassBased<{ options: UniqueOptions }> } - | { type: 'checkConstraint'; item: ClassBased<{ options: CheckOptions }> } - | { type: 'column'; item: PropertyBased<{ options: ColumnOptions }> } - | { type: 'function'; item: DatabaseFunction } - | { type: 'enum'; item: DatabaseEnum } - | { type: 'trigger'; item: ClassBased<{ options: TriggerOptions }> } - | { type: 'extension'; item: ClassBased<{ options: ExtensionOptions }> } - | { type: 'configurationParameter'; item: ClassBased<{ options: ConfigurationParameterOptions }> } - | { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => Function }> } - | { type: 'foreignKeyConstraint'; item: ClassBased<{ options: ForeignKeyConstraintOptions }> }; -export type RegisterItemType = Extract['item']; diff --git a/server/src/sql-tools/register.ts b/server/src/sql-tools/register.ts deleted file mode 100644 index 4df04c935a..0000000000 --- a/server/src/sql-tools/register.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { RegisterItem } from 'src/sql-tools/register-item'; - -const items: RegisterItem[] = []; - -export const register = (item: RegisterItem) => void items.push(item); - -export const getRegisteredItems = () => items; - -export const resetRegisteredItems = () => { - items.length = 0; -}; diff --git a/server/src/sql-tools/schema-diff.spec.ts b/server/src/sql-tools/schema-diff.spec.ts deleted file mode 100644 index f45fb98bd3..0000000000 --- a/server/src/sql-tools/schema-diff.spec.ts +++ /dev/null @@ -1,689 +0,0 @@ -import { schemaDiff } from 'src/sql-tools/schema-diff'; -import { - ActionType, - ColumnType, - ConstraintType, - DatabaseColumn, - DatabaseConstraint, - DatabaseIndex, - DatabaseSchema, - DatabaseTable, -} from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const fromColumn = (column: Partial>): DatabaseSchema => { - const tableName = 'table1'; - - return { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: tableName, - columns: [ - { - name: 'column1', - primary: false, - synchronize: true, - isArray: false, - type: 'character varying', - nullable: false, - ...column, - tableName, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], - }; -}; - -const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => { - const tableName = constraint?.tableName || 'table1'; - - return { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: tableName, - columns: [ - { - name: 'column1', - primary: false, - synchronize: true, - isArray: false, - type: 'character varying', - nullable: false, - tableName, - }, - ], - indexes: [], - triggers: [], - constraints: constraint ? [constraint] : [], - synchronize: true, - }, - ], - warnings: [], - }; -}; - -const fromIndex = (index?: DatabaseIndex): DatabaseSchema => { - const tableName = index?.tableName || 'table1'; - - return { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: tableName, - columns: [ - { - name: 'column1', - primary: false, - synchronize: true, - isArray: false, - type: 'character varying', - nullable: false, - tableName, - }, - ], - indexes: index ? [index] : [], - constraints: [], - triggers: [], - synchronize: true, - }, - ], - warnings: [], - }; -}; - -const newSchema = (schema: { - name?: string; - tables: Array<{ - name: string; - columns?: Array<{ - name: string; - type?: ColumnType; - nullable?: boolean; - isArray?: boolean; - }>; - indexes?: DatabaseIndex[]; - constraints?: DatabaseConstraint[]; - }>; -}): DatabaseSchema => { - const tables: DatabaseTable[] = []; - - for (const table of schema.tables || []) { - const tableName = table.name; - const columns: DatabaseColumn[] = []; - - for (const column of table.columns || []) { - const columnName = column.name; - - columns.push({ - tableName, - name: columnName, - primary: false, - type: column.type || 'character varying', - isArray: column.isArray ?? false, - nullable: column.nullable ?? false, - synchronize: true, - }); - } - - tables.push({ - name: tableName, - columns, - indexes: table.indexes ?? [], - constraints: table.constraints ?? [], - triggers: [], - synchronize: true, - }); - } - - return { - databaseName: 'immich', - schemaName: schema?.name || 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables, - warnings: [], - }; -}; - -describe(schemaDiff.name, () => { - it('should work', () => { - const diff = schemaDiff(newSchema({ tables: [] }), newSchema({ tables: [] })); - expect(diff.items).toEqual([]); - }); - - describe('table', () => { - describe('TableCreate', () => { - it('should find a missing table', () => { - const column: DatabaseColumn = { - type: 'character varying', - tableName: 'table1', - primary: false, - name: 'column1', - isArray: false, - nullable: false, - synchronize: true, - }; - const diff = schemaDiff( - newSchema({ tables: [{ name: 'table1', columns: [column] }] }), - newSchema({ tables: [] }), - ); - - expect(diff.items).toHaveLength(1); - expect(diff.items[0]).toEqual({ - type: 'TableCreate', - table: { - name: 'table1', - columns: [column], - constraints: [], - indexes: [], - triggers: [], - synchronize: true, - }, - reason: 'missing in target', - }); - }); - }); - - describe('TableDrop', () => { - it('should find an extra table', () => { - const diff = schemaDiff( - newSchema({ tables: [] }), - newSchema({ - tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], - }), - { tables: { ignoreExtra: false } }, - ); - - expect(diff.items).toHaveLength(1); - expect(diff.items[0]).toEqual({ - type: 'TableDrop', - tableName: 'table1', - reason: 'missing in source', - }); - }); - }); - - it('should skip identical tables', () => { - const diff = schemaDiff( - newSchema({ - tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], - }), - newSchema({ - tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], - }), - ); - - expect(diff.items).toEqual([]); - }); - }); - - describe('column', () => { - describe('ColumnAdd', () => { - it('should find a new column', () => { - const diff = schemaDiff( - newSchema({ - tables: [ - { - name: 'table1', - columns: [{ name: 'column1' }, { name: 'column2' }], - }, - ], - }), - newSchema({ - tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], - }), - ); - - expect(diff.items).toEqual([ - { - type: 'ColumnAdd', - column: { - tableName: 'table1', - isArray: false, - primary: false, - name: 'column2', - nullable: false, - type: 'character varying', - synchronize: true, - }, - reason: 'missing in target', - }, - ]); - }); - }); - - describe('ColumnDrop', () => { - it('should find an extra column', () => { - const diff = schemaDiff( - newSchema({ - tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], - }), - newSchema({ - tables: [ - { - name: 'table1', - columns: [{ name: 'column1' }, { name: 'column2' }], - }, - ], - }), - ); - - expect(diff.items).toEqual([ - { - type: 'ColumnDrop', - tableName: 'table1', - columnName: 'column2', - reason: 'missing in source', - }, - ]); - }); - }); - - describe('nullable', () => { - it('should make a column nullable', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', nullable: true }), - fromColumn({ name: 'column1', nullable: false }), - ); - - expect(diff.items).toEqual([ - { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { - nullable: true, - }, - reason: 'nullable is different (true vs false)', - }, - ]); - }); - - it('should make a column non-nullable', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', nullable: false }), - fromColumn({ name: 'column1', nullable: true }), - ); - - expect(diff.items).toEqual([ - { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { - nullable: false, - }, - reason: 'nullable is different (false vs true)', - }, - ]); - }); - }); - - describe('default', () => { - it('should set a default value to a function', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', default: 'uuid_generate_v4()' }), - fromColumn({ name: 'column1' }), - ); - - expect(diff.items).toEqual([ - { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { - default: 'uuid_generate_v4()', - }, - reason: 'default is different (uuid_generate_v4() vs undefined)', - }, - ]); - }); - - it('should ignore explicit casts for strings', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', type: 'character varying', default: `''` }), - fromColumn({ name: 'column1', type: 'character varying', default: `''::character varying` }), - ); - - expect(diff.items).toEqual([]); - }); - - it('should ignore explicit casts for numbers', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', type: 'bigint', default: `0` }), - fromColumn({ name: 'column1', type: 'bigint', default: `'0'::bigint` }), - ); - - expect(diff.items).toEqual([]); - }); - - it('should ignore explicit casts for enums', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `test` }), - fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `'test'::enum1` }), - ); - - expect(diff.items).toEqual([]); - }); - - it('should support arrays, ignoring types', () => { - const diff = schemaDiff( - fromColumn({ name: 'column1', type: 'character varying', isArray: true, default: "'{}'" }), - fromColumn({ - name: 'column1', - type: 'character varying', - isArray: true, - default: "'{}'::character varying[]", - }), - ); - - expect(diff.items).toEqual([]); - }); - }); - }); - - describe('constraint', () => { - describe('ConstraintAdd', () => { - it('should detect a new constraint', () => { - const diff = schemaDiff( - fromConstraint({ - name: 'PK_test', - type: ConstraintType.PRIMARY_KEY, - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }), - fromConstraint(), - ); - - expect(diff.items).toEqual([ - { - type: 'ConstraintAdd', - constraint: { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_test', - columnNames: ['id'], - tableName: 'table1', - synchronize: true, - }, - reason: 'missing in target', - }, - ]); - }); - }); - - describe('ConstraintDrop', () => { - it('should detect an extra constraint', () => { - const diff = schemaDiff( - fromConstraint(), - fromConstraint({ - name: 'PK_test', - type: ConstraintType.PRIMARY_KEY, - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }), - ); - - expect(diff.items).toEqual([ - { - type: 'ConstraintDrop', - tableName: 'table1', - constraintName: 'PK_test', - reason: 'missing in source', - }, - ]); - }); - }); - - describe('primary key', () => { - it('should skip identical primary key constraints', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }; - - const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint })); - - expect(diff.items).toEqual([]); - }); - }); - - describe('foreign key', () => { - it('should skip identical foreign key constraints', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_test', - tableName: 'table1', - columnNames: ['parentId'], - referenceTableName: 'table2', - referenceColumnNames: ['id'], - synchronize: true, - }; - - const diff = schemaDiff(fromConstraint(constraint), fromConstraint(constraint)); - - expect(diff.items).toEqual([]); - }); - - it('should drop and recreate when the column changes', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_test', - tableName: 'table1', - columnNames: ['parentId'], - referenceTableName: 'table2', - referenceColumnNames: ['id'], - synchronize: true, - }; - - const diff = schemaDiff( - fromConstraint(constraint), - fromConstraint({ ...constraint, columnNames: ['parentId2'] }), - ); - - expect(diff.items).toEqual([ - { - constraintName: 'FK_test', - reason: 'columns are different (parentId vs parentId2)', - tableName: 'table1', - type: 'ConstraintDrop', - }, - { - constraint: { - columnNames: ['parentId'], - name: 'FK_test', - referenceColumnNames: ['id'], - referenceTableName: 'table2', - synchronize: true, - tableName: 'table1', - type: 'foreign-key', - }, - reason: 'columns are different (parentId vs parentId2)', - type: 'ConstraintAdd', - }, - ]); - }); - - it('should drop and recreate when the ON DELETE action changes', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_test', - tableName: 'table1', - columnNames: ['parentId'], - referenceTableName: 'table2', - referenceColumnNames: ['id'], - onDelete: ActionType.CASCADE, - synchronize: true, - }; - - const diff = schemaDiff(fromConstraint(constraint), fromConstraint({ ...constraint, onDelete: undefined })); - - expect(diff.items).toEqual([ - { - constraintName: 'FK_test', - reason: 'ON DELETE action is different (CASCADE vs NO ACTION)', - tableName: 'table1', - type: 'ConstraintDrop', - }, - { - constraint: { - columnNames: ['parentId'], - name: 'FK_test', - referenceColumnNames: ['id'], - referenceTableName: 'table2', - onDelete: ActionType.CASCADE, - synchronize: true, - tableName: 'table1', - type: 'foreign-key', - }, - reason: 'ON DELETE action is different (CASCADE vs NO ACTION)', - type: 'ConstraintAdd', - }, - ]); - }); - }); - - describe('unique', () => { - it('should skip identical unique constraints', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.UNIQUE, - name: 'UQ_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }; - - const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint })); - - expect(diff.items).toEqual([]); - }); - }); - - describe('check', () => { - it('should skip identical check constraints', () => { - const constraint: DatabaseConstraint = { - type: ConstraintType.CHECK, - name: 'CHK_test', - tableName: 'table1', - expression: 'column1 > 0', - synchronize: true, - }; - - const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint })); - - expect(diff.items).toEqual([]); - }); - }); - }); - - describe('index', () => { - describe('IndexCreate', () => { - it('should detect a new index', () => { - const diff = schemaDiff( - fromIndex({ - name: 'IDX_test', - tableName: 'table1', - columnNames: ['id'], - unique: false, - synchronize: true, - }), - fromIndex(), - ); - - expect(diff.items).toEqual([ - { - type: 'IndexCreate', - index: { - name: 'IDX_test', - columnNames: ['id'], - tableName: 'table1', - unique: false, - synchronize: true, - }, - reason: 'missing in target', - }, - ]); - }); - }); - - describe('IndexDrop', () => { - it('should detect an extra index', () => { - const diff = schemaDiff( - fromIndex(), - fromIndex({ - name: 'IDX_test', - unique: true, - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }), - ); - - expect(diff.items).toEqual([ - { - type: 'IndexDrop', - indexName: 'IDX_test', - reason: 'missing in source', - }, - ]); - }); - }); - - it('should recreate the index if unique changes', () => { - const index: DatabaseIndex = { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['id'], - unique: true, - synchronize: true, - }; - const diff = schemaDiff(fromIndex(index), fromIndex({ ...index, unique: false })); - - expect(diff.items).toEqual([ - { - type: 'IndexDrop', - indexName: 'IDX_test', - reason: 'uniqueness is different (true vs false)', - }, - { - type: 'IndexCreate', - index, - reason: 'uniqueness is different (true vs false)', - }, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/schema-diff.ts b/server/src/sql-tools/schema-diff.ts deleted file mode 100644 index 846210931b..0000000000 --- a/server/src/sql-tools/schema-diff.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { compareEnums } from 'src/sql-tools/comparers/enum.comparer'; -import { compareExtensions } from 'src/sql-tools/comparers/extension.comparer'; -import { compareFunctions } from 'src/sql-tools/comparers/function.comparer'; -import { compareOverrides } from 'src/sql-tools/comparers/override.comparer'; -import { compareParameters } from 'src/sql-tools/comparers/parameter.comparer'; -import { compareTables } from 'src/sql-tools/comparers/table.comparer'; -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { compare } from 'src/sql-tools/helpers'; -import { transformers } from 'src/sql-tools/transformers'; -import { - ConstraintType, - DatabaseSchema, - SchemaDiff, - SchemaDiffOptions, - SchemaDiffToSqlOptions, -} from 'src/sql-tools/types'; - -/** - * Compute the difference between two database schemas - */ -export const schemaDiff = (source: DatabaseSchema, target: DatabaseSchema, options: SchemaDiffOptions = {}) => { - const items = [ - ...compare(source.parameters, target.parameters, options.parameters, compareParameters()), - ...compare(source.extensions, target.extensions, options.extensions, compareExtensions()), - ...compare(source.functions, target.functions, options.functions, compareFunctions()), - ...compare(source.enums, target.enums, options.enums, compareEnums()), - ...compare(source.tables, target.tables, options.tables, compareTables(options)), - ...compare(source.overrides, target.overrides, options.overrides, compareOverrides()), - ]; - - type SchemaName = SchemaDiff['type']; - const itemMap: Record = { - ColumnRename: [], - ConstraintRename: [], - IndexRename: [], - - ExtensionDrop: [], - ExtensionCreate: [], - - ParameterSet: [], - ParameterReset: [], - - FunctionDrop: [], - FunctionCreate: [], - - EnumDrop: [], - EnumCreate: [], - - TriggerDrop: [], - ConstraintDrop: [], - TableDrop: [], - ColumnDrop: [], - ColumnAdd: [], - ColumnAlter: [], - TableCreate: [], - ConstraintAdd: [], - TriggerCreate: [], - - IndexCreate: [], - IndexDrop: [], - - OverrideCreate: [], - OverrideUpdate: [], - OverrideDrop: [], - }; - - for (const item of items) { - itemMap[item.type].push(item); - } - - const constraintAdds = itemMap.ConstraintAdd.filter((item) => item.type === 'ConstraintAdd'); - - const orderedItems = [ - ...itemMap.ExtensionCreate, - ...itemMap.FunctionCreate, - ...itemMap.ParameterSet, - ...itemMap.ParameterReset, - ...itemMap.EnumCreate, - ...itemMap.TriggerDrop, - ...itemMap.IndexDrop, - ...itemMap.ConstraintDrop, - ...itemMap.TableCreate, - ...itemMap.ColumnAlter, - ...itemMap.ColumnAdd, - ...itemMap.ColumnRename, - ...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.PRIMARY_KEY), - ...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.FOREIGN_KEY), - ...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.UNIQUE), - ...constraintAdds.filter(({ constraint }) => constraint.type === ConstraintType.CHECK), - ...itemMap.ConstraintRename, - ...itemMap.IndexCreate, - ...itemMap.IndexRename, - ...itemMap.TriggerCreate, - ...itemMap.ColumnDrop, - ...itemMap.TableDrop, - ...itemMap.EnumDrop, - ...itemMap.FunctionDrop, - ...itemMap.OverrideCreate, - ...itemMap.OverrideUpdate, - ...itemMap.OverrideDrop, - ]; - - return { - items: orderedItems, - asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(orderedItems, options), - asHuman: () => schemaDiffToHuman(orderedItems), - }; -}; - -/** - * Convert schema diffs into SQL statements - */ -export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => { - return items.flatMap((item) => asSql(item, options)); -}; - -/** - * Convert schema diff into human readable statements - */ -export const schemaDiffToHuman = (items: SchemaDiff[]): string[] => { - return items.flatMap((item) => asHuman(item)); -}; - -export const asSql = (item: SchemaDiff, options: SchemaDiffToSqlOptions): string[] => { - const ctx = new BaseContext(options); - for (const transform of transformers) { - const result = transform(ctx, item); - if (!result) { - continue; - } - - return asArray(result).map((result) => result + withComments(options.comments, item)); - } - - throw new Error(`Unhandled schema diff type: ${item.type}`); -}; - -export const asHuman = (item: SchemaDiff): string => { - switch (item.type) { - case 'ExtensionCreate': { - return `The extension "${item.extension.name}" is missing and needs to be created`; - } - case 'ExtensionDrop': { - return `The extension "${item.extensionName}" exists but is no longer needed`; - } - case 'FunctionCreate': { - return `The function "${item.function.name}" is missing and needs to be created`; - } - case 'FunctionDrop': { - return `The function "${item.functionName}" exists but should be removed`; - } - case 'TableCreate': { - return `The table "${item.table.name}" is missing and needs to be created`; - } - case 'TableDrop': { - return `The table "${item.tableName}" exists but should be removed`; - } - case 'ColumnAdd': { - return `The column "${item.column.tableName}"."${item.column.name}" is missing and needs to be created`; - } - case 'ColumnRename': { - return `The column "${item.tableName}"."${item.oldName}" was renamed to "${item.tableName}"."${item.newName}"`; - } - case 'ColumnAlter': { - return `The column "${item.tableName}"."${item.columnName}" has changes that need to be applied ${JSON.stringify( - item.changes, - )}`; - } - case 'ColumnDrop': { - return `The column "${item.tableName}"."${item.columnName}" exists but should be removed`; - } - case 'ConstraintAdd': { - return `The constraint "${item.constraint.tableName}"."${item.constraint.name}" (${item.constraint.type}) is missing and needs to be created`; - } - case 'ConstraintRename': { - return `The constraint "${item.tableName}"."${item.oldName}" was renamed to "${item.tableName}"."${item.newName}"`; - } - case 'ConstraintDrop': { - return `The constraint "${item.tableName}"."${item.constraintName}" exists but should be removed`; - } - case 'IndexCreate': { - return `The index "${item.index.tableName}"."${item.index.name}" is missing and needs to be created`; - } - case 'IndexRename': { - return `The index "${item.tableName}"."${item.oldName}" was renamed to "${item.tableName}"."${item.newName}"`; - } - case 'IndexDrop': { - return `The index "${item.indexName}" exists but is no longer needed`; - } - case 'TriggerCreate': { - return `The trigger "${item.trigger.tableName}"."${item.trigger.name}" is missing and needs to be created`; - } - case 'TriggerDrop': { - return `The trigger "${item.tableName}"."${item.triggerName}" exists but is no longer needed`; - } - case 'ParameterSet': { - return `The configuration parameter "${item.parameter.name}" has a different value and needs to be updated to "${item.parameter.value}"`; - } - case 'ParameterReset': { - return `The configuration parameter "${item.parameterName}" is set, but should be reset to the default value`; - } - case 'EnumCreate': { - return `The enum "${item.enum.name}" is missing and needs to be created`; - } - case 'EnumDrop': { - return `The enum "${item.enumName}" exists but is no longer needed`; - } - case 'OverrideCreate': { - return `The override "${item.override.name}" is missing and needs to be created`; - } - case 'OverrideUpdate': { - return `The override "${item.override.name}" needs to be updated`; - } - case 'OverrideDrop': { - return `The override "${item.overrideName}" exists but is no longer needed`; - } - } -}; - -const withComments = (comments: boolean | undefined, item: SchemaDiff): string => { - if (!comments) { - return ''; - } - - return ` -- ${item.reason}`; -}; - -const asArray = (items: T | T[]): T[] => { - if (Array.isArray(items)) { - return items; - } - - return [items]; -}; diff --git a/server/src/sql-tools/schema-from-code.spec.ts b/server/src/sql-tools/schema-from-code.spec.ts deleted file mode 100644 index b0c88d1f57..0000000000 --- a/server/src/sql-tools/schema-from-code.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { readdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { schemaFromCode } from 'src/sql-tools/schema-from-code'; -import { SchemaFromCodeOptions } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const importModule = async (filePath: string) => { - const module = await import(filePath); - const options: SchemaFromCodeOptions = module.options; - - return { module, options }; -}; - -describe(schemaFromCode.name, () => { - it('should work', () => { - expect(schemaFromCode({ reset: true })).toEqual({ - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [], - warnings: [], - }); - }); - - describe('test files', () => { - const errorStubs = readdirSync('test/sql-tools/errors', { withFileTypes: true }); - for (const file of errorStubs) { - const filePath = join(file.parentPath, file.name); - it(filePath, async () => { - const { module, options } = await importModule(filePath); - - expect(module.message).toBeDefined(); - expect(() => schemaFromCode({ ...options, reset: true })).toThrowError(module.message); - }); - } - - const stubs = readdirSync('test/sql-tools', { withFileTypes: true }); - for (const file of stubs) { - if (file.isDirectory()) { - continue; - } - - const filePath = join(file.parentPath, file.name); - it(filePath, async () => { - const { module, options } = await importModule(filePath); - - expect(module.description).toBeDefined(); - expect(module.schema).toBeDefined(); - expect(schemaFromCode({ ...options, reset: true }), module.description).toEqual(module.schema); - }); - } - }); -}); diff --git a/server/src/sql-tools/schema-from-code.ts b/server/src/sql-tools/schema-from-code.ts deleted file mode 100644 index 2e19f414e4..0000000000 --- a/server/src/sql-tools/schema-from-code.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ProcessorContext } from 'src/sql-tools/contexts/processor-context'; -import { processors } from 'src/sql-tools/processors'; -import { getRegisteredItems, resetRegisteredItems } from 'src/sql-tools/register'; -import { ConstraintType, SchemaFromCodeOptions } from 'src/sql-tools/types'; - -/** - * Load schema from code (decorators, etc) - */ -export const schemaFromCode = (options: SchemaFromCodeOptions = {}) => { - try { - const ctx = new ProcessorContext(options); - const items = getRegisteredItems(); - - for (const processor of processors) { - processor(ctx, items); - } - - if (ctx.options.overrides) { - ctx.tables.push({ - name: ctx.overrideTableName, - columns: [ - { - name: 'name', - tableName: ctx.overrideTableName, - primary: true, - type: 'character varying', - nullable: false, - isArray: false, - synchronize: true, - }, - { - name: 'value', - tableName: ctx.overrideTableName, - primary: false, - type: 'jsonb', - nullable: false, - isArray: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: `${ctx.overrideTableName}_pkey`, - tableName: ctx.overrideTableName, - columnNames: ['name'], - synchronize: true, - }, - ], - synchronize: true, - }); - } - - return ctx.build(); - } finally { - if (options.reset) { - resetRegisteredItems(); - } - } -}; diff --git a/server/src/sql-tools/schema-from-database.ts b/server/src/sql-tools/schema-from-database.ts deleted file mode 100644 index ee34e9dd8d..0000000000 --- a/server/src/sql-tools/schema-from-database.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Kysely } from 'kysely'; -import { PostgresJSDialect } from 'kysely-postgres-js'; -import { Sql } from 'postgres'; -import { ReaderContext } from 'src/sql-tools/contexts/reader-context'; -import { readers } from 'src/sql-tools/readers'; -import { DatabaseSchema, PostgresDB, SchemaFromDatabaseOptions } from 'src/sql-tools/types'; - -export type DatabaseLike = Sql | Kysely; - -const isKysely = (db: DatabaseLike): db is Kysely => db instanceof Kysely; - -/** - * Load schema from a database url - */ -export const schemaFromDatabase = async ( - database: DatabaseLike, - options: SchemaFromDatabaseOptions = {}, -): Promise => { - const db = isKysely(database) - ? (database as Kysely) - : new Kysely({ dialect: new PostgresJSDialect({ postgres: database }) }); - const ctx = new ReaderContext(options); - - try { - for (const reader of readers) { - await reader(ctx, db); - } - - return ctx.build(); - } finally { - // only close the connection it we created it - if (!isKysely(database)) { - await db.destroy(); - } - } -}; diff --git a/server/src/sql-tools/transformers/column.transformer.spec.ts b/server/src/sql-tools/transformers/column.transformer.spec.ts deleted file mode 100644 index 6828e2a72d..0000000000 --- a/server/src/sql-tools/transformers/column.transformer.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformColumns } from 'src/sql-tools/transformers/column.transformer'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformColumns.name, () => { - describe('ColumnAdd', () => { - it('should work', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAdd', - column: { - name: 'column1', - tableName: 'table1', - primary: false, - type: 'character varying', - nullable: false, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD "column1" character varying NOT NULL;'); - }); - - it('should add a nullable column', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAdd', - column: { - name: 'column1', - tableName: 'table1', - primary: false, - type: 'character varying', - nullable: true, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD "column1" character varying;'); - }); - - it('should add a column with an enum type', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAdd', - column: { - name: 'column1', - tableName: 'table1', - primary: false, - type: 'character varying', - enumName: 'table1_column1_enum', - nullable: true, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD "column1" table1_column1_enum;'); - }); - - it('should add a column that is an array type', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAdd', - column: { - name: 'column1', - tableName: 'table1', - primary: false, - type: 'boolean', - nullable: true, - isArray: true, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD "column1" boolean[];'); - }); - }); - - describe('ColumnAlter', () => { - it('should make a column nullable', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { nullable: true }, - reason: 'unknown', - }), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" DROP NOT NULL;`]); - }); - - it('should make a column non-nullable', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { nullable: false }, - reason: 'unknown', - }), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET NOT NULL;`]); - }); - - it('should update the default value', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { default: 'uuid_generate_v4()' }, - reason: 'unknown', - }), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]); - }); - - it('should update the default value to NULL', () => { - expect( - transformColumns(ctx, { - type: 'ColumnAlter', - tableName: 'table1', - columnName: 'column1', - changes: { - default: 'NULL', - }, - reason: 'unknown', - }), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT NULL;`]); - }); - }); - - describe('ColumnDrop', () => { - it('should work', () => { - expect( - transformColumns(ctx, { - type: 'ColumnDrop', - tableName: 'table1', - columnName: 'column1', - reason: 'unknown', - }), - ).toEqual(`ALTER TABLE "table1" DROP COLUMN "column1";`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/column.transformer.ts b/server/src/sql-tools/transformers/column.transformer.ts deleted file mode 100644 index ffa565e533..0000000000 --- a/server/src/sql-tools/transformers/column.transformer.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { ColumnChanges, DatabaseColumn } from 'src/sql-tools/types'; - -export const transformColumns: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'ColumnAdd': { - return asColumnAdd(item.column); - } - - case 'ColumnAlter': { - return asColumnAlter(item.tableName, item.columnName, item.changes); - } - - case 'ColumnRename': { - return `ALTER TABLE "${item.tableName}" RENAME COLUMN "${item.oldName}" TO "${item.newName}";`; - } - - case 'ColumnDrop': { - return `ALTER TABLE "${item.tableName}" DROP COLUMN "${item.columnName}";`; - } - - default: { - return false; - } - } -}; - -const asColumnAdd = (column: DatabaseColumn): string => { - return ( - `ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` + getColumnModifiers(column) + ';' - ); -}; - -export const asColumnAlter = (tableName: string, columnName: string, changes: ColumnChanges): string[] => { - const base = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}"`; - const items: string[] = []; - if (changes.nullable !== undefined) { - items.push(changes.nullable ? `${base} DROP NOT NULL;` : `${base} SET NOT NULL;`); - } - - if (changes.default !== undefined) { - items.push(`${base} SET DEFAULT ${changes.default};`); - } - - if (changes.storage !== undefined) { - items.push(`${base} SET STORAGE ${changes.storage.toUpperCase()};`); - } - - if (changes.comment !== undefined) { - items.push(asColumnComment(tableName, columnName, changes.comment)); - } - - return items; -}; diff --git a/server/src/sql-tools/transformers/constraint.transformer.spec.ts b/server/src/sql-tools/transformers/constraint.transformer.spec.ts deleted file mode 100644 index 6e512afdca..0000000000 --- a/server/src/sql-tools/transformers/constraint.transformer.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformConstraints } from 'src/sql-tools/transformers/constraint.transformer'; -import { ConstraintType } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformConstraints.name, () => { - describe('ConstraintAdd', () => { - describe('primary keys', () => { - it('should work', () => { - expect( - transformConstraints(ctx, { - type: 'ConstraintAdd', - constraint: { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "PK_test" PRIMARY KEY ("id");'); - }); - }); - - describe('foreign keys', () => { - it('should work', () => { - expect( - transformConstraints(ctx, { - type: 'ConstraintAdd', - constraint: { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_test', - tableName: 'table1', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table2', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual( - 'ALTER TABLE "table1" ADD CONSTRAINT "FK_test" FOREIGN KEY ("parentId") REFERENCES "table2" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;', - ); - }); - }); - - describe('unique', () => { - it('should work', () => { - expect( - transformConstraints(ctx, { - type: 'ConstraintAdd', - constraint: { - type: ConstraintType.UNIQUE, - name: 'UQ_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "UQ_test" UNIQUE ("id");'); - }); - }); - - describe('check', () => { - it('should work', () => { - expect( - transformConstraints(ctx, { - type: 'ConstraintAdd', - constraint: { - type: ConstraintType.CHECK, - name: 'CHK_test', - tableName: 'table1', - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "CHK_test" CHECK ("id" IS NOT NULL);'); - }); - }); - }); - - describe('ConstraintDrop', () => { - it('should work', () => { - expect( - transformConstraints(ctx, { - type: 'ConstraintDrop', - tableName: 'table1', - constraintName: 'PK_test', - reason: 'unknown', - }), - ).toEqual(`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/constraint.transformer.ts b/server/src/sql-tools/transformers/constraint.transformer.ts deleted file mode 100644 index 94421e56fa..0000000000 --- a/server/src/sql-tools/transformers/constraint.transformer.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { asColumnList } from 'src/sql-tools/helpers'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { ActionType, ConstraintType, DatabaseConstraint } from 'src/sql-tools/types'; - -export const transformConstraints: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'ConstraintAdd': { - return `ALTER TABLE "${item.constraint.tableName}" ADD ${asConstraintBody(item.constraint)};`; - } - - case 'ConstraintRename': { - return `ALTER TABLE "${item.tableName}" RENAME CONSTRAINT "${item.oldName}" TO "${item.newName}";`; - } - - case 'ConstraintDrop': { - return `ALTER TABLE "${item.tableName}" DROP CONSTRAINT "${item.constraintName}";`; - } - default: { - return false; - } - } -}; - -const withAction = (constraint: { onDelete?: ActionType; onUpdate?: ActionType }) => - ` ON UPDATE ${constraint.onUpdate ?? ActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? ActionType.NO_ACTION}`; - -export const asConstraintBody = (constraint: DatabaseConstraint): string => { - const base = `CONSTRAINT "${constraint.name}"`; - - switch (constraint.type) { - case ConstraintType.PRIMARY_KEY: { - const columnNames = asColumnList(constraint.columnNames); - return `${base} PRIMARY KEY (${columnNames})`; - } - - case ConstraintType.FOREIGN_KEY: { - const columnNames = asColumnList(constraint.columnNames); - const referenceColumnNames = asColumnList(constraint.referenceColumnNames); - return ( - `${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` + - withAction(constraint) - ); - } - - case ConstraintType.UNIQUE: { - const columnNames = asColumnList(constraint.columnNames); - return `${base} UNIQUE (${columnNames})`; - } - - case ConstraintType.CHECK: { - return `${base} CHECK (${constraint.expression})`; - } - - default: { - throw new Error(`Unknown constraint type: ${(constraint as any).type}`); - } - } -}; diff --git a/server/src/sql-tools/transformers/enum.transformer.ts b/server/src/sql-tools/transformers/enum.transformer.ts deleted file mode 100644 index cd7bddc2d2..0000000000 --- a/server/src/sql-tools/transformers/enum.transformer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseEnum } from 'src/sql-tools/types'; - -export const transformEnums: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'EnumCreate': { - return asEnumCreate(item.enum); - } - - case 'EnumDrop': { - return asEnumDrop(item.enumName); - } - - default: { - return false; - } - } -}; - -const asEnumCreate = ({ name, values }: DatabaseEnum): string => { - return `CREATE TYPE "${name}" AS ENUM (${values.map((value) => `'${value}'`)});`; -}; - -const asEnumDrop = (enumName: string): string => { - return `DROP TYPE "${enumName}";`; -}; diff --git a/server/src/sql-tools/transformers/extension.transformer.spec.ts b/server/src/sql-tools/transformers/extension.transformer.spec.ts deleted file mode 100644 index 2ab0402875..0000000000 --- a/server/src/sql-tools/transformers/extension.transformer.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformExtensions } from 'src/sql-tools/transformers/extension.transformer'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformExtensions.name, () => { - describe('ExtensionDrop', () => { - it('should work', () => { - expect( - transformExtensions(ctx, { - type: 'ExtensionDrop', - extensionName: 'cube', - reason: 'unknown', - }), - ).toEqual(`DROP EXTENSION "cube";`); - }); - }); - - describe('ExtensionCreate', () => { - it('should work', () => { - expect( - transformExtensions(ctx, { - type: 'ExtensionCreate', - extension: { - name: 'cube', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual(`CREATE EXTENSION IF NOT EXISTS "cube";`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/extension.transformer.ts b/server/src/sql-tools/transformers/extension.transformer.ts deleted file mode 100644 index 26e76c1157..0000000000 --- a/server/src/sql-tools/transformers/extension.transformer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseExtension } from 'src/sql-tools/types'; - -export const transformExtensions: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'ExtensionCreate': { - return asExtensionCreate(item.extension); - } - - case 'ExtensionDrop': { - return asExtensionDrop(item.extensionName); - } - - default: { - return false; - } - } -}; - -const asExtensionCreate = (extension: DatabaseExtension): string => { - return `CREATE EXTENSION IF NOT EXISTS "${extension.name}";`; -}; - -const asExtensionDrop = (extensionName: string): string => { - return `DROP EXTENSION "${extensionName}";`; -}; diff --git a/server/src/sql-tools/transformers/function.transformer.spec.ts b/server/src/sql-tools/transformers/function.transformer.spec.ts deleted file mode 100644 index 5b0ba71c7d..0000000000 --- a/server/src/sql-tools/transformers/function.transformer.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformFunctions } from 'src/sql-tools/transformers/function.transformer'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformFunctions.name, () => { - describe('FunctionDrop', () => { - it('should work', () => { - expect( - transformFunctions(ctx, { - type: 'FunctionDrop', - functionName: 'test_func', - reason: 'unknown', - }), - ).toEqual(`DROP FUNCTION test_func;`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/function.transformer.ts b/server/src/sql-tools/transformers/function.transformer.ts deleted file mode 100644 index 42a56cbe13..0000000000 --- a/server/src/sql-tools/transformers/function.transformer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseFunction } from 'src/sql-tools/types'; - -export const transformFunctions: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'FunctionCreate': { - return asFunctionCreate(item.function); - } - - case 'FunctionDrop': { - return asFunctionDrop(item.functionName); - } - - default: { - return false; - } - } -}; - -export const asFunctionCreate = (func: DatabaseFunction): string => { - return func.expression; -}; - -const asFunctionDrop = (functionName: string): string => { - return `DROP FUNCTION ${functionName};`; -}; diff --git a/server/src/sql-tools/transformers/index.transformer.spec.ts b/server/src/sql-tools/transformers/index.transformer.spec.ts deleted file mode 100644 index c9656463bf..0000000000 --- a/server/src/sql-tools/transformers/index.transformer.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformIndexes } from 'src/sql-tools/transformers/index.transformer'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformIndexes.name, () => { - describe('IndexCreate', () => { - it('should work', () => { - expect( - transformIndexes(ctx, { - type: 'IndexCreate', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['column1'], - unique: false, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("column1");'); - }); - - it('should create an unique index', () => { - expect( - transformIndexes(ctx, { - type: 'IndexCreate', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['column1'], - unique: true, - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1");'); - }); - - it('should create an index with a custom expression', () => { - expect( - transformIndexes(ctx, { - type: 'IndexCreate', - index: { - name: 'IDX_test', - tableName: 'table1', - unique: false, - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL);'); - }); - - it('should create an index with a where clause', () => { - expect( - transformIndexes(ctx, { - type: 'IndexCreate', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['id'], - unique: false, - where: '("id" IS NOT NULL)', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL);'); - }); - - it('should create an index with a custom expression', () => { - expect( - transformIndexes(ctx, { - type: 'IndexCreate', - index: { - name: 'IDX_test', - tableName: 'table1', - unique: false, - using: 'gin', - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual('CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL);'); - }); - }); - - describe('IndexDrop', () => { - it('should work', () => { - expect( - transformIndexes(ctx, { - type: 'IndexDrop', - indexName: 'IDX_test', - reason: 'unknown', - }), - ).toEqual(`DROP INDEX "IDX_test";`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/index.transformer.ts b/server/src/sql-tools/transformers/index.transformer.ts deleted file mode 100644 index acd65140ee..0000000000 --- a/server/src/sql-tools/transformers/index.transformer.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { asColumnList } from 'src/sql-tools/helpers'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseIndex } from 'src/sql-tools/types'; - -export const transformIndexes: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'IndexCreate': { - return asIndexCreate(item.index); - } - - case 'IndexRename': { - return `ALTER INDEX "${item.oldName}" RENAME TO "${item.newName}";`; - } - - case 'IndexDrop': { - return `DROP INDEX "${item.indexName}";`; - } - - default: { - return false; - } - } -}; - -export const asIndexCreate = (index: DatabaseIndex): string => { - let sql = `CREATE`; - - if (index.unique) { - sql += ' UNIQUE'; - } - - sql += ` INDEX "${index.name}" ON "${index.tableName}"`; - - if (index.columnNames) { - const columnNames = asColumnList(index.columnNames); - sql += ` (${columnNames})`; - } - - if (index.using && index.using !== 'btree') { - sql += ` USING ${index.using}`; - } - - if (index.expression) { - sql += ` (${index.expression})`; - } - - if (index.with) { - sql += ` WITH (${index.with})`; - } - - if (index.where) { - sql += ` WHERE ${index.where}`; - } - - return sql + ';'; -}; diff --git a/server/src/sql-tools/transformers/index.ts b/server/src/sql-tools/transformers/index.ts deleted file mode 100644 index 395d69f2e2..0000000000 --- a/server/src/sql-tools/transformers/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { transformColumns } from 'src/sql-tools/transformers/column.transformer'; -import { transformConstraints } from 'src/sql-tools/transformers/constraint.transformer'; -import { transformEnums } from 'src/sql-tools/transformers/enum.transformer'; -import { transformExtensions } from 'src/sql-tools/transformers/extension.transformer'; -import { transformFunctions } from 'src/sql-tools/transformers/function.transformer'; -import { transformIndexes } from 'src/sql-tools/transformers/index.transformer'; -import { transformOverrides } from 'src/sql-tools/transformers/override.transformer'; -import { transformParameters } from 'src/sql-tools/transformers/parameter.transformer'; -import { transformTables } from 'src/sql-tools/transformers/table.transformer'; -import { transformTriggers } from 'src/sql-tools/transformers/trigger.transformer'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; - -export const transformers: SqlTransformer[] = [ - transformColumns, - transformConstraints, - transformEnums, - transformExtensions, - transformFunctions, - transformIndexes, - transformParameters, - transformTables, - transformTriggers, - transformOverrides, -]; diff --git a/server/src/sql-tools/transformers/override.transformer.ts b/server/src/sql-tools/transformers/override.transformer.ts deleted file mode 100644 index 1e2e981128..0000000000 --- a/server/src/sql-tools/transformers/override.transformer.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { asJsonString } from 'src/sql-tools/helpers'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseOverride } from 'src/sql-tools/types'; - -export const transformOverrides: SqlTransformer = (ctx, item) => { - const tableName = ctx.overrideTableName; - - switch (item.type) { - case 'OverrideCreate': { - return asOverrideCreate(tableName, item.override); - } - - case 'OverrideUpdate': { - return asOverrideUpdate(tableName, item.override); - } - - case 'OverrideDrop': { - return asOverrideDrop(tableName, item.overrideName); - } - - default: { - return false; - } - } -}; - -export const asOverrideCreate = (tableName: string, override: DatabaseOverride): string => { - return `INSERT INTO "${tableName}" ("name", "value") VALUES ('${override.name}', ${asJsonString(override.value)});`; -}; - -export const asOverrideUpdate = (tableName: string, override: DatabaseOverride): string => { - return `UPDATE "${tableName}" SET "value" = ${asJsonString(override.value)} WHERE "name" = '${override.name}';`; -}; - -export const asOverrideDrop = (tableName: string, overrideName: string): string => { - return `DELETE FROM "${tableName}" WHERE "name" = '${overrideName}';`; -}; diff --git a/server/src/sql-tools/transformers/parameter.transformer.ts b/server/src/sql-tools/transformers/parameter.transformer.ts deleted file mode 100644 index d23472f991..0000000000 --- a/server/src/sql-tools/transformers/parameter.transformer.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseParameter } from 'src/sql-tools/types'; - -export const transformParameters: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'ParameterSet': { - return asParameterSet(item.parameter); - } - - case 'ParameterReset': { - return asParameterReset(item.databaseName, item.parameterName); - } - - default: { - return false; - } - } -}; - -const asParameterSet = (parameter: DatabaseParameter): string => { - let sql = ''; - if (parameter.scope === 'database') { - sql += `ALTER DATABASE "${parameter.databaseName}" `; - } - - sql += `SET ${parameter.name} TO ${parameter.value}`; - - return sql; -}; - -const asParameterReset = (databaseName: string, parameterName: string): string => { - return `ALTER DATABASE "${databaseName}" RESET "${parameterName}"`; -}; diff --git a/server/src/sql-tools/transformers/table.transformer.spec.ts b/server/src/sql-tools/transformers/table.transformer.spec.ts deleted file mode 100644 index 0d89fcd278..0000000000 --- a/server/src/sql-tools/transformers/table.transformer.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformTables } from 'src/sql-tools/transformers/table.transformer'; -import { ConstraintType, DatabaseTable } from 'src/sql-tools/types'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -const table1: DatabaseTable = { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - primary: true, - type: 'character varying', - nullable: true, - isArray: false, - synchronize: true, - }, - { - name: 'column2', - primary: false, - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'index1', - tableName: 'table1', - columnNames: ['column2'], - unique: false, - synchronize: true, - }, - ], - constraints: [ - { - name: 'constraint1', - tableName: 'table1', - columnNames: ['column1'], - type: ConstraintType.PRIMARY_KEY, - synchronize: true, - }, - { - name: 'constraint2', - tableName: 'table1', - columnNames: ['column1'], - type: ConstraintType.FOREIGN_KEY, - referenceTableName: 'table2', - referenceColumnNames: ['parentId'], - synchronize: true, - }, - { - name: 'constraint3', - tableName: 'table1', - columnNames: ['column1'], - type: ConstraintType.UNIQUE, - synchronize: true, - }, - ], - triggers: [], - synchronize: true, -}; - -describe(transformTables.name, () => { - describe('TableDrop', () => { - it('should work', () => { - expect( - transformTables(ctx, { - type: 'TableDrop', - tableName: 'table1', - reason: 'unknown', - }), - ).toEqual(`DROP TABLE "table1";`); - }); - }); - - describe('TableCreate', () => { - it('should work', () => { - expect( - transformTables(ctx, { - type: 'TableCreate', - table: table1, - reason: 'unknown', - }), - ).toEqual([ - `CREATE TABLE "table1" ( - "column1" character varying, - "column2" character varying, - CONSTRAINT "constraint1" PRIMARY KEY ("column1"), - CONSTRAINT "constraint2" FOREIGN KEY ("column1") REFERENCES "table2" ("parentId") ON UPDATE NO ACTION ON DELETE NO ACTION, - CONSTRAINT "constraint3" UNIQUE ("column1") -);`, - `CREATE INDEX "index1" ON "table1" ("column2");`, - ]); - }); - - it('should handle a non-nullable column', () => { - expect( - transformTables(ctx, { - type: 'TableCreate', - table: { - name: 'table1', - columns: [ - { - tableName: 'table1', - primary: false, - name: 'column1', - type: 'character varying', - isArray: false, - nullable: false, - synchronize: true, - }, - ], - indexes: [], - constraints: [], - triggers: [], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual([ - `CREATE TABLE "table1" ( - "column1" character varying NOT NULL -);`, - ]); - }); - - it('should handle a default value', () => { - expect( - transformTables(ctx, { - type: 'TableCreate', - table: { - name: 'table1', - columns: [ - { - tableName: 'table1', - name: 'column1', - primary: false, - type: 'character varying', - isArray: false, - nullable: true, - default: 'uuid_generate_v4()', - synchronize: true, - }, - ], - indexes: [], - constraints: [], - triggers: [], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual([ - `CREATE TABLE "table1" ( - "column1" character varying DEFAULT uuid_generate_v4() -);`, - ]); - }); - - it('should handle a string with a fixed length', () => { - expect( - transformTables(ctx, { - type: 'TableCreate', - table: { - name: 'table1', - columns: [ - { - tableName: 'table1', - primary: false, - name: 'column1', - type: 'character varying', - length: 2, - isArray: false, - nullable: true, - synchronize: true, - }, - ], - indexes: [], - constraints: [], - triggers: [], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual([ - `CREATE TABLE "table1" ( - "column1" character varying(2) -);`, - ]); - }); - - it('should handle an array type', () => { - expect( - transformTables(ctx, { - type: 'TableCreate', - table: { - name: 'table1', - columns: [ - { - tableName: 'table1', - primary: false, - name: 'column1', - type: 'character varying', - isArray: true, - nullable: true, - synchronize: true, - }, - ], - indexes: [], - constraints: [], - triggers: [], - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual([ - `CREATE TABLE "table1" ( - "column1" character varying[] -);`, - ]); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/table.transformer.ts b/server/src/sql-tools/transformers/table.transformer.ts deleted file mode 100644 index a81bfc25aa..0000000000 --- a/server/src/sql-tools/transformers/table.transformer.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers'; -import { asColumnAlter } from 'src/sql-tools/transformers/column.transformer'; -import { asConstraintBody } from 'src/sql-tools/transformers/constraint.transformer'; -import { asIndexCreate } from 'src/sql-tools/transformers/index.transformer'; -import { asTriggerCreate } from 'src/sql-tools/transformers/trigger.transformer'; -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseTable } from 'src/sql-tools/types'; - -export const transformTables: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'TableCreate': { - return asTableCreate(item.table); - } - - case 'TableDrop': { - return asTableDrop(item.tableName); - } - - default: { - return false; - } - } -}; - -const asTableCreate = (table: DatabaseTable) => { - const tableName = table.name; - - const items: string[] = []; - for (const column of table.columns) { - items.push(`"${column.name}" ${getColumnType(column)}${getColumnModifiers(column)}`); - } - - for (const constraint of table.constraints) { - items.push(asConstraintBody(constraint)); - } - - const sql = [`CREATE TABLE "${tableName}" (\n ${items.join(',\n ')}\n);`]; - - for (const column of table.columns) { - if (column.comment) { - sql.push(asColumnComment(tableName, column.name, column.comment)); - } - - if (column.storage) { - sql.push(...asColumnAlter(tableName, column.name, { storage: column.storage })); - } - } - - for (const index of table.indexes) { - sql.push(asIndexCreate(index)); - } - - for (const trigger of table.triggers) { - sql.push(asTriggerCreate(trigger)); - } - - return sql; -}; - -const asTableDrop = (tableName: string) => { - return `DROP TABLE "${tableName}";`; -}; diff --git a/server/src/sql-tools/transformers/trigger.transformer.spec.ts b/server/src/sql-tools/transformers/trigger.transformer.spec.ts deleted file mode 100644 index f6ba889c29..0000000000 --- a/server/src/sql-tools/transformers/trigger.transformer.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { transformTriggers } from 'src/sql-tools/transformers/trigger.transformer'; -import { describe, expect, it } from 'vitest'; - -const ctx = new BaseContext({}); - -describe(transformTriggers.name, () => { - describe('TriggerCreate', () => { - it('should work', () => { - expect( - transformTriggers(ctx, { - type: 'TriggerCreate', - trigger: { - name: 'trigger1', - tableName: 'table1', - timing: 'before', - actions: ['update'], - scope: 'row', - functionName: 'function1', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual( - `CREATE OR REPLACE TRIGGER "trigger1" - BEFORE UPDATE ON "table1" - FOR EACH ROW - EXECUTE FUNCTION function1();`, - ); - }); - - it('should work with multiple actions', () => { - expect( - transformTriggers(ctx, { - type: 'TriggerCreate', - trigger: { - name: 'trigger1', - tableName: 'table1', - timing: 'before', - actions: ['update', 'delete'], - scope: 'row', - functionName: 'function1', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual( - `CREATE OR REPLACE TRIGGER "trigger1" - BEFORE UPDATE OR DELETE ON "table1" - FOR EACH ROW - EXECUTE FUNCTION function1();`, - ); - }); - - it('should work with old/new reference table aliases', () => { - expect( - transformTriggers(ctx, { - type: 'TriggerCreate', - trigger: { - name: 'trigger1', - tableName: 'table1', - timing: 'before', - actions: ['update'], - referencingNewTableAs: 'new', - referencingOldTableAs: 'old', - scope: 'row', - functionName: 'function1', - synchronize: true, - }, - reason: 'unknown', - }), - ).toEqual( - `CREATE OR REPLACE TRIGGER "trigger1" - BEFORE UPDATE ON "table1" - REFERENCING OLD TABLE AS "old" NEW TABLE AS "new" - FOR EACH ROW - EXECUTE FUNCTION function1();`, - ); - }); - }); - - describe('TriggerDrop', () => { - it('should work', () => { - expect( - transformTriggers(ctx, { - type: 'TriggerDrop', - tableName: 'table1', - triggerName: 'trigger1', - reason: 'unknown', - }), - ).toEqual(`DROP TRIGGER "trigger1" ON "table1";`); - }); - }); -}); diff --git a/server/src/sql-tools/transformers/trigger.transformer.ts b/server/src/sql-tools/transformers/trigger.transformer.ts deleted file mode 100644 index fca557abfc..0000000000 --- a/server/src/sql-tools/transformers/trigger.transformer.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { SqlTransformer } from 'src/sql-tools/transformers/types'; -import { DatabaseTrigger } from 'src/sql-tools/types'; - -export const transformTriggers: SqlTransformer = (ctx, item) => { - switch (item.type) { - case 'TriggerCreate': { - return asTriggerCreate(item.trigger); - } - - case 'TriggerDrop': { - return asTriggerDrop(item.tableName, item.triggerName); - } - - default: { - return false; - } - } -}; - -export const asTriggerCreate = (trigger: DatabaseTrigger): string => { - const sql: string[] = [ - `CREATE OR REPLACE TRIGGER "${trigger.name}"`, - `${trigger.timing.toUpperCase()} ${trigger.actions.map((action) => action.toUpperCase()).join(' OR ')} ON "${trigger.tableName}"`, - ]; - - if (trigger.referencingOldTableAs || trigger.referencingNewTableAs) { - let statement = `REFERENCING`; - if (trigger.referencingOldTableAs) { - statement += ` OLD TABLE AS "${trigger.referencingOldTableAs}"`; - } - if (trigger.referencingNewTableAs) { - statement += ` NEW TABLE AS "${trigger.referencingNewTableAs}"`; - } - sql.push(statement); - } - - if (trigger.scope) { - sql.push(`FOR EACH ${trigger.scope.toUpperCase()}`); - } - - if (trigger.when) { - sql.push(`WHEN (${trigger.when})`); - } - - sql.push(`EXECUTE FUNCTION ${trigger.functionName}();`); - - return sql.join('\n '); -}; - -export const asTriggerDrop = (tableName: string, triggerName: string): string => { - return `DROP TRIGGER "${triggerName}" ON "${tableName}";`; -}; diff --git a/server/src/sql-tools/transformers/types.ts b/server/src/sql-tools/transformers/types.ts deleted file mode 100644 index 96cbe4d918..0000000000 --- a/server/src/sql-tools/transformers/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { BaseContext } from 'src/sql-tools/contexts/base-context'; -import { SchemaDiff } from 'src/sql-tools/types'; - -export type SqlTransformer = (ctx: BaseContext, item: SchemaDiff) => string | string[] | false; diff --git a/server/src/sql-tools/types.ts b/server/src/sql-tools/types.ts deleted file mode 100644 index 9d93a79ff1..0000000000 --- a/server/src/sql-tools/types.ts +++ /dev/null @@ -1,538 +0,0 @@ -import { Kysely, ColumnType as KyselyColumnType } from 'kysely'; -import { ProcessorContext } from 'src/sql-tools/contexts/processor-context'; -import { ReaderContext } from 'src/sql-tools/contexts/reader-context'; -import { NamingInterface } from 'src/sql-tools/naming/naming.interface'; -import { RegisterItem } from 'src/sql-tools/register-item'; - -export type BaseContextOptions = { - databaseName?: string; - schemaName?: string; - overrideTableName?: string; - namingStrategy?: 'default' | 'hash' | NamingInterface; -}; - -export type SchemaFromCodeOptions = BaseContextOptions & { - /** automatically create indexes on foreign key columns */ - createForeignKeyIndexes?: boolean; - reset?: boolean; - - functions?: boolean; - extensions?: boolean; - parameters?: boolean; - overrides?: boolean; -}; - -export type SchemaFromDatabaseOptions = BaseContextOptions; - -export type SchemaDiffToSqlOptions = BaseContextOptions & { - comments?: boolean; -}; - -export type SchemaDiffOptions = BaseContextOptions & { - tables?: IgnoreOptions; - columns?: IgnoreOptions; - indexes?: IgnoreOptions; - triggers?: IgnoreOptions; - constraints?: IgnoreOptions; - functions?: IgnoreOptions; - enums?: IgnoreOptions; - extensions?: IgnoreOptions; - parameters?: IgnoreOptions; - overrides?: IgnoreOptions; -}; - -export type IgnoreOptions = - | boolean - | { - ignoreExtra?: boolean; - ignoreMissing?: boolean; - }; - -export type Processor = (ctx: ProcessorContext, items: RegisterItem[]) => void; -export type Reader = (ctx: ReaderContext, db: DatabaseClient) => Promise; - -export type PostgresDB = { - pg_am: { - oid: number; - amname: string; - amhandler: string; - amtype: string; - }; - - pg_attribute: { - attrelid: number; - attname: string; - attnum: number; - atttypeid: number; - attstattarget: number; - attstatarget: number; - aanum: number; - }; - - pg_class: { - oid: number; - relname: string; - relkind: string; - relnamespace: string; - reltype: string; - relowner: string; - relam: string; - relfilenode: string; - reltablespace: string; - relpages: number; - reltuples: number; - relallvisible: number; - reltoastrelid: string; - relhasindex: PostgresYesOrNo; - relisshared: PostgresYesOrNo; - relpersistence: string; - }; - - pg_constraint: { - oid: number; - conname: string; - conrelid: string; - contype: string; - connamespace: string; - conkey: number[]; - confkey: number[]; - confrelid: string; - confupdtype: string; - confdeltype: string; - confmatchtype: number; - condeferrable: PostgresYesOrNo; - condeferred: PostgresYesOrNo; - convalidated: PostgresYesOrNo; - conindid: number; - }; - - pg_description: { - objoid: string; - classoid: string; - objsubid: number; - description: string; - }; - - pg_trigger: { - oid: string; - tgisinternal: boolean; - tginitdeferred: boolean; - tgdeferrable: boolean; - tgrelid: string; - tgfoid: string; - tgname: string; - tgenabled: string; - tgtype: number; - tgconstraint: string; - tgdeferred: boolean; - tgargs: Buffer; - tgoldtable: string; - tgnewtable: string; - tgqual: string; - }; - - 'pg_catalog.pg_extension': { - oid: string; - extname: string; - extowner: string; - extnamespace: string; - extrelocatable: boolean; - extversion: string; - extconfig: string[]; - extcondition: string[]; - }; - - pg_enum: { - oid: string; - enumtypid: string; - enumsortorder: number; - enumlabel: string; - }; - - pg_index: { - indexrelid: string; - indrelid: string; - indisready: boolean; - indexprs: string | null; - indpred: string | null; - indkey: number[]; - indisprimary: boolean; - indisunique: boolean; - }; - - pg_indexes: { - schemaname: string; - tablename: string; - indexname: string; - tablespace: string | null; - indexrelid: string; - indexdef: string; - }; - - pg_namespace: { - oid: number; - nspname: string; - nspowner: number; - nspacl: string[]; - }; - - pg_type: { - oid: string; - typname: string; - typnamespace: string; - typowner: string; - typtype: string; - typcategory: string; - typarray: string; - }; - - pg_depend: { - objid: string; - deptype: string; - }; - - pg_proc: { - oid: string; - proname: string; - pronamespace: string; - prokind: string; - }; - - pg_settings: { - name: string; - setting: string; - unit: string | null; - category: string; - short_desc: string | null; - extra_desc: string | null; - context: string; - vartype: string; - source: string; - min_val: string | null; - max_val: string | null; - enumvals: string[] | null; - boot_val: string | null; - reset_val: string | null; - sourcefile: string | null; - sourceline: number | null; - pending_restart: PostgresYesOrNo; - }; - - 'information_schema.tables': { - table_catalog: string; - table_schema: string; - table_name: string; - table_type: 'VIEW' | 'BASE TABLE' | string; - is_insertable_info: PostgresYesOrNo; - is_typed: PostgresYesOrNo; - commit_action: string | null; - }; - - 'information_schema.columns': { - table_catalog: string; - table_schema: string; - table_name: string; - column_name: string; - ordinal_position: number; - column_default: string | null; - is_nullable: PostgresYesOrNo; - data_type: string; - dtd_identifier: string; - character_maximum_length: number | null; - character_octet_length: number | null; - numeric_precision: number | null; - numeric_precision_radix: number | null; - numeric_scale: number | null; - datetime_precision: number | null; - interval_type: string | null; - interval_precision: number | null; - udt_catalog: string; - udt_schema: string; - udt_name: string; - maximum_cardinality: number | null; - is_updatable: PostgresYesOrNo; - }; - - 'information_schema.element_types': { - object_catalog: string; - object_schema: string; - object_name: string; - object_type: string; - collection_type_identifier: string; - data_type: string; - }; - - 'information_schema.routines': { - specific_catalog: string; - specific_schema: string; - specific_name: string; - routine_catalog: string; - routine_schema: string; - routine_name: string; - routine_type: string; - data_type: string; - type_udt_catalog: string; - type_udt_schema: string; - type_udt_name: string; - dtd_identifier: string; - routine_body: string; - routine_definition: string; - external_name: string; - external_language: string; - is_deterministic: PostgresYesOrNo; - security_type: string; - }; -}; - -type PostgresYesOrNo = 'YES' | 'NO'; - -export type DatabaseClient = Kysely; - -export enum ConstraintType { - PRIMARY_KEY = 'primary-key', - FOREIGN_KEY = 'foreign-key', - UNIQUE = 'unique', - CHECK = 'check', -} - -export enum ActionType { - NO_ACTION = 'NO ACTION', - RESTRICT = 'RESTRICT', - CASCADE = 'CASCADE', - SET_NULL = 'SET NULL', - SET_DEFAULT = 'SET DEFAULT', -} - -export type ColumnStorage = 'default' | 'external' | 'extended' | 'main'; - -export type ColumnType = - | 'bigint' - | 'boolean' - | 'bytea' - | 'character' - | 'character varying' - | 'date' - | 'double precision' - | 'integer' - | 'jsonb' - | 'polygon' - | 'text' - | 'time' - | 'time with time zone' - | 'time without time zone' - | 'timestamp' - | 'timestamp with time zone' - | 'timestamp without time zone' - | 'uuid' - | 'vector' - | 'enum' - | 'serial' - | 'real'; - -export type DatabaseSchema = { - databaseName: string; - schemaName: string; - functions: DatabaseFunction[]; - enums: DatabaseEnum[]; - tables: DatabaseTable[]; - extensions: DatabaseExtension[]; - parameters: DatabaseParameter[]; - overrides: DatabaseOverride[]; - warnings: string[]; -}; - -export type DatabaseParameter = { - name: string; - databaseName: string; - value: string | number | null | undefined; - scope: ParameterScope; - synchronize: boolean; -}; - -export type ParameterScope = 'database' | 'user'; - -export type DatabaseOverride = { - name: string; - value: { name: string; type: OverrideType; sql: string }; - synchronize: boolean; -}; - -export type OverrideType = 'function' | 'index' | 'trigger'; - -export type DatabaseEnum = { - name: string; - values: string[]; - synchronize: boolean; -}; - -export type DatabaseFunction = { - name: string; - expression: string; - synchronize: boolean; - override?: DatabaseOverride; -}; - -export type DatabaseExtension = { - name: string; - synchronize: boolean; -}; - -export type DatabaseTable = { - name: string; - columns: DatabaseColumn[]; - indexes: DatabaseIndex[]; - constraints: DatabaseConstraint[]; - triggers: DatabaseTrigger[]; - synchronize: boolean; -}; - -export type DatabaseConstraint = - | DatabasePrimaryKeyConstraint - | DatabaseForeignKeyConstraint - | DatabaseUniqueConstraint - | DatabaseCheckConstraint; - -export type DatabaseColumn = { - primary: boolean; - name: string; - tableName: string; - comment?: string; - - type: ColumnType; - nullable: boolean; - isArray: boolean; - synchronize: boolean; - - default?: string; - length?: number; - storage?: ColumnStorage; - identity?: boolean; - - // enum values - enumName?: string; - - // numeric types - numericPrecision?: number; - numericScale?: number; -}; - -export type ColumnChanges = { - nullable?: boolean; - default?: string; - comment?: string; - storage?: ColumnStorage; -}; - -type ColumBasedConstraint = { - name: string; - tableName: string; - columnNames: string[]; -}; - -export type DatabasePrimaryKeyConstraint = ColumBasedConstraint & { - type: ConstraintType.PRIMARY_KEY; - synchronize: boolean; -}; - -export type DatabaseUniqueConstraint = ColumBasedConstraint & { - type: ConstraintType.UNIQUE; - synchronize: boolean; -}; - -export type DatabaseForeignKeyConstraint = ColumBasedConstraint & { - type: ConstraintType.FOREIGN_KEY; - referenceTableName: string; - referenceColumnNames: string[]; - onUpdate?: ActionType; - onDelete?: ActionType; - synchronize: boolean; -}; - -export type DatabaseCheckConstraint = { - type: ConstraintType.CHECK; - name: string; - tableName: string; - expression: string; - synchronize: boolean; -}; - -export type DatabaseTrigger = { - name: string; - tableName: string; - timing: TriggerTiming; - actions: TriggerAction[]; - scope: TriggerScope; - referencingNewTableAs?: string; - referencingOldTableAs?: string; - when?: string; - functionName: string; - override?: DatabaseOverride; - synchronize: boolean; -}; -export type TriggerTiming = 'before' | 'after' | 'instead of'; -export type TriggerAction = 'insert' | 'update' | 'delete' | 'truncate'; -export type TriggerScope = 'row' | 'statement'; - -export type DatabaseIndex = { - name: string; - tableName: string; - columnNames?: string[]; - expression?: string; - unique: boolean; - using?: string; - with?: string; - where?: string; - override?: DatabaseOverride; - synchronize: boolean; -}; - -export type SchemaDiff = { reason: string } & ( - | { type: 'ExtensionCreate'; extension: DatabaseExtension } - | { type: 'ExtensionDrop'; extensionName: string } - | { type: 'FunctionCreate'; function: DatabaseFunction } - | { type: 'FunctionDrop'; functionName: string } - | { type: 'TableCreate'; table: DatabaseTable } - | { type: 'TableDrop'; tableName: string } - | { type: 'ColumnAdd'; column: DatabaseColumn } - | { type: 'ColumnRename'; tableName: string; oldName: string; newName: string } - | { type: 'ColumnAlter'; tableName: string; columnName: string; changes: ColumnChanges } - | { type: 'ColumnDrop'; tableName: string; columnName: string } - | { type: 'ConstraintAdd'; constraint: DatabaseConstraint } - | { type: 'ConstraintRename'; tableName: string; oldName: string; newName: string } - | { type: 'ConstraintDrop'; tableName: string; constraintName: string } - | { type: 'IndexCreate'; index: DatabaseIndex } - | { type: 'IndexRename'; tableName: string; oldName: string; newName: string } - | { type: 'IndexDrop'; indexName: string } - | { type: 'TriggerCreate'; trigger: DatabaseTrigger } - | { type: 'TriggerDrop'; tableName: string; triggerName: string } - | { type: 'ParameterSet'; parameter: DatabaseParameter } - | { type: 'ParameterReset'; databaseName: string; parameterName: string } - | { type: 'EnumCreate'; enum: DatabaseEnum } - | { type: 'EnumDrop'; enumName: string } - | { type: 'OverrideCreate'; override: DatabaseOverride } - | { type: 'OverrideUpdate'; override: DatabaseOverride } - | { type: 'OverrideDrop'; overrideName: string } -); - -export type CompareFunction = (source: T, target: T) => SchemaDiff[]; -export type Comparer = { - onMissing: (source: T) => SchemaDiff[]; - onExtra: (target: T) => SchemaDiff[]; - onCompare: CompareFunction; - /** if two items have the same key, they are considered identical and can be renamed via `onRename` */ - getRenameKey?: (item: T) => string; - onRename?: (source: T, target: T) => SchemaDiff[]; -}; - -export enum Reason { - MissingInSource = 'missing in source', - MissingInTarget = 'missing in target', - Rename = 'name has changed', -} - -export type Timestamp = KyselyColumnType; -export type Generated = - T extends KyselyColumnType - ? KyselyColumnType - : KyselyColumnType; -export type Int8 = KyselyColumnType; diff --git a/server/src/types.ts b/server/src/types.ts index 3e9ea25957..8cf128f497 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -8,7 +8,6 @@ import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { AssetOrder, AssetType, - DatabaseSslMode, ExifOrientation, ImageFormat, JobName, @@ -393,23 +392,6 @@ export type JobItem = export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; -export type DatabaseConnectionURL = { - connectionType: 'url'; - url: string; -}; - -export type DatabaseConnectionParts = { - connectionType: 'parts'; - host: string; - port: number; - username: string; - password: string; - database: string; - ssl?: DatabaseSslMode; -}; - -export type DatabaseConnectionParams = DatabaseConnectionURL | DatabaseConnectionParts; - export interface ExtensionVersion { name: VectorExtension; availableVersion: string | null; diff --git a/server/src/utils/database.spec.ts b/server/src/utils/database.spec.ts deleted file mode 100644 index 4c6a82ad8f..0000000000 --- a/server/src/utils/database.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { asPostgresConnectionConfig } from 'src/utils/database'; - -describe('database utils', () => { - describe('asPostgresConnectionConfig', () => { - it('should handle sslmode=require', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require', - }), - ).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=prefer', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer', - }), - ).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=verify-ca', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca', - }), - ).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=verify-full', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full', - }), - ).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=no-verify', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify', - }), - ).toMatchObject({ ssl: { rejectUnauthorized: false } }); - }); - - it('should handle ssl=true', () => { - expect( - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true', - }), - ).toMatchObject({ ssl: true }); - }); - - it('should reject invalid ssl', () => { - expect(() => - asPostgresConnectionConfig({ - connectionType: 'url', - url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid', - }), - ).toThrowError('Invalid ssl option'); - }); - - it('should handle socket: URLs', () => { - expect( - asPostgresConnectionConfig({ connectionType: 'url', url: 'socket:/run/postgresql?db=database1' }), - ).toMatchObject({ host: '/run/postgresql', database: 'database1' }); - }); - - it('should handle sockets in postgres: URLs', () => { - expect( - asPostgresConnectionConfig({ connectionType: 'url', url: 'postgres:///database2?host=/path/to/socket' }), - ).toMatchObject({ - host: '/path/to/socket', - database: 'database2', - }); - }); - }); -}); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 9ae15fd7d5..4dd0c9b302 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,3 +1,4 @@ +import { createPostgres, DatabaseConnectionParams } from '@immich/sql-tools'; import { AliasedRawBuilder, DeduplicateJoinsPlugin, @@ -14,90 +15,24 @@ import { } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; -import { parse } from 'pg-connection-string'; -import postgres, { Notice, PostgresError } from 'postgres'; +import { Notice, PostgresError } from 'postgres'; import { columns, Exif, lockableProperties, LockableProperty, Person } from 'src/database'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; +import { AssetFileType, AssetVisibility, DatabaseExtension } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; -import { DatabaseConnectionParams, VectorExtension } from 'src/types'; - -type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; - -const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl => - typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full'; - -export const asPostgresConnectionConfig = (params: DatabaseConnectionParams) => { - if (params.connectionType === 'parts') { - return { - host: params.host, - port: params.port, - username: params.username, - password: params.password, - database: params.database, - ssl: params.ssl === DatabaseSslMode.Disable ? false : params.ssl, - }; - } - - const { host, port, user, password, database, ...rest } = parse(params.url); - let ssl: Ssl | undefined; - if (rest.ssl) { - if (!isValidSsl(rest.ssl)) { - throw new Error(`Invalid ssl option: ${rest.ssl}`); - } - ssl = rest.ssl; - } - - return { - host: host ?? undefined, - port: port ? Number(port) : undefined, - username: user, - password, - database: database ?? undefined, - ssl, - }; -}; - -export const getKyselyConfig = ( - params: DatabaseConnectionParams, - options: Partial>> = {}, -): KyselyConfig => { - const config = asPostgresConnectionConfig(params); +import { VectorExtension } from 'src/types'; +export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => { return { dialect: new PostgresJSDialect({ - postgres: postgres({ - onnotice: (notice: Notice) => { + postgres: createPostgres({ + connection, + onNotice: (notice: Notice) => { if (notice['severity'] !== 'NOTICE') { console.warn('Postgres notice:', notice); } }, - max: 10, - types: { - date: { - to: 1184, - from: [1082, 1114, 1184], - serialize: (x: Date | string) => (x instanceof Date ? x.toISOString() : x), - parse: (x: string) => new Date(x), - }, - bigint: { - to: 20, - from: [20, 1700], - parse: (value: string) => Number.parseInt(value), - serialize: (value: number) => value.toString(), - }, - }, - connection: { - TimeZone: 'UTC', - }, - host: config.host, - port: config.port, - username: config.username, - password: config.password, - database: config.database, - ssl: config.ssl, - ...options, }), }), log(event) { diff --git a/server/test/sql-tools/check-constraint-default-name.stub.ts b/server/test/sql-tools/check-constraint-default-name.stub.ts deleted file mode 100644 index 1cb7c0644a..0000000000 --- a/server/test/sql-tools/check-constraint-default-name.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Check, Column, ConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -@Check({ expression: '1=1' }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should create a check constraint with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.CHECK, - name: 'CHK_8d2ecfd49b984941f6b2589799', - tableName: 'table1', - expression: '1=1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/check-constraint-override-name.stub.ts b/server/test/sql-tools/check-constraint-override-name.stub.ts deleted file mode 100644 index 3752dcfb22..0000000000 --- a/server/test/sql-tools/check-constraint-override-name.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Check, Column, ConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -@Check({ name: 'CHK_test', expression: '1=1' }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should create a check constraint with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.CHECK, - name: 'CHK_test', - tableName: 'table1', - expression: '1=1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-create-date.stub.ts b/server/test/sql-tools/column-create-date.stub.ts deleted file mode 100644 index db5add2a12..0000000000 --- a/server/test/sql-tools/column-create-date.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { CreateDateColumn, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @CreateDateColumn() - createdAt!: string; -} - -export const description = 'should register a table with an created at date column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'createdAt', - tableName: 'table1', - type: 'timestamp with time zone', - default: 'now()', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-array.stub.ts b/server/test/sql-tools/column-default-array.stub.ts deleted file mode 100644 index b5e9b7d04a..0000000000 --- a/server/test/sql-tools/column-default-array.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'character varying', array: true, default: [] }) - column1!: string[]; -} - -export const description = 'should register a table with a column with a default value (array)'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: true, - primary: false, - synchronize: true, - default: "'{}'", - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-boolean.stub.ts b/server/test/sql-tools/column-default-boolean.stub.ts deleted file mode 100644 index 6454333599..0000000000 --- a/server/test/sql-tools/column-default-boolean.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'boolean', default: true }) - column1!: boolean; -} - -export const description = 'should register a table with a column with a default value (boolean)'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'boolean', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - default: 'true', - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-date.stub.ts b/server/test/sql-tools/column-default-date.stub.ts deleted file mode 100644 index 70f4d520f9..0000000000 --- a/server/test/sql-tools/column-default-date.stub.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -const date = new Date(2023, 0, 1); - -@Table() -export class Table1 { - @Column({ type: 'character varying', default: date }) - column1!: string; -} - -export const description = 'should register a table with a column with a default value (date)'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - default: "'2023-01-01T00:00:00.000Z'", - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-function.stub.ts b/server/test/sql-tools/column-default-function.stub.ts deleted file mode 100644 index 1066a9af21..0000000000 --- a/server/test/sql-tools/column-default-function.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'character varying', default: () => 'now()' }) - column1!: string; -} - -export const description = 'should register a table with a column with a default function'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - default: 'now()', - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-null.stub.ts b/server/test/sql-tools/column-default-null.stub.ts deleted file mode 100644 index b517ca5a96..0000000000 --- a/server/test/sql-tools/column-default-null.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'character varying', default: null }) - column1!: string; -} - -export const description = 'should register a nullable column from a default of null'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-number.stub.ts b/server/test/sql-tools/column-default-number.stub.ts deleted file mode 100644 index 7954f2498b..0000000000 --- a/server/test/sql-tools/column-default-number.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'integer', default: 0 }) - column1!: string; -} - -export const description = 'should register a table with a column with a default value (number)'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'integer', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - default: '0', - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-default-string.stub.ts b/server/test/sql-tools/column-default-string.stub.ts deleted file mode 100644 index 0d0a18a0eb..0000000000 --- a/server/test/sql-tools/column-default-string.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'character varying', default: 'foo' }) - column1!: string; -} - -export const description = 'should register a table with a column with a default value (string)'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - default: "'foo'", - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-delete-date.stub.ts b/server/test/sql-tools/column-delete-date.stub.ts deleted file mode 100644 index de494ad16e..0000000000 --- a/server/test/sql-tools/column-delete-date.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { DatabaseSchema, DeleteDateColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @DeleteDateColumn() - deletedAt!: string; -} - -export const description = 'should register a table with a deleted at date column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'deletedAt', - tableName: 'table1', - type: 'timestamp with time zone', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-enum-type.stub.ts b/server/test/sql-tools/column-enum-type.stub.ts deleted file mode 100644 index 563835d720..0000000000 --- a/server/test/sql-tools/column-enum-type.stub.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Column, DatabaseSchema, registerEnum, Table } from 'src/sql-tools'; - -enum Test { - Foo = 'foo', - Bar = 'bar', -} - -const test_enum = registerEnum({ name: 'test_enum', values: Object.values(Test) }); - -@Table() -export class Table1 { - @Column({ enum: test_enum }) - column1!: string; -} - -export const description = 'should accept an enum type'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [ - { - name: 'test_enum', - values: ['foo', 'bar'], - synchronize: true, - }, - ], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'enum', - enumName: 'test_enum', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-generated-identity.ts b/server/test/sql-tools/column-generated-identity.ts deleted file mode 100644 index 29f7ba969a..0000000000 --- a/server/test/sql-tools/column-generated-identity.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ConstraintType, DatabaseSchema, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryGeneratedColumn({ strategy: 'identity' }) - column1!: string; -} - -export const description = 'should register a table with a generated identity column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'integer', - identity: true, - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_50c4f9905061b1e506d38a2a380', - tableName: 'table1', - columnNames: ['column1'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-generated-uuid.stub.ts b/server/test/sql-tools/column-generated-uuid.stub.ts deleted file mode 100644 index 0d4d78a84f..0000000000 --- a/server/test/sql-tools/column-generated-uuid.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ConstraintType, DatabaseSchema, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryGeneratedColumn({ strategy: 'uuid' }) - column1!: string; -} - -export const description = 'should register a table with a primary generated uuid column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'uuid', - default: 'uuid_generate_v4()', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_50c4f9905061b1e506d38a2a380', - tableName: 'table1', - columnNames: ['column1'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-index-name-default.ts b/server/test/sql-tools/column-index-name-default.ts deleted file mode 100644 index ea1fb17fb4..0000000000 --- a/server/test/sql-tools/column-index-name-default.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ index: true }) - column1!: string; -} - -export const description = 'should create a column with an index'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_50c4f9905061b1e506d38a2a38', - columnNames: ['column1'], - tableName: 'table1', - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-index-name.ts b/server/test/sql-tools/column-index-name.ts deleted file mode 100644 index 2a37469600..0000000000 --- a/server/test/sql-tools/column-index-name.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ indexName: 'IDX_test' }) - column1!: string; -} - -export const description = 'should create a column with an index if a name is provided'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_test', - columnNames: ['column1'], - tableName: 'table1', - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-inferred-nullable.stub.ts b/server/test/sql-tools/column-inferred-nullable.stub.ts deleted file mode 100644 index 50810291d3..0000000000 --- a/server/test/sql-tools/column-inferred-nullable.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ default: null }) - column1!: string; -} - -export const description = 'should infer nullable from the default value'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-name-default.stub.ts b/server/test/sql-tools/column-name-default.stub.ts deleted file mode 100644 index 57e15fc8b6..0000000000 --- a/server/test/sql-tools/column-name-default.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column() - column1!: string; -} - -export const description = 'should register a table with a column with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-name-override.stub.ts b/server/test/sql-tools/column-name-override.stub.ts deleted file mode 100644 index 8741162735..0000000000 --- a/server/test/sql-tools/column-name-override.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ name: 'column-1' }) - column1!: string; -} - -export const description = 'should register a table with a column with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column-1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-name-string.stub.ts b/server/test/sql-tools/column-name-string.stub.ts deleted file mode 100644 index e4a60f51b9..0000000000 --- a/server/test/sql-tools/column-name-string.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column('column-1') - column1!: string; -} - -export const description = 'should register a table with a column with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column-1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-nullable.stub.ts b/server/test/sql-tools/column-nullable.stub.ts deleted file mode 100644 index 31c72fe97c..0000000000 --- a/server/test/sql-tools/column-nullable.stub.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ nullable: true }) - column1!: string; -} - -export const description = 'should set nullable correctly'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-string-length.stub.ts b/server/test/sql-tools/column-string-length.stub.ts deleted file mode 100644 index a04cfbd117..0000000000 --- a/server/test/sql-tools/column-string-length.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Column, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ length: 2 }) - column1!: string; -} - -export const description = 'should use create a string column with a fixed length'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - length: 2, - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-unique-constraint-name-default.stub.ts b/server/test/sql-tools/column-unique-constraint-name-default.stub.ts deleted file mode 100644 index 076a93bf57..0000000000 --- a/server/test/sql-tools/column-unique-constraint-name-default.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'uuid', unique: true }) - id!: string; -} - -export const description = 'should create a unique key constraint with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.UNIQUE, - name: 'UQ_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-unique-constraint-name-override.stub.ts b/server/test/sql-tools/column-unique-constraint-name-override.stub.ts deleted file mode 100644 index d4c3d5bb6a..0000000000 --- a/server/test/sql-tools/column-unique-constraint-name-override.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column({ type: 'uuid', unique: true, uniqueConstraintName: 'UQ_test' }) - id!: string; -} - -export const description = 'should create a unique key constraint with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.UNIQUE, - name: 'UQ_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/column-update-date.stub.ts b/server/test/sql-tools/column-update-date.stub.ts deleted file mode 100644 index dfa09888c0..0000000000 --- a/server/test/sql-tools/column-update-date.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { DatabaseSchema, Table, UpdateDateColumn } from 'src/sql-tools'; - -@Table() -export class Table1 { - @UpdateDateColumn() - updatedAt!: string; -} - -export const description = 'should register a table with an updated at date column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'updatedAt', - tableName: 'table1', - type: 'timestamp with time zone', - default: 'now()', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/errors/table-duplicate-decorator.stub.ts b/server/test/sql-tools/errors/table-duplicate-decorator.stub.ts deleted file mode 100644 index 3b7a8781b9..0000000000 --- a/server/test/sql-tools/errors/table-duplicate-decorator.stub.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Table } from 'src/sql-tools'; - -@Table({ name: 'table-1' }) -@Table({ name: 'table-2' }) -export class Table1 {} - -export const message = 'Table table-2 has already been registered'; diff --git a/server/test/sql-tools/foreign-key-constraint-column-order.stub.ts b/server/test/sql-tools/foreign-key-constraint-column-order.stub.ts deleted file mode 100644 index 2523701e49..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-column-order.stub.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id1!: string; - - @PrimaryColumn({ type: 'uuid' }) - id2!: string; -} - -@Table() -@ForeignKeyConstraint({ - columns: ['parentId1', 'parentId2'], - referenceTable: () => Table1, - referenceColumns: ['id2', 'id1'], -}) -export class Table2 { - @Column({ type: 'uuid' }) - parentId1!: string; - - @Column({ type: 'uuid' }) - parentId2!: string; -} - -export const description = 'should create a foreign key constraint to the target table'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id1', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - { - name: 'id2', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_e457e8b1301b7bc06ef78188ee4', - tableName: 'table1', - columnNames: ['id1', 'id2'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId1', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - { - name: 'parentId2', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_aed36d04470eba20161aa8b1dc', - tableName: 'table2', - columnNames: ['parentId1', 'parentId2'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_aed36d04470eba20161aa8b1dc6', - tableName: 'table2', - columnNames: ['parentId1', 'parentId2'], - referenceColumnNames: ['id2', 'id1'], - referenceTableName: 'table1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts b/server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts deleted file mode 100644 index dcd957676a..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-missing-column.stub.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -@ForeignKeyConstraint({ columns: ['parentId2'], referenceTable: () => Table1 }) -export class Table2 { - @Column({ type: 'uuid' }) - parentId!: string; -} - -export const description = 'should warn against missing column in foreign key constraint'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: ['[@ForeignKeyConstraint.columns] Unable to find column (Table2.parentId2)'], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts b/server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts deleted file mode 100644 index 238f4174f3..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-missing-reference-column.stub.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1, referenceColumns: ['foo'] }) -export class Table2 { - @Column({ type: 'uuid' }) - parentId!: string; -} - -export const description = 'should warn against missing reference column in foreign key constraint'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: ['[@ForeignKeyConstraint.referenceColumns] Unable to find column (Table1.foo)'], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts b/server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts deleted file mode 100644 index c6d6fd5b09..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-missing-reference-table.stub.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Column, DatabaseSchema, ForeignKeyConstraint, Table } from 'src/sql-tools'; - -class Foo {} - -@Table() -@ForeignKeyConstraint({ - columns: ['parentId'], - referenceTable: () => Foo, -}) -export class Table1 { - @Column() - parentId!: string; -} - -export const description = 'should warn against missing reference table'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'parentId', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: ['[@ForeignKeyConstraint.referenceTable] Unable to find table (Foo)'], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts b/server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts deleted file mode 100644 index a86611bb50..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-multiple-columns.stub.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id1!: string; - - @PrimaryColumn({ type: 'uuid' }) - id2!: string; -} - -@Table() -@ForeignKeyConstraint({ columns: ['parentId1', 'parentId2'], referenceTable: () => Table1 }) -export class Table2 { - @Column({ type: 'uuid' }) - parentId1!: string; - - @Column({ type: 'uuid' }) - parentId2!: string; -} - -export const description = 'should create a foreign key constraint to the target table'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id1', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - { - name: 'id2', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_e457e8b1301b7bc06ef78188ee4', - tableName: 'table1', - columnNames: ['id1', 'id2'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId1', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - { - name: 'parentId2', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_aed36d04470eba20161aa8b1dc', - tableName: 'table2', - columnNames: ['parentId1', 'parentId2'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_aed36d04470eba20161aa8b1dc6', - tableName: 'table2', - columnNames: ['parentId1', 'parentId2'], - referenceColumnNames: ['id1', 'id2'], - referenceTableName: 'table1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-no-index.stub.ts b/server/test/sql-tools/foreign-key-constraint-no-index.stub.ts deleted file mode 100644 index 8bb436c9ac..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-no-index.stub.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1, index: false }) -export class Table2 { - @Column({ type: 'uuid' }) - parentId!: string; -} - -export const description = 'should create a foreign key constraint to the target table without an index'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_3fcca5cc563abf256fc346e3ff4', - tableName: 'table2', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts b/server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts deleted file mode 100644 index 6680b13b91..0000000000 --- a/server/test/sql-tools/foreign-key-constraint-no-primary.stub.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @Column() - foo!: string; -} - -@Table() -@ForeignKeyConstraint({ - columns: ['bar'], - referenceTable: () => Table1, - referenceColumns: ['foo'], -}) -export class Table2 { - @Column() - bar!: string; -} - -export const description = 'should create a foreign key constraint to the target table without a primary key'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'foo', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'bar', - tableName: 'table2', - type: 'character varying', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_7d9c784c98d12365d198d52e4e', - tableName: 'table2', - columnNames: ['bar'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_7d9c784c98d12365d198d52e4e6', - tableName: 'table2', - columnNames: ['bar'], - referenceTableName: 'table1', - referenceColumnNames: ['foo'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-constraint.stub.ts b/server/test/sql-tools/foreign-key-constraint.stub.ts deleted file mode 100644 index 518c5aa6bb..0000000000 --- a/server/test/sql-tools/foreign-key-constraint.stub.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, ForeignKeyConstraint, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1 }) -export class Table2 { - @Column({ type: 'uuid' }) - parentId!: string; -} - -export const description = 'should create a foreign key constraint to the target table'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_3fcca5cc563abf256fc346e3ff', - tableName: 'table2', - columnNames: ['parentId'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_3fcca5cc563abf256fc346e3ff4', - tableName: 'table2', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-inferred-type.stub.ts b/server/test/sql-tools/foreign-key-inferred-type.stub.ts deleted file mode 100644 index 33f1c2dfde..0000000000 --- a/server/test/sql-tools/foreign-key-inferred-type.stub.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { ConstraintType, DatabaseSchema, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -export class Table2 { - @ForeignKeyColumn(() => Table1, {}) - parentId!: string; -} - -export const description = 'should infer the column type from the reference column'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_3fcca5cc563abf256fc346e3ff', - tableName: 'table2', - columnNames: ['parentId'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_3fcca5cc563abf256fc346e3ff4', - tableName: 'table2', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table1', - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts b/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts deleted file mode 100644 index 288f7c6698..0000000000 --- a/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ConstraintType, DatabaseSchema, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -@Table() -export class Table2 { - @ForeignKeyColumn(() => Table1, { unique: true }) - parentId!: string; -} - -export const description = 'should create a foreign key constraint with a unique constraint'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - { - name: 'table2', - columns: [ - { - name: 'parentId', - tableName: 'table2', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_3fcca5cc563abf256fc346e3ff', - tableName: 'table2', - columnNames: ['parentId'], - unique: false, - synchronize: true, - }, - ], - triggers: [], - constraints: [ - { - type: ConstraintType.FOREIGN_KEY, - name: 'FK_3fcca5cc563abf256fc346e3ff4', - tableName: 'table2', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table1', - synchronize: true, - }, - { - type: ConstraintType.UNIQUE, - name: 'UQ_3fcca5cc563abf256fc346e3ff4', - tableName: 'table2', - columnNames: ['parentId'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/index-name-default.stub.ts b/server/test/sql-tools/index-name-default.stub.ts deleted file mode 100644 index 1918106eaa..0000000000 --- a/server/test/sql-tools/index-name-default.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; - -@Table() -@Index({ columns: ['id'] }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should create an index with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_b249cc64cf63b8a22557cdc853', - tableName: 'table1', - unique: false, - columnNames: ['id'], - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/index-name-override.stub.ts b/server/test/sql-tools/index-name-override.stub.ts deleted file mode 100644 index a48dc6e6d6..0000000000 --- a/server/test/sql-tools/index-name-override.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; - -@Table() -@Index({ name: 'IDX_test', columns: ['id'] }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should create an index with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_test', - tableName: 'table1', - unique: false, - columnNames: ['id'], - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/index-with-expression.ts b/server/test/sql-tools/index-with-expression.ts deleted file mode 100644 index 07755b7f96..0000000000 --- a/server/test/sql-tools/index-with-expression.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; - -@Table() -@Index({ expression: '"id" IS NOT NULL' }) -export class Table1 { - @Column({ nullable: true }) - column1!: string; -} - -export const description = 'should create an index based off of an expression'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_376788d186160c4faa5aaaef63', - tableName: 'table1', - unique: false, - expression: '"id" IS NOT NULL', - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/index-with-where.stub.ts b/server/test/sql-tools/index-with-where.stub.ts deleted file mode 100644 index 86a4a3089d..0000000000 --- a/server/test/sql-tools/index-with-where.stub.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools'; - -@Table() -@Index({ columns: ['id'], where: '"id" IS NOT NULL' }) -export class Table1 { - @Column({ nullable: true }) - column1!: string; -} - -export const description = 'should create an index with a where clause'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [ - { - name: 'IDX_9f4e073964c0395f51f9b39900', - tableName: 'table1', - unique: false, - columnNames: ['id'], - where: '"id" IS NOT NULL', - synchronize: true, - }, - ], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/primary-key-constraint-name-default.stub.ts b/server/test/sql-tools/primary-key-constraint-name-default.stub.ts deleted file mode 100644 index 7edfd6ff36..0000000000 --- a/server/test/sql-tools/primary-key-constraint-name-default.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ConstraintType, DatabaseSchema, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table() -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -export const description = 'should add a primary key constraint to the table with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/primary-key-constraint-name-override.stub.ts b/server/test/sql-tools/primary-key-constraint-name-override.stub.ts deleted file mode 100644 index ce1f2a096c..0000000000 --- a/server/test/sql-tools/primary-key-constraint-name-override.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ConstraintType, DatabaseSchema, PrimaryColumn, Table } from 'src/sql-tools'; - -@Table({ primaryConstraintName: 'PK_test' }) -export class Table1 { - @PrimaryColumn({ type: 'uuid' }) - id!: string; -} - -export const description = 'should add a primary key constraint to the table with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: true, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.PRIMARY_KEY, - name: 'PK_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/table-name-default.stub.ts b/server/test/sql-tools/table-name-default.stub.ts deleted file mode 100644 index 4384944364..0000000000 --- a/server/test/sql-tools/table-name-default.stub.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DatabaseSchema, Table } from 'src/sql-tools'; - -@Table() -export class Table1 {} - -export const description = 'should register a table with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/table-name-override.stub.ts b/server/test/sql-tools/table-name-override.stub.ts deleted file mode 100644 index 5bccc429d0..0000000000 --- a/server/test/sql-tools/table-name-override.stub.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DatabaseSchema, Table } from 'src/sql-tools'; - -@Table({ name: 'table-1' }) -export class Table1 {} - -export const description = 'should register a table with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table-1', - columns: [], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/table-name-string-option.stub.ts b/server/test/sql-tools/table-name-string-option.stub.ts deleted file mode 100644 index f394699172..0000000000 --- a/server/test/sql-tools/table-name-string-option.stub.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DatabaseSchema, Table } from 'src/sql-tools'; - -@Table('table-1') -export class Table1 {} - -export const description = 'should register a table with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table-1', - columns: [], - indexes: [], - triggers: [], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/trigger-after-delete.stub.ts b/server/test/sql-tools/trigger-after-delete.stub.ts deleted file mode 100644 index dcceaf25ce..0000000000 --- a/server/test/sql-tools/trigger-after-delete.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { AfterDeleteTrigger, DatabaseSchema, registerFunction, Table } from 'src/sql-tools'; - -const test_fn = registerFunction({ - name: 'test_fn', - body: 'SELECT 1;', - returnType: 'character varying', -}); - -@Table() -@AfterDeleteTrigger({ - name: 'my_trigger', - function: test_fn, - scope: 'row', -}) -export class Table1 {} - -export const description = 'should create a trigger'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [expect.any(Object)], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [], - indexes: [], - triggers: [ - { - name: 'my_trigger', - functionName: 'test_fn', - tableName: 'table1', - timing: 'after', - scope: 'row', - actions: ['delete'], - synchronize: true, - }, - ], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/trigger-before-update.stub.ts b/server/test/sql-tools/trigger-before-update.stub.ts deleted file mode 100644 index 6bf6afc721..0000000000 --- a/server/test/sql-tools/trigger-before-update.stub.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { BeforeUpdateTrigger, DatabaseSchema, registerFunction, Table } from 'src/sql-tools'; - -const test_fn = registerFunction({ - name: 'test_fn', - body: 'SELECT 1;', - returnType: 'character varying', -}); - -@Table() -@BeforeUpdateTrigger({ - name: 'my_trigger', - function: test_fn, - scope: 'row', -}) -export class Table1 {} - -export const description = 'should create a trigger '; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [expect.any(Object)], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [], - indexes: [], - triggers: [ - { - name: 'my_trigger', - functionName: 'test_fn', - tableName: 'table1', - timing: 'before', - scope: 'row', - actions: ['update'], - synchronize: true, - }, - ], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/trigger-name-default.stub.ts b/server/test/sql-tools/trigger-name-default.stub.ts deleted file mode 100644 index 382389bcf7..0000000000 --- a/server/test/sql-tools/trigger-name-default.stub.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { DatabaseSchema, Table, Trigger } from 'src/sql-tools'; - -@Table() -@Trigger({ - timing: 'before', - actions: ['insert'], - scope: 'row', - functionName: 'function1', -}) -export class Table1 {} - -export const description = 'should register a trigger with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [], - indexes: [], - triggers: [ - { - name: 'TR_ca71832b10b77ed600ef05df631', - tableName: 'table1', - functionName: 'function1', - actions: ['insert'], - scope: 'row', - timing: 'before', - synchronize: true, - }, - ], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/trigger-name-override.stub.ts b/server/test/sql-tools/trigger-name-override.stub.ts deleted file mode 100644 index 33c4da6b67..0000000000 --- a/server/test/sql-tools/trigger-name-override.stub.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { DatabaseSchema, Table, Trigger } from 'src/sql-tools'; - -@Table() -@Trigger({ - name: 'trigger1', - timing: 'before', - actions: ['insert'], - scope: 'row', - functionName: 'function1', -}) -export class Table1 {} - -export const description = 'should a trigger with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [], - indexes: [], - triggers: [ - { - name: 'trigger1', - tableName: 'table1', - functionName: 'function1', - actions: ['insert'], - scope: 'row', - timing: 'before', - synchronize: true, - }, - ], - constraints: [], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/unique-constraint-name-default.stub.ts b/server/test/sql-tools/unique-constraint-name-default.stub.ts deleted file mode 100644 index 90fbe09224..0000000000 --- a/server/test/sql-tools/unique-constraint-name-default.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, Table, Unique } from 'src/sql-tools'; - -@Table() -@Unique({ columns: ['id'] }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should add a unique constraint to the table with a default name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.UNIQUE, - name: 'UQ_b249cc64cf63b8a22557cdc8537', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/sql-tools/unique-constraint-name-override.stub.ts b/server/test/sql-tools/unique-constraint-name-override.stub.ts deleted file mode 100644 index 3da7584c0c..0000000000 --- a/server/test/sql-tools/unique-constraint-name-override.stub.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, ConstraintType, DatabaseSchema, Table, Unique } from 'src/sql-tools'; - -@Table() -@Unique({ name: 'UQ_test', columns: ['id'] }) -export class Table1 { - @Column({ type: 'uuid' }) - id!: string; -} - -export const description = 'should add a unique constraint to the table with a specific name'; -export const schema: DatabaseSchema = { - databaseName: 'postgres', - schemaName: 'public', - functions: [], - enums: [], - extensions: [], - parameters: [], - overrides: [], - tables: [ - { - name: 'table1', - columns: [ - { - name: 'id', - tableName: 'table1', - type: 'uuid', - nullable: false, - isArray: false, - primary: false, - synchronize: true, - }, - ], - indexes: [], - triggers: [], - constraints: [ - { - type: ConstraintType.UNIQUE, - name: 'UQ_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - ], - synchronize: true, - }, - ], - warnings: [], -}; diff --git a/server/test/utils.ts b/server/test/utils.ts index c2a83c52ae..b3e47b2b7e 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -1,3 +1,4 @@ +import { createPostgres, DatabaseConnectionParams } from '@immich/sql-tools'; import { CallHandler, ExecutionContext, Provider, ValidationPipe } from '@nestjs/common'; import { APP_GUARD, APP_PIPE } from '@nestjs/core'; import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils'; @@ -9,7 +10,6 @@ import multer from 'multer'; import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { Duplex, Readable, Writable } from 'node:stream'; import { PNG } from 'pngjs'; -import postgres from 'postgres'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { AuthGuard } from 'src/middleware/auth.guard'; @@ -70,7 +70,7 @@ import { DB } from 'src/schema'; import { AuthService } from 'src/services/auth.service'; import { BaseService } from 'src/services/base.service'; import { RepositoryInterface } from 'src/types'; -import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database'; +import { getKyselyConfig } from 'src/utils/database'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; @@ -445,13 +445,8 @@ const withDatabase = (url: string, name: string) => url.replace(`/${templateName export const getKyselyDB = async (suffix?: string): Promise> => { const testUrl = process.env.IMMICH_TEST_POSTGRES_URL!; - const sql = postgres({ - ...asPostgresConnectionConfig({ - connectionType: 'url', - url: withDatabase(testUrl, 'postgres'), - }), - max: 1, - }); + const connection = { connectionType: 'url', url: withDatabase(testUrl, 'postgres') } as DatabaseConnectionParams; + const sql = createPostgres({ maxConnections: 1, connection }); const randomSuffix = Math.random().toString(36).slice(2, 7); const dbName = `immich_${suffix ?? randomSuffix}`; diff --git a/server/tsconfig.json b/server/tsconfig.json index e12b614f0d..fcb0ea2a97 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "node16", + "module": "node20", "strict": true, "declaration": true, "removeComments": true, From e633bc3f247953eae40a6c04d5af5c22efd0c24b Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Mon, 23 Feb 2026 08:50:54 -0600 Subject: [PATCH 111/143] fix: missing deletedAt and isVisible columns on mobile (#26414) * feat: SyncAssetV2 * feat: mobile sync handling * feat: request correct sync object based on server version * fix: mobile queries * chore: sync sql * fix: test * chore: switch to mapper * fix: sql sync --- .../drift_schemas/main/drift_schema_v20.json | 1 + .../domain/services/sync_stream.service.dart | 14 +- .../entities/asset_face.entity.dart | 4 + .../entities/asset_face.entity.drift.dart | 270 +- .../repositories/db.repository.dart | 6 +- .../repositories/db.repository.steps.dart | 552 ++ .../repositories/people.repository.dart | 16 +- .../repositories/sync_api.repository.dart | 6 +- .../repositories/sync_stream.repository.dart | 31 + .../repositories/timeline.repository.dart | 12 +- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/sync_asset_face_v2.dart | 201 + .../openapi/lib/model/sync_entity_type.dart | 3 + .../openapi/lib/model/sync_request_type.dart | 3 + .../services/sync_stream_service_test.dart | 20 +- mobile/test/drift/main/generated/schema.dart | 4 + .../test/drift/main/generated/schema_v20.dart | 8471 +++++++++++++++++ .../sync_api_repository_test.dart | 19 +- open-api/immich-openapi-specs.json | 66 + open-api/typescript-sdk/src/fetch-client.ts | 22 + server/src/dtos/sync.dto.ts | 15 + server/src/enum.ts | 2 + server/src/queries/sync.repository.sql | 2 + server/src/repositories/sync.repository.ts | 2 + server/src/services/sync.service.ts | 18 + .../medium/specs/sync/sync-asset-face.spec.ts | 131 + 28 files changed, 9803 insertions(+), 92 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v20.json create mode 100644 mobile/openapi/lib/model/sync_asset_face_v2.dart create mode 100644 mobile/test/drift/main/generated/schema_v20.dart diff --git a/mobile/drift_schemas/main/drift_schema_v20.json b/mobile/drift_schemas/main/drift_schema_v20.json new file mode 100644 index 0000000000..f85af83439 --- /dev/null +++ b/mobile/drift_schemas/main/drift_schema_v20.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":[]},{"name":"is_edited","getter_name":"isEdited","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_edited\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_edited\" 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":["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":[]},{"name":"i_cloud_id","getter_name":"iCloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","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":[]}],"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":[6],"type":"index","data":{"on":6,"name":"idx_local_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":8,"references":[4],"type":"index","data":{"on":4,"name":"idx_remote_album_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)","unique":false,"columns":[]}},{"id":9,"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":10,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)","unique":false,"columns":[]}},{"id":11,"references":[2],"type":"index","data":{"on":2,"name":"idx_stack_primary_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)","unique":false,"columns":[]}},{"id":12,"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":13,"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":14,"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":15,"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":16,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_stack_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)","unique":false,"columns":[]}},{"id":17,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_day","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))","unique":false,"columns":[]}},{"id":18,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_month","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))","unique":false,"columns":[]}},{"id":19,"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":20,"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":21,"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":22,"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":23,"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":24,"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":25,"references":[1],"type":"table","data":{"name":"remote_asset_cloud_id_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":"cloud_id","getter_name":"cloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","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":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":26,"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":27,"references":[1,26],"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":28,"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":29,"references":[1,28],"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":[]},{"name":"is_visible","getter_name":"isVisible","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_visible\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_visible\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","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":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":30,"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":31,"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":[]},{"name":"source","getter_name":"source","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TrashOrigin.values)","dart_type_name":"TrashOrigin"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id","album_id"]}},{"id":32,"references":[21],"type":"index","data":{"on":21,"name":"idx_partner_shared_with_id","sql":"CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)","unique":false,"columns":[]}},{"id":33,"references":[22],"type":"index","data":{"on":22,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}},{"id":34,"references":[23],"type":"index","data":{"on":23,"name":"idx_remote_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":35,"references":[25],"type":"index","data":{"on":25,"name":"idx_remote_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)","unique":false,"columns":[]}},{"id":36,"references":[28],"type":"index","data":{"on":28,"name":"idx_person_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)","unique":false,"columns":[]}},{"id":37,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_person_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)","unique":false,"columns":[]}},{"id":38,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)","unique":false,"columns":[]}},{"id":39,"references":[31],"type":"index","data":{"on":31,"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":40,"references":[31],"type":"index","data":{"on":31,"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/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index af1c94ca71..2bda6cd683 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -68,12 +68,12 @@ class SyncStreamService { return false; } - final semVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_); + final serverSemVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_); final value = Store.get(StoreKey.syncMigrationStatus, "[]"); final migrations = (jsonDecode(value) as List).cast(); int previousLength = migrations.length; - await _runPreSyncTasks(migrations, semVer); + await _runPreSyncTasks(migrations, serverSemVer); if (migrations.length != previousLength) { _logger.info("Updated pre-sync migration status: $migrations"); @@ -82,10 +82,14 @@ class SyncStreamService { // Start the sync stream and handle events bool shouldReset = false; - await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true); + await _syncApiRepository.streamChanges( + _handleEvents, + serverVersion: serverSemVer, + onReset: () => shouldReset = true, + ); if (shouldReset) { _logger.info("Resetting sync state as requested by server"); - await _syncApiRepository.streamChanges(_handleEvents); + await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer); } previousLength = migrations.length; @@ -282,6 +286,8 @@ class SyncStreamService { return _syncStreamRepository.deletePeopleV1(data.cast()); case SyncEntityType.assetFaceV1: return _syncStreamRepository.updateAssetFacesV1(data.cast()); + case SyncEntityType.assetFaceV2: + return _syncStreamRepository.updateAssetFacesV2(data.cast()); case SyncEntityType.assetFaceDeleteV1: return _syncStreamRepository.deleteAssetFacesV1(data.cast()); default: diff --git a/mobile/lib/infrastructure/entities/asset_face.entity.dart b/mobile/lib/infrastructure/entities/asset_face.entity.dart index 45a0b436bd..40fe9ab1c1 100644 --- a/mobile/lib/infrastructure/entities/asset_face.entity.dart +++ b/mobile/lib/infrastructure/entities/asset_face.entity.dart @@ -28,6 +28,10 @@ class AssetFaceEntity extends Table with DriftDefaultsMixin { TextColumn get sourceType => text()(); + BoolColumn get isVisible => boolean().withDefault(const Constant(true))(); + + DateTimeColumn get deletedAt => dateTime().nullable()(); + @override Set get primaryKey => {id}; } diff --git a/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart b/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart index 7f2f3825e3..c97dd545a8 100644 --- a/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart @@ -5,11 +5,12 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da as i1; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart' as i2; +import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' - as i3; -import 'package:drift/internal/modular.dart' as i4; + as i4; +import 'package:drift/internal/modular.dart' as i5; import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart' - as i5; + as i6; typedef $$AssetFaceEntityTableCreateCompanionBuilder = i1.AssetFaceEntityCompanion Function({ @@ -23,6 +24,8 @@ typedef $$AssetFaceEntityTableCreateCompanionBuilder = required int boundingBoxX2, required int boundingBoxY2, required String sourceType, + i0.Value isVisible, + i0.Value deletedAt, }); typedef $$AssetFaceEntityTableUpdateCompanionBuilder = i1.AssetFaceEntityCompanion Function({ @@ -36,6 +39,8 @@ typedef $$AssetFaceEntityTableUpdateCompanionBuilder = i0.Value boundingBoxX2, i0.Value boundingBoxY2, i0.Value sourceType, + i0.Value isVisible, + i0.Value deletedAt, }); final class $$AssetFaceEntityTableReferences @@ -51,29 +56,29 @@ final class $$AssetFaceEntityTableReferences super.$_typedResult, ); - static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => - i4.ReadDatabaseContainer(db) - .resultSet('remote_asset_entity') + static i4.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => + i5.ReadDatabaseContainer(db) + .resultSet('remote_asset_entity') .createAlias( i0.$_aliasNameGenerator( - i4.ReadDatabaseContainer(db) + i5.ReadDatabaseContainer(db) .resultSet('asset_face_entity') .assetId, - i4.ReadDatabaseContainer( + i5.ReadDatabaseContainer( db, - ).resultSet('remote_asset_entity').id, + ).resultSet('remote_asset_entity').id, ), ); - i3.$$RemoteAssetEntityTableProcessedTableManager get assetId { + i4.$$RemoteAssetEntityTableProcessedTableManager get assetId { final $_column = $_itemColumn('asset_id')!; - final manager = i3 + final manager = i4 .$$RemoteAssetEntityTableTableManager( $_db, - i4.ReadDatabaseContainer( + i5.ReadDatabaseContainer( $_db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), ) .filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_assetIdTable($_db)); @@ -83,29 +88,29 @@ final class $$AssetFaceEntityTableReferences ); } - static i5.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) => - i4.ReadDatabaseContainer(db) - .resultSet('person_entity') + static i6.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) => + i5.ReadDatabaseContainer(db) + .resultSet('person_entity') .createAlias( i0.$_aliasNameGenerator( - i4.ReadDatabaseContainer(db) + i5.ReadDatabaseContainer(db) .resultSet('asset_face_entity') .personId, - i4.ReadDatabaseContainer( + i5.ReadDatabaseContainer( db, - ).resultSet('person_entity').id, + ).resultSet('person_entity').id, ), ); - i5.$$PersonEntityTableProcessedTableManager? get personId { + i6.$$PersonEntityTableProcessedTableManager? get personId { final $_column = $_itemColumn('person_id'); if ($_column == null) return null; - final manager = i5 + final manager = i6 .$$PersonEntityTableTableManager( $_db, - i4.ReadDatabaseContainer( + i5.ReadDatabaseContainer( $_db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), ) .filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_personIdTable($_db)); @@ -165,24 +170,34 @@ class $$AssetFaceEntityTableFilterComposer builder: (column) => i0.ColumnFilters(column), ); - i3.$$RemoteAssetEntityTableFilterComposer get assetId { - final i3.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( + i0.ColumnFilters get isVisible => $composableBuilder( + column: $table.isVisible, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => i0.ColumnFilters(column), + ); + + i4.$$RemoteAssetEntityTableFilterComposer get assetId { + final i4.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.assetId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i3.$$RemoteAssetEntityTableFilterComposer( + }) => i4.$$RemoteAssetEntityTableFilterComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -192,24 +207,24 @@ class $$AssetFaceEntityTableFilterComposer return composer; } - i5.$$PersonEntityTableFilterComposer get personId { - final i5.$$PersonEntityTableFilterComposer composer = $composerBuilder( + i6.$$PersonEntityTableFilterComposer get personId { + final i6.$$PersonEntityTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.personId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i5.$$PersonEntityTableFilterComposer( + }) => i6.$$PersonEntityTableFilterComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -269,25 +284,35 @@ class $$AssetFaceEntityTableOrderingComposer builder: (column) => i0.ColumnOrderings(column), ); - i3.$$RemoteAssetEntityTableOrderingComposer get assetId { - final i3.$$RemoteAssetEntityTableOrderingComposer composer = + i0.ColumnOrderings get isVisible => $composableBuilder( + column: $table.isVisible, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => i0.ColumnOrderings(column), + ); + + i4.$$RemoteAssetEntityTableOrderingComposer get assetId { + final i4.$$RemoteAssetEntityTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.assetId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i3.$$RemoteAssetEntityTableOrderingComposer( + }) => i4.$$RemoteAssetEntityTableOrderingComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -297,24 +322,24 @@ class $$AssetFaceEntityTableOrderingComposer return composer; } - i5.$$PersonEntityTableOrderingComposer get personId { - final i5.$$PersonEntityTableOrderingComposer composer = $composerBuilder( + i6.$$PersonEntityTableOrderingComposer get personId { + final i6.$$PersonEntityTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.personId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i5.$$PersonEntityTableOrderingComposer( + }) => i6.$$PersonEntityTableOrderingComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -372,25 +397,31 @@ class $$AssetFaceEntityTableAnnotationComposer builder: (column) => column, ); - i3.$$RemoteAssetEntityTableAnnotationComposer get assetId { - final i3.$$RemoteAssetEntityTableAnnotationComposer composer = + i0.GeneratedColumn get isVisible => + $composableBuilder(column: $table.isVisible, builder: (column) => column); + + i0.GeneratedColumn get deletedAt => + $composableBuilder(column: $table.deletedAt, builder: (column) => column); + + i4.$$RemoteAssetEntityTableAnnotationComposer get assetId { + final i4.$$RemoteAssetEntityTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.assetId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i3.$$RemoteAssetEntityTableAnnotationComposer( + }) => i4.$$RemoteAssetEntityTableAnnotationComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('remote_asset_entity'), + ).resultSet('remote_asset_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -400,24 +431,24 @@ class $$AssetFaceEntityTableAnnotationComposer return composer; } - i5.$$PersonEntityTableAnnotationComposer get personId { - final i5.$$PersonEntityTableAnnotationComposer composer = $composerBuilder( + i6.$$PersonEntityTableAnnotationComposer get personId { + final i6.$$PersonEntityTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.personId, - referencedTable: i4.ReadDatabaseContainer( + referencedTable: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => i5.$$PersonEntityTableAnnotationComposer( + }) => i6.$$PersonEntityTableAnnotationComposer( $db: $db, - $table: i4.ReadDatabaseContainer( + $table: i5.ReadDatabaseContainer( $db, - ).resultSet('person_entity'), + ).resultSet('person_entity'), $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -468,6 +499,8 @@ class $$AssetFaceEntityTableTableManager i0.Value boundingBoxX2 = const i0.Value.absent(), i0.Value boundingBoxY2 = const i0.Value.absent(), i0.Value sourceType = const i0.Value.absent(), + i0.Value isVisible = const i0.Value.absent(), + i0.Value deletedAt = const i0.Value.absent(), }) => i1.AssetFaceEntityCompanion( id: id, assetId: assetId, @@ -479,6 +512,8 @@ class $$AssetFaceEntityTableTableManager boundingBoxX2: boundingBoxX2, boundingBoxY2: boundingBoxY2, sourceType: sourceType, + isVisible: isVisible, + deletedAt: deletedAt, ), createCompanionCallback: ({ @@ -492,6 +527,8 @@ class $$AssetFaceEntityTableTableManager required int boundingBoxX2, required int boundingBoxY2, required String sourceType, + i0.Value isVisible = const i0.Value.absent(), + i0.Value deletedAt = const i0.Value.absent(), }) => i1.AssetFaceEntityCompanion.insert( id: id, assetId: assetId, @@ -503,6 +540,8 @@ class $$AssetFaceEntityTableTableManager boundingBoxX2: boundingBoxX2, boundingBoxY2: boundingBoxY2, sourceType: sourceType, + isVisible: isVisible, + deletedAt: deletedAt, ), withReferenceMapper: (p0) => p0 .map( @@ -709,6 +748,33 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity type: i0.DriftSqlType.string, requiredDuringInsert: true, ); + static const i0.VerificationMeta _isVisibleMeta = const i0.VerificationMeta( + 'isVisible', + ); + @override + late final i0.GeneratedColumn isVisible = i0.GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_visible" IN (0, 1))', + ), + defaultValue: const i3.Constant(true), + ); + static const i0.VerificationMeta _deletedAtMeta = const i0.VerificationMeta( + 'deletedAt', + ); + @override + late final i0.GeneratedColumn deletedAt = + i0.GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -721,6 +787,8 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity boundingBoxX2, boundingBoxY2, sourceType, + isVisible, + deletedAt, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -824,6 +892,18 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity } else if (isInserting) { context.missing(_sourceTypeMeta); } + if (data.containsKey('is_visible')) { + context.handle( + _isVisibleMeta, + isVisible.isAcceptableOrUnknown(data['is_visible']!, _isVisibleMeta), + ); + } + if (data.containsKey('deleted_at')) { + context.handle( + _deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta), + ); + } return context; } @@ -873,6 +953,14 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity i0.DriftSqlType.string, data['${effectivePrefix}source_type'], )!, + isVisible: attachedDatabase.typeMapping.read( + i0.DriftSqlType.bool, + data['${effectivePrefix}is_visible'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), ); } @@ -899,6 +987,8 @@ class AssetFaceEntityData extends i0.DataClass final int boundingBoxX2; final int boundingBoxY2; final String sourceType; + final bool isVisible; + final DateTime? deletedAt; const AssetFaceEntityData({ required this.id, required this.assetId, @@ -910,6 +1000,8 @@ class AssetFaceEntityData extends i0.DataClass required this.boundingBoxX2, required this.boundingBoxY2, required this.sourceType, + required this.isVisible, + this.deletedAt, }); @override Map toColumns(bool nullToAbsent) { @@ -926,6 +1018,10 @@ class AssetFaceEntityData extends i0.DataClass map['bounding_box_x2'] = i0.Variable(boundingBoxX2); map['bounding_box_y2'] = i0.Variable(boundingBoxY2); map['source_type'] = i0.Variable(sourceType); + map['is_visible'] = i0.Variable(isVisible); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = i0.Variable(deletedAt); + } return map; } @@ -945,6 +1041,8 @@ class AssetFaceEntityData extends i0.DataClass boundingBoxX2: serializer.fromJson(json['boundingBoxX2']), boundingBoxY2: serializer.fromJson(json['boundingBoxY2']), sourceType: serializer.fromJson(json['sourceType']), + isVisible: serializer.fromJson(json['isVisible']), + deletedAt: serializer.fromJson(json['deletedAt']), ); } @override @@ -961,6 +1059,8 @@ class AssetFaceEntityData extends i0.DataClass 'boundingBoxX2': serializer.toJson(boundingBoxX2), 'boundingBoxY2': serializer.toJson(boundingBoxY2), 'sourceType': serializer.toJson(sourceType), + 'isVisible': serializer.toJson(isVisible), + 'deletedAt': serializer.toJson(deletedAt), }; } @@ -975,6 +1075,8 @@ class AssetFaceEntityData extends i0.DataClass int? boundingBoxX2, int? boundingBoxY2, String? sourceType, + bool? isVisible, + i0.Value deletedAt = const i0.Value.absent(), }) => i1.AssetFaceEntityData( id: id ?? this.id, assetId: assetId ?? this.assetId, @@ -986,6 +1088,8 @@ class AssetFaceEntityData extends i0.DataClass boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, sourceType: sourceType ?? this.sourceType, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, ); AssetFaceEntityData copyWithCompanion(i1.AssetFaceEntityCompanion data) { return AssetFaceEntityData( @@ -1013,6 +1117,8 @@ class AssetFaceEntityData extends i0.DataClass sourceType: data.sourceType.present ? data.sourceType.value : this.sourceType, + isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, ); } @@ -1028,7 +1134,9 @@ class AssetFaceEntityData extends i0.DataClass ..write('boundingBoxY1: $boundingBoxY1, ') ..write('boundingBoxX2: $boundingBoxX2, ') ..write('boundingBoxY2: $boundingBoxY2, ') - ..write('sourceType: $sourceType') + ..write('sourceType: $sourceType, ') + ..write('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') ..write(')')) .toString(); } @@ -1045,6 +1153,8 @@ class AssetFaceEntityData extends i0.DataClass boundingBoxX2, boundingBoxY2, sourceType, + isVisible, + deletedAt, ); @override bool operator ==(Object other) => @@ -1059,7 +1169,9 @@ class AssetFaceEntityData extends i0.DataClass other.boundingBoxY1 == this.boundingBoxY1 && other.boundingBoxX2 == this.boundingBoxX2 && other.boundingBoxY2 == this.boundingBoxY2 && - other.sourceType == this.sourceType); + other.sourceType == this.sourceType && + other.isVisible == this.isVisible && + other.deletedAt == this.deletedAt); } class AssetFaceEntityCompanion @@ -1074,6 +1186,8 @@ class AssetFaceEntityCompanion final i0.Value boundingBoxX2; final i0.Value boundingBoxY2; final i0.Value sourceType; + final i0.Value isVisible; + final i0.Value deletedAt; const AssetFaceEntityCompanion({ this.id = const i0.Value.absent(), this.assetId = const i0.Value.absent(), @@ -1085,6 +1199,8 @@ class AssetFaceEntityCompanion this.boundingBoxX2 = const i0.Value.absent(), this.boundingBoxY2 = const i0.Value.absent(), this.sourceType = const i0.Value.absent(), + this.isVisible = const i0.Value.absent(), + this.deletedAt = const i0.Value.absent(), }); AssetFaceEntityCompanion.insert({ required String id, @@ -1097,6 +1213,8 @@ class AssetFaceEntityCompanion required int boundingBoxX2, required int boundingBoxY2, required String sourceType, + this.isVisible = const i0.Value.absent(), + this.deletedAt = const i0.Value.absent(), }) : id = i0.Value(id), assetId = i0.Value(assetId), imageWidth = i0.Value(imageWidth), @@ -1117,6 +1235,8 @@ class AssetFaceEntityCompanion i0.Expression? boundingBoxX2, i0.Expression? boundingBoxY2, i0.Expression? sourceType, + i0.Expression? isVisible, + i0.Expression? deletedAt, }) { return i0.RawValuesInsertable({ if (id != null) 'id': id, @@ -1129,6 +1249,8 @@ class AssetFaceEntityCompanion if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2, if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2, if (sourceType != null) 'source_type': sourceType, + if (isVisible != null) 'is_visible': isVisible, + if (deletedAt != null) 'deleted_at': deletedAt, }); } @@ -1143,6 +1265,8 @@ class AssetFaceEntityCompanion i0.Value? boundingBoxX2, i0.Value? boundingBoxY2, i0.Value? sourceType, + i0.Value? isVisible, + i0.Value? deletedAt, }) { return i1.AssetFaceEntityCompanion( id: id ?? this.id, @@ -1155,6 +1279,8 @@ class AssetFaceEntityCompanion boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, sourceType: sourceType ?? this.sourceType, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt ?? this.deletedAt, ); } @@ -1191,6 +1317,12 @@ class AssetFaceEntityCompanion if (sourceType.present) { map['source_type'] = i0.Variable(sourceType.value); } + if (isVisible.present) { + map['is_visible'] = i0.Variable(isVisible.value); + } + if (deletedAt.present) { + map['deleted_at'] = i0.Variable(deletedAt.value); + } return map; } @@ -1206,7 +1338,9 @@ class AssetFaceEntityCompanion ..write('boundingBoxY1: $boundingBoxY1, ') ..write('boundingBoxX2: $boundingBoxX2, ') ..write('boundingBoxY2: $boundingBoxY2, ') - ..write('sourceType: $sourceType') + ..write('sourceType: $sourceType, ') + ..write('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') ..write(')')) .toString(); } diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 5495d21bd3..2d90044aea 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository { } @override - int get schemaVersion => 19; + int get schemaVersion => 20; @override MigrationStrategy get migration => MigrationStrategy( @@ -226,6 +226,10 @@ class Drift extends $Drift implements IDatabaseRepository { await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth); await m.createIndex(v19.idxStackPrimaryAssetId); }, + from19To20: (m, v20) async { + await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible); + await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index e56eb97c75..527b0693c7 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -8360,6 +8360,550 @@ final class Schema19 extends i0.VersionedSchema { ); } +final class Schema20 extends i0.VersionedSchema { + Schema20({required super.database}) : super(version: 20); + @override + late final List entities = [ + userEntity, + remoteAssetEntity, + stackEntity, + localAssetEntity, + remoteAlbumEntity, + localAlbumEntity, + localAlbumAssetEntity, + idxLocalAlbumAssetAlbumAsset, + idxRemoteAlbumOwnerId, + idxLocalAssetChecksum, + idxLocalAssetCloudId, + idxStackPrimaryAssetId, + idxRemoteAssetOwnerChecksum, + uQRemoteAssetsOwnerChecksum, + uQRemoteAssetsOwnerLibraryChecksum, + idxRemoteAssetChecksum, + idxRemoteAssetStackId, + idxRemoteAssetLocalDateTimeDay, + idxRemoteAssetLocalDateTimeMonth, + authUserEntity, + userMetadataEntity, + partnerEntity, + remoteExifEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + remoteAssetCloudIdEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + assetFaceEntity, + storeEntity, + trashedLocalAssetEntity, + idxPartnerSharedWithId, + idxLatLng, + idxRemoteAlbumAssetAlbumAsset, + idxRemoteAssetCloudId, + idxPersonOwnerId, + idxAssetFacePersonId, + idxAssetFaceAssetId, + 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 Shape28 remoteAssetEntity = Shape28( + 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, + _column_101, + ], + 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 Shape26 localAssetEntity = Shape26( + 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, + _column_98, + _column_96, + _column_46, + _column_47, + ], + 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 idxLocalAlbumAssetAlbumAsset = i1.Index( + 'idx_local_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)', + ); + final i1.Index idxRemoteAlbumOwnerId = i1.Index( + 'idx_remote_album_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)', + ); + 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 idxLocalAssetCloudId = i1.Index( + 'idx_local_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)', + ); + final i1.Index idxStackPrimaryAssetId = i1.Index( + 'idx_stack_primary_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', + ); + 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)', + ); + final i1.Index idxRemoteAssetStackId = i1.Index( + 'idx_remote_asset_stack_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', + ); + final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index( + 'idx_remote_asset_local_date_time_day', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', + ); + final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index( + 'idx_remote_asset_local_date_time_month', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + ); + 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 Shape27 remoteAssetCloudIdEntity = Shape27( + source: i0.VersionedTable( + entityName: 'remote_asset_cloud_id_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(asset_id)'], + columns: [ + _column_36, + _column_99, + _column_100, + _column_96, + _column_46, + _column_47, + ], + 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 Shape29 assetFaceEntity = Shape29( + 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, + _column_102, + _column_18, + ], + 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 Shape25 trashedLocalAssetEntity = Shape25( + 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, + _column_97, + ], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxPartnerSharedWithId = i1.Index( + 'idx_partner_shared_with_id', + 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)', + ); + 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 idxRemoteAlbumAssetAlbumAsset = i1.Index( + 'idx_remote_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', + ); + final i1.Index idxRemoteAssetCloudId = i1.Index( + 'idx_remote_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)', + ); + final i1.Index idxPersonOwnerId = i1.Index( + 'idx_person_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)', + ); + final i1.Index idxAssetFacePersonId = i1.Index( + 'idx_asset_face_person_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)', + ); + final i1.Index idxAssetFaceAssetId = i1.Index( + 'idx_asset_face_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', + ); + 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 Shape29 extends i0.VersionedTable { + Shape29({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get assetId => + columnsByName['asset_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get personId => + columnsByName['person_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get imageWidth => + columnsByName['image_width']! as i1.GeneratedColumn; + i1.GeneratedColumn get imageHeight => + columnsByName['image_height']! as i1.GeneratedColumn; + i1.GeneratedColumn get boundingBoxX1 => + columnsByName['bounding_box_x1']! as i1.GeneratedColumn; + i1.GeneratedColumn get boundingBoxY1 => + columnsByName['bounding_box_y1']! as i1.GeneratedColumn; + i1.GeneratedColumn get boundingBoxX2 => + columnsByName['bounding_box_x2']! as i1.GeneratedColumn; + i1.GeneratedColumn get boundingBoxY2 => + columnsByName['bounding_box_y2']! as i1.GeneratedColumn; + i1.GeneratedColumn get sourceType => + columnsByName['source_type']! as i1.GeneratedColumn; + i1.GeneratedColumn get isVisible => + columnsByName['is_visible']! as i1.GeneratedColumn; + i1.GeneratedColumn get deletedAt => + columnsByName['deleted_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_102(String aliasedName) => + i1.GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_visible" IN (0, 1))', + ), + defaultValue: const CustomExpression('1'), + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, @@ -8379,6 +8923,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema17 schema) from16To17, required Future Function(i1.Migrator m, Schema18 schema) from17To18, required Future Function(i1.Migrator m, Schema19 schema) from18To19, + required Future Function(i1.Migrator m, Schema20 schema) from19To20, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -8472,6 +9017,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from18To19(migrator, schema); return 19; + case 19: + final schema = Schema20(database: database); + final migrator = i1.Migrator(database, schema); + await from19To20(migrator, schema); + return 20; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -8497,6 +9047,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema17 schema) from16To17, required Future Function(i1.Migrator m, Schema18 schema) from17To18, required Future Function(i1.Migrator m, Schema19 schema) from18To19, + required Future Function(i1.Migrator m, Schema20 schema) from19To20, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -8517,5 +9068,6 @@ i1.OnUpgrade stepByStep({ from16To17: from16To17, from17To18: from17To18, from18To19: from18To19, + from19To20: from19To20, ), ); diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index 40402b6f72..9e55d44867 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -16,9 +16,15 @@ class DriftPeopleRepository extends DriftDatabaseRepository { } Future> getAssetPeople(String assetId) async { - final query = _db.select(_db.assetFaceEntity).join([ - innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)), - ])..where(_db.assetFaceEntity.assetId.equals(assetId) & _db.personEntity.isHidden.equals(false)); + final query = + _db.select(_db.assetFaceEntity).join([ + innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)), + ])..where( + _db.assetFaceEntity.assetId.equals(assetId) & + _db.assetFaceEntity.isVisible.equals(true) & + _db.assetFaceEntity.deletedAt.isNull() & + _db.personEntity.isHidden.equals(false), + ); return query.map((row) { final person = row.readTable(_db.personEntity); @@ -39,7 +45,9 @@ class DriftPeopleRepository extends DriftDatabaseRepository { ..where( people.isHidden.equals(false) & assets.deletedAt.isNull() & - assets.visibility.equalsValue(AssetVisibility.timeline), + assets.visibility.equalsValue(AssetVisibility.timeline) & + faces.isVisible.equals(true) & + faces.deletedAt.isNull(), ) ..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(3) | people.name.equals('').not()) ..orderBy([ diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index d13083d706..0e5c99edd7 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -7,6 +7,7 @@ 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/services/api.service.dart'; +import 'package:immich_mobile/utils/semver.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -25,6 +26,7 @@ class SyncApiRepository { Future streamChanges( Future Function(List, Function() abort, Function() reset) onData, { + required SemVer serverVersion, Function()? onReset, int batchSize = kSyncEventBatchSize, http.Client? httpClient, @@ -64,7 +66,8 @@ class SyncApiRepository { SyncRequestType.partnerStacksV1, SyncRequestType.userMetadataV1, SyncRequestType.peopleV1, - SyncRequestType.assetFacesV1, + if (serverVersion < const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV1, + if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV2, ], reset: shouldReset, ).toJson(), @@ -190,6 +193,7 @@ const _kResponseMap = { SyncEntityType.personV1: SyncPersonV1.fromJson, SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson, SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson, + SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson, SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson, SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson, }; diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 26f89432a5..8ff1c2d59c 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -652,6 +652,37 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + Future updateAssetFacesV2(Iterable data) async { + try { + await _db.batch((batch) { + for (final assetFace in data) { + final companion = AssetFaceEntityCompanion( + assetId: Value(assetFace.assetId), + personId: Value(assetFace.personId), + imageWidth: Value(assetFace.imageWidth), + imageHeight: Value(assetFace.imageHeight), + boundingBoxX1: Value(assetFace.boundingBoxX1), + boundingBoxY1: Value(assetFace.boundingBoxY1), + boundingBoxX2: Value(assetFace.boundingBoxX2), + boundingBoxY2: Value(assetFace.boundingBoxY2), + sourceType: Value(assetFace.sourceType), + deletedAt: Value(assetFace.deletedAt), + isVisible: Value(assetFace.isVisible), + ); + + batch.insert( + _db.assetFaceEntity, + companion.copyWith(id: Value(assetFace.id)), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: updateAssetFacesV2', error, stack); + rethrow; + } + } + Future deleteAssetFacesV1(Iterable data) async { try { await _db.batch((batch) { diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 7544b4b2ac..4ddb679a0f 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -421,7 +421,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository { _db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & - _db.assetFaceEntity.personId.equals(personId), + _db.assetFaceEntity.personId.equals(personId) & + _db.assetFaceEntity.isVisible.equals(true) & + _db.assetFaceEntity.deletedAt.isNull(), ); return query.map((row) { @@ -446,7 +448,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository { _db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & - _db.assetFaceEntity.personId.equals(personId), + _db.assetFaceEntity.personId.equals(personId) & + _db.assetFaceEntity.isVisible.equals(true) & + _db.assetFaceEntity.deletedAt.isNull(), ) ..groupBy([dateExp]) ..orderBy([OrderingTerm.desc(dateExp)]); @@ -476,7 +480,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository { _db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & - _db.assetFaceEntity.personId.equals(personId), + _db.assetFaceEntity.personId.equals(personId) & + _db.assetFaceEntity.isVisible.equals(true) & + _db.assetFaceEntity.deletedAt.isNull(), ) ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) ..limit(count, offset: offset); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index afeeb694e1..34845dcd9f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -580,6 +580,7 @@ Class | Method | HTTP request | Description - [SyncAssetExifV1](doc//SyncAssetExifV1.md) - [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md) - [SyncAssetFaceV1](doc//SyncAssetFaceV1.md) + - [SyncAssetFaceV2](doc//SyncAssetFaceV2.md) - [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md) - [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md) - [SyncAssetV1](doc//SyncAssetV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 0d6a98c001..927ccae4cc 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -319,6 +319,7 @@ part 'model/sync_asset_delete_v1.dart'; part 'model/sync_asset_exif_v1.dart'; part 'model/sync_asset_face_delete_v1.dart'; part 'model/sync_asset_face_v1.dart'; +part 'model/sync_asset_face_v2.dart'; part 'model/sync_asset_metadata_delete_v1.dart'; part 'model/sync_asset_metadata_v1.dart'; part 'model/sync_asset_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5aabf5cd4b..33281f3be3 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -684,6 +684,8 @@ class ApiClient { return SyncAssetFaceDeleteV1.fromJson(value); case 'SyncAssetFaceV1': return SyncAssetFaceV1.fromJson(value); + case 'SyncAssetFaceV2': + return SyncAssetFaceV2.fromJson(value); case 'SyncAssetMetadataDeleteV1': return SyncAssetMetadataDeleteV1.fromJson(value); case 'SyncAssetMetadataV1': diff --git a/mobile/openapi/lib/model/sync_asset_face_v2.dart b/mobile/openapi/lib/model/sync_asset_face_v2.dart new file mode 100644 index 0000000000..688d71229f --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_face_v2.dart @@ -0,0 +1,201 @@ +// +// 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 SyncAssetFaceV2 { + /// Returns a new [SyncAssetFaceV2] instance. + SyncAssetFaceV2({ + required this.assetId, + required this.boundingBoxX1, + required this.boundingBoxX2, + required this.boundingBoxY1, + required this.boundingBoxY2, + required this.deletedAt, + required this.id, + required this.imageHeight, + required this.imageWidth, + required this.isVisible, + required this.personId, + required this.sourceType, + }); + + /// Asset ID + String assetId; + + int boundingBoxX1; + + int boundingBoxX2; + + int boundingBoxY1; + + int boundingBoxY2; + + /// Face deleted at + DateTime? deletedAt; + + /// Asset face ID + String id; + + int imageHeight; + + int imageWidth; + + /// Is the face visible in the asset + bool isVisible; + + /// Person ID + String? personId; + + /// Source type + String sourceType; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetFaceV2 && + other.assetId == assetId && + other.boundingBoxX1 == boundingBoxX1 && + other.boundingBoxX2 == boundingBoxX2 && + other.boundingBoxY1 == boundingBoxY1 && + other.boundingBoxY2 == boundingBoxY2 && + other.deletedAt == deletedAt && + other.id == id && + other.imageHeight == imageHeight && + other.imageWidth == imageWidth && + other.isVisible == isVisible && + other.personId == personId && + other.sourceType == sourceType; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (boundingBoxX1.hashCode) + + (boundingBoxX2.hashCode) + + (boundingBoxY1.hashCode) + + (boundingBoxY2.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (id.hashCode) + + (imageHeight.hashCode) + + (imageWidth.hashCode) + + (isVisible.hashCode) + + (personId == null ? 0 : personId!.hashCode) + + (sourceType.hashCode); + + @override + String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, personId=$personId, sourceType=$sourceType]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'boundingBoxX1'] = this.boundingBoxX1; + json[r'boundingBoxX2'] = this.boundingBoxX2; + json[r'boundingBoxY1'] = this.boundingBoxY1; + json[r'boundingBoxY2'] = this.boundingBoxY2; + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + json[r'id'] = this.id; + json[r'imageHeight'] = this.imageHeight; + json[r'imageWidth'] = this.imageWidth; + json[r'isVisible'] = this.isVisible; + if (this.personId != null) { + json[r'personId'] = this.personId; + } else { + // json[r'personId'] = null; + } + json[r'sourceType'] = this.sourceType; + return json; + } + + /// Returns a new [SyncAssetFaceV2] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetFaceV2? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetFaceV2"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetFaceV2( + assetId: mapValueOfType(json, r'assetId')!, + boundingBoxX1: mapValueOfType(json, r'boundingBoxX1')!, + boundingBoxX2: mapValueOfType(json, r'boundingBoxX2')!, + boundingBoxY1: mapValueOfType(json, r'boundingBoxY1')!, + boundingBoxY2: mapValueOfType(json, r'boundingBoxY2')!, + deletedAt: mapDateTime(json, r'deletedAt', r''), + id: mapValueOfType(json, r'id')!, + imageHeight: mapValueOfType(json, r'imageHeight')!, + imageWidth: mapValueOfType(json, r'imageWidth')!, + isVisible: mapValueOfType(json, r'isVisible')!, + personId: mapValueOfType(json, r'personId'), + sourceType: mapValueOfType(json, r'sourceType')!, + ); + } + 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 = SyncAssetFaceV2.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 = SyncAssetFaceV2.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetFaceV2-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] = SyncAssetFaceV2.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'boundingBoxX1', + 'boundingBoxX2', + 'boundingBoxY1', + 'boundingBoxY2', + 'deletedAt', + 'id', + 'imageHeight', + 'imageWidth', + 'isVisible', + 'personId', + 'sourceType', + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index d1e321f39b..e7605a5dd1 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -64,6 +64,7 @@ class SyncEntityType { static const personV1 = SyncEntityType._(r'PersonV1'); static const personDeleteV1 = SyncEntityType._(r'PersonDeleteV1'); static const assetFaceV1 = SyncEntityType._(r'AssetFaceV1'); + static const assetFaceV2 = SyncEntityType._(r'AssetFaceV2'); static const assetFaceDeleteV1 = SyncEntityType._(r'AssetFaceDeleteV1'); static const userMetadataV1 = SyncEntityType._(r'UserMetadataV1'); static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1'); @@ -114,6 +115,7 @@ class SyncEntityType { personV1, personDeleteV1, assetFaceV1, + assetFaceV2, assetFaceDeleteV1, userMetadataV1, userMetadataDeleteV1, @@ -199,6 +201,7 @@ class SyncEntityTypeTypeTransformer { case r'PersonV1': return SyncEntityType.personV1; case r'PersonDeleteV1': return SyncEntityType.personDeleteV1; case r'AssetFaceV1': return SyncEntityType.assetFaceV1; + case r'AssetFaceV2': return SyncEntityType.assetFaceV2; case r'AssetFaceDeleteV1': return SyncEntityType.assetFaceDeleteV1; case r'UserMetadataV1': return SyncEntityType.userMetadataV1; case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1; diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index 135af3c7bb..3614394d55 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -42,6 +42,7 @@ class SyncRequestType { static const usersV1 = SyncRequestType._(r'UsersV1'); static const peopleV1 = SyncRequestType._(r'PeopleV1'); static const assetFacesV1 = SyncRequestType._(r'AssetFacesV1'); + static const assetFacesV2 = SyncRequestType._(r'AssetFacesV2'); static const userMetadataV1 = SyncRequestType._(r'UserMetadataV1'); /// List of all possible values in this [enum][SyncRequestType]. @@ -65,6 +66,7 @@ class SyncRequestType { usersV1, peopleV1, assetFacesV1, + assetFacesV2, userMetadataV1, ]; @@ -123,6 +125,7 @@ class SyncRequestTypeTypeTransformer { case r'UsersV1': return SyncRequestType.usersV1; case r'PeopleV1': return SyncRequestType.peopleV1; case r'AssetFacesV1': return SyncRequestType.assetFacesV1; + case r'AssetFacesV2': return SyncRequestType.assetFacesV2; case r'UserMetadataV1': return SyncRequestType.userMetadataV1; default: if (!allowNull) { diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 0eabf3b612..a182c6cdca 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.da 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:immich_mobile/utils/semver.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; @@ -66,6 +67,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); debugDefaultTargetPlatformOverride = TargetPlatform.android; registerFallbackValue(LocalAssetStub.image1); + registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0)); db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); await StoreService.init(storeRepository: DriftStoreRepository(db)); @@ -94,11 +96,19 @@ void main() { when(() => mockAbortCallbackWrapper()).thenReturn(false); - when(() => mockSyncApiRepo.streamChanges(any())).thenAnswer((invocation) async { + when(() => mockSyncApiRepo.streamChanges(any(), serverVersion: any(named: 'serverVersion'))).thenAnswer(( + invocation, + ) async { handleEventsCallback = invocation.positionalArguments.first; }); - when(() => mockSyncApiRepo.streamChanges(any(), onReset: any(named: 'onReset'))).thenAnswer((invocation) async { + when( + () => mockSyncApiRepo.streamChanges( + any(), + onReset: any(named: 'onReset'), + serverVersion: any(named: 'serverVersion'), + ), + ).thenAnswer((invocation) async { handleEventsCallback = invocation.positionalArguments.first; }); @@ -106,9 +116,9 @@ void main() { when(() => mockSyncApiRepo.deleteSyncAck(any())).thenAnswer((_) async => {}); when(() => mockApi.serverInfoApi).thenReturn(mockServerApi); - when(() => mockServerApi.getServerVersion()).thenAnswer( - (_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0), - ); + when( + () => mockServerApi.getServerVersion(), + ).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0)); when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler); diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index d9f18b3007..2ec39fafde 100644 --- a/mobile/test/drift/main/generated/schema.dart +++ b/mobile/test/drift/main/generated/schema.dart @@ -22,6 +22,7 @@ import 'schema_v16.dart' as v16; import 'schema_v17.dart' as v17; import 'schema_v18.dart' as v18; import 'schema_v19.dart' as v19; +import 'schema_v20.dart' as v20; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -65,6 +66,8 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v18.DatabaseAtV18(db); case 19: return v19.DatabaseAtV19(db); + case 20: + return v20.DatabaseAtV20(db); default: throw MissingSchemaException(version, versions); } @@ -90,5 +93,6 @@ class GeneratedHelper implements SchemaInstantiationHelper { 17, 18, 19, + 20, ]; } diff --git a/mobile/test/drift/main/generated/schema_v20.dart b/mobile/test/drift/main/generated/schema_v20.dart new file mode 100644 index 0000000000..8f7b204f7a --- /dev/null +++ b/mobile/test/drift/main/generated/schema_v20.dart @@ -0,0 +1,8471 @@ +// 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, + ); + late final GeneratedColumn isEdited = GeneratedColumn( + 'is_edited', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_edited" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId, + libraryId, + isEdited, + ]; + @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'], + ), + isEdited: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_edited'], + )!, + ); + } + + @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; + final bool isEdited; + 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, + required this.isEdited, + }); + @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); + } + map['is_edited'] = Variable(isEdited); + 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']), + isEdited: serializer.fromJson(json['isEdited']), + ); + } + @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), + 'isEdited': serializer.toJson(isEdited), + }; + } + + 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(), + bool? isEdited, + }) => 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, + isEdited: isEdited ?? this.isEdited, + ); + 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, + isEdited: data.isEdited.present ? data.isEdited.value : this.isEdited, + ); + } + + @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('isEdited: $isEdited') + ..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, + isEdited, + ); + @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 && + other.isEdited == this.isEdited); +} + +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; + final Value isEdited; + 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(), + this.isEdited = 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(), + this.isEdited = 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, + Expression? isEdited, + }) { + 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, + if (isEdited != null) 'is_edited': isEdited, + }); + } + + 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, + Value? isEdited, + }) { + 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, + isEdited: isEdited ?? this.isEdited, + ); + } + + @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); + } + if (isEdited.present) { + map['is_edited'] = Variable(isEdited.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('isEdited: $isEdited') + ..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'), + ); + late final GeneratedColumn iCloudId = GeneratedColumn( + 'i_cloud_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn adjustmentTime = + GeneratedColumn( + 'adjustment_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + 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, + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation, + iCloudId, + adjustmentTime, + latitude, + longitude, + ]; + @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'], + )!, + iCloudId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}i_cloud_id'], + ), + adjustmentTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}adjustment_time'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + ); + } + + @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; + final String? iCloudId; + final DateTime? adjustmentTime; + final double? latitude; + final double? longitude; + 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, + this.iCloudId, + this.adjustmentTime, + this.latitude, + this.longitude, + }); + @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); + if (!nullToAbsent || iCloudId != null) { + map['i_cloud_id'] = Variable(iCloudId); + } + if (!nullToAbsent || adjustmentTime != null) { + map['adjustment_time'] = Variable(adjustmentTime); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + 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']), + iCloudId: serializer.fromJson(json['iCloudId']), + adjustmentTime: serializer.fromJson(json['adjustmentTime']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + ); + } + @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), + 'iCloudId': serializer.toJson(iCloudId), + 'adjustmentTime': serializer.toJson(adjustmentTime), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + }; + } + + 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, + Value iCloudId = const Value.absent(), + Value adjustmentTime = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + }) => 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, + iCloudId: iCloudId.present ? iCloudId.value : this.iCloudId, + adjustmentTime: adjustmentTime.present + ? adjustmentTime.value + : this.adjustmentTime, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + ); + 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, + iCloudId: data.iCloudId.present ? data.iCloudId.value : this.iCloudId, + adjustmentTime: data.adjustmentTime.present + ? data.adjustmentTime.value + : this.adjustmentTime, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + ); + } + + @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('iCloudId: $iCloudId, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation, + iCloudId, + adjustmentTime, + latitude, + longitude, + ); + @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 && + other.iCloudId == this.iCloudId && + other.adjustmentTime == this.adjustmentTime && + other.latitude == this.latitude && + other.longitude == this.longitude); +} + +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; + final Value iCloudId; + final Value adjustmentTime; + final Value latitude; + final Value longitude; + 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(), + this.iCloudId = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = 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(), + this.iCloudId = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = 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, + Expression? iCloudId, + Expression? adjustmentTime, + Expression? latitude, + Expression? longitude, + }) { + 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, + if (iCloudId != null) 'i_cloud_id': iCloudId, + if (adjustmentTime != null) 'adjustment_time': adjustmentTime, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + }); + } + + LocalAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? orientation, + Value? iCloudId, + Value? adjustmentTime, + Value? latitude, + Value? longitude, + }) { + 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, + iCloudId: iCloudId ?? this.iCloudId, + adjustmentTime: adjustmentTime ?? this.adjustmentTime, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } + + @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); + } + if (iCloudId.present) { + map['i_cloud_id'] = Variable(iCloudId.value); + } + if (adjustmentTime.present) { + map['adjustment_time'] = Variable(adjustmentTime.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.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('iCloudId: $iCloudId, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..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 RemoteAssetCloudIdEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetCloudIdEntity(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 cloudId = GeneratedColumn( + 'cloud_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn adjustmentTime = + GeneratedColumn( + 'adjustment_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + 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, + ); + @override + List get $columns => [ + assetId, + cloudId, + createdAt, + adjustmentTime, + latitude, + longitude, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_cloud_id_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteAssetCloudIdEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetCloudIdEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + cloudId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cloud_id'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + ), + adjustmentTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}adjustment_time'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + ); + } + + @override + RemoteAssetCloudIdEntity createAlias(String alias) { + return RemoteAssetCloudIdEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetCloudIdEntityData extends DataClass + implements Insertable { + final String assetId; + final String? cloudId; + final DateTime? createdAt; + final DateTime? adjustmentTime; + final double? latitude; + final double? longitude; + const RemoteAssetCloudIdEntityData({ + required this.assetId, + this.cloudId, + this.createdAt, + this.adjustmentTime, + this.latitude, + this.longitude, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || cloudId != null) { + map['cloud_id'] = Variable(cloudId); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + if (!nullToAbsent || adjustmentTime != null) { + map['adjustment_time'] = Variable(adjustmentTime); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + return map; + } + + factory RemoteAssetCloudIdEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetCloudIdEntityData( + assetId: serializer.fromJson(json['assetId']), + cloudId: serializer.fromJson(json['cloudId']), + createdAt: serializer.fromJson(json['createdAt']), + adjustmentTime: serializer.fromJson(json['adjustmentTime']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'cloudId': serializer.toJson(cloudId), + 'createdAt': serializer.toJson(createdAt), + 'adjustmentTime': serializer.toJson(adjustmentTime), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + }; + } + + RemoteAssetCloudIdEntityData copyWith({ + String? assetId, + Value cloudId = const Value.absent(), + Value createdAt = const Value.absent(), + Value adjustmentTime = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + }) => RemoteAssetCloudIdEntityData( + assetId: assetId ?? this.assetId, + cloudId: cloudId.present ? cloudId.value : this.cloudId, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + adjustmentTime: adjustmentTime.present + ? adjustmentTime.value + : this.adjustmentTime, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + ); + RemoteAssetCloudIdEntityData copyWithCompanion( + RemoteAssetCloudIdEntityCompanion data, + ) { + return RemoteAssetCloudIdEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + adjustmentTime: data.adjustmentTime.present + ? data.adjustmentTime.value + : this.adjustmentTime, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetCloudIdEntityData(') + ..write('assetId: $assetId, ') + ..write('cloudId: $cloudId, ') + ..write('createdAt: $createdAt, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + assetId, + cloudId, + createdAt, + adjustmentTime, + latitude, + longitude, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetCloudIdEntityData && + other.assetId == this.assetId && + other.cloudId == this.cloudId && + other.createdAt == this.createdAt && + other.adjustmentTime == this.adjustmentTime && + other.latitude == this.latitude && + other.longitude == this.longitude); +} + +class RemoteAssetCloudIdEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value cloudId; + final Value createdAt; + final Value adjustmentTime; + final Value latitude; + final Value longitude; + const RemoteAssetCloudIdEntityCompanion({ + this.assetId = const Value.absent(), + this.cloudId = const Value.absent(), + this.createdAt = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + }); + RemoteAssetCloudIdEntityCompanion.insert({ + required String assetId, + this.cloudId = const Value.absent(), + this.createdAt = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? cloudId, + Expression? createdAt, + Expression? adjustmentTime, + Expression? latitude, + Expression? longitude, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (cloudId != null) 'cloud_id': cloudId, + if (createdAt != null) 'created_at': createdAt, + if (adjustmentTime != null) 'adjustment_time': adjustmentTime, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + }); + } + + RemoteAssetCloudIdEntityCompanion copyWith({ + Value? assetId, + Value? cloudId, + Value? createdAt, + Value? adjustmentTime, + Value? latitude, + Value? longitude, + }) { + return RemoteAssetCloudIdEntityCompanion( + assetId: assetId ?? this.assetId, + cloudId: cloudId ?? this.cloudId, + createdAt: createdAt ?? this.createdAt, + adjustmentTime: adjustmentTime ?? this.adjustmentTime, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (cloudId.present) { + map['cloud_id'] = Variable(cloudId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (adjustmentTime.present) { + map['adjustment_time'] = Variable(adjustmentTime.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetCloudIdEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('cloudId: $cloudId, ') + ..write('createdAt: $createdAt, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..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, + ); + late final GeneratedColumn isVisible = GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_visible" IN (0, 1))', + ), + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + isVisible, + deletedAt, + ]; + @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'], + )!, + isVisible: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_visible'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + ); + } + + @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; + final bool isVisible; + final DateTime? deletedAt; + 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, + required this.isVisible, + this.deletedAt, + }); + @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); + map['is_visible'] = Variable(isVisible); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + 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']), + isVisible: serializer.fromJson(json['isVisible']), + deletedAt: serializer.fromJson(json['deletedAt']), + ); + } + @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), + 'isVisible': serializer.toJson(isVisible), + 'deletedAt': serializer.toJson(deletedAt), + }; + } + + 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, + bool? isVisible, + Value deletedAt = const Value.absent(), + }) => 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, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ); + 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, + isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ); + } + + @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('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + isVisible, + deletedAt, + ); + @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 && + other.isVisible == this.isVisible && + other.deletedAt == this.deletedAt); +} + +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; + final Value isVisible; + final Value deletedAt; + 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(), + this.isVisible = const Value.absent(), + this.deletedAt = 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, + this.isVisible = const Value.absent(), + this.deletedAt = const Value.absent(), + }) : 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, + Expression? isVisible, + Expression? deletedAt, + }) { + 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, + if (isVisible != null) 'is_visible': isVisible, + if (deletedAt != null) 'deleted_at': deletedAt, + }); + } + + AssetFaceEntityCompanion copyWith({ + Value? id, + Value? assetId, + Value? personId, + Value? imageWidth, + Value? imageHeight, + Value? boundingBoxX1, + Value? boundingBoxY1, + Value? boundingBoxX2, + Value? boundingBoxY2, + Value? sourceType, + Value? isVisible, + Value? deletedAt, + }) { + 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, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt ?? this.deletedAt, + ); + } + + @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); + } + if (isVisible.present) { + map['is_visible'] = Variable(isVisible.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.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('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') + ..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'), + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + source, + ]; + @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'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + ); + } + + @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; + final int source; + 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, + required this.source, + }); + @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); + map['source'] = Variable(source); + 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']), + source: serializer.fromJson(json['source']), + ); + } + @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), + 'source': serializer.toJson(source), + }; + } + + 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, + int? source, + }) => 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, + source: source ?? this.source, + ); + 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, + source: data.source.present ? data.source.value : this.source, + ); + } + + @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('source: $source') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + source, + ); + @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 && + other.source == this.source); +} + +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; + final Value source; + 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(), + this.source = 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(), + required int source, + }) : name = Value(name), + type = Value(type), + id = Value(id), + albumId = Value(albumId), + source = Value(source); + 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, + Expression? source, + }) { + 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, + if (source != null) 'source': source, + }); + } + + 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, + Value? source, + }) { + 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, + source: source ?? this.source, + ); + } + + @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); + } + if (source.present) { + map['source'] = Variable(source.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('source: $source') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV20 extends GeneratedDatabase { + DatabaseAtV20(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 idxLocalAlbumAssetAlbumAsset = Index( + 'idx_local_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)', + ); + late final Index idxRemoteAlbumOwnerId = Index( + 'idx_remote_album_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)', + ); + 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 idxLocalAssetCloudId = Index( + 'idx_local_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)', + ); + late final Index idxStackPrimaryAssetId = Index( + 'idx_stack_primary_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', + ); + 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 Index idxRemoteAssetStackId = Index( + 'idx_remote_asset_stack_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', + ); + late final Index idxRemoteAssetLocalDateTimeDay = Index( + 'idx_remote_asset_local_date_time_day', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', + ); + late final Index idxRemoteAssetLocalDateTimeMonth = Index( + 'idx_remote_asset_local_date_time_month', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + ); + 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 RemoteAssetCloudIdEntity remoteAssetCloudIdEntity = + RemoteAssetCloudIdEntity(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 idxPartnerSharedWithId = Index( + 'idx_partner_shared_with_id', + 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)', + ); + 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 idxRemoteAlbumAssetAlbumAsset = Index( + 'idx_remote_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', + ); + late final Index idxRemoteAssetCloudId = Index( + 'idx_remote_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)', + ); + late final Index idxPersonOwnerId = Index( + 'idx_person_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)', + ); + late final Index idxAssetFacePersonId = Index( + 'idx_asset_face_person_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)', + ); + late final Index idxAssetFaceAssetId = Index( + 'idx_asset_face_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', + ); + 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, + idxLocalAlbumAssetAlbumAsset, + idxRemoteAlbumOwnerId, + idxLocalAssetChecksum, + idxLocalAssetCloudId, + idxStackPrimaryAssetId, + idxRemoteAssetOwnerChecksum, + uQRemoteAssetsOwnerChecksum, + uQRemoteAssetsOwnerLibraryChecksum, + idxRemoteAssetChecksum, + idxRemoteAssetStackId, + idxRemoteAssetLocalDateTimeDay, + idxRemoteAssetLocalDateTimeMonth, + authUserEntity, + userMetadataEntity, + partnerEntity, + remoteExifEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + remoteAssetCloudIdEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + assetFaceEntity, + storeEntity, + trashedLocalAssetEntity, + idxPartnerSharedWithId, + idxLatLng, + idxRemoteAlbumAssetAlbumAsset, + idxRemoteAssetCloudId, + idxPersonOwnerId, + idxAssetFacePersonId, + idxAssetFaceAssetId, + idxTrashedLocalAssetChecksum, + idxTrashedLocalAssetAlbum, + ]; + @override + int get schemaVersion => 20; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 660b8206bb..62aae4c0da 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; +import 'package:immich_mobile/utils/semver.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; @@ -72,8 +73,14 @@ void main() { Future streamChanges( Future Function(List, Function() abort, Function() reset) onDataCallback, + SemVer serverVersion, ) { - return sut.streamChanges(onDataCallback, batchSize: testBatchSize, httpClient: mockHttpClient); + return sut.streamChanges( + onDataCallback, + batchSize: testBatchSize, + httpClient: mockHttpClient, + serverVersion: serverVersion, + ); } test('streamChanges stops processing stream when abort is called', () async { @@ -94,7 +101,7 @@ void main() { } } - final streamChangesFuture = streamChanges(onDataCallback); + final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0)); // Give the stream subscription time to start (longer delay to account for mock delay) await Future.delayed(const Duration(milliseconds: 50)); @@ -145,7 +152,7 @@ void main() { } } - final streamChangesFuture = streamChanges(onDataCallback); + final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0)); await Future.delayed(const Duration(milliseconds: 50)); @@ -197,7 +204,7 @@ void main() { } } - final streamChangesFuture = streamChanges(onDataCallback); + final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0)); await Future.delayed(const Duration(milliseconds: 50)); @@ -244,7 +251,7 @@ void main() { onDataCallCount++; } - final streamChangesFuture = streamChanges(onDataCallback); + final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0)); await Future.delayed(const Duration(milliseconds: 50)); @@ -271,7 +278,7 @@ void main() { onDataCallCount++; } - final future = streamChanges(onDataCallback); + final future = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0)); errorBodyController.add(utf8.encode('{"error":"Unauthorized"}')); await errorBodyController.close(); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0e57fc4819..da654f0907 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -22839,6 +22839,70 @@ ], "type": "object" }, + "SyncAssetFaceV2": { + "properties": { + "assetId": { + "description": "Asset ID", + "type": "string" + }, + "boundingBoxX1": { + "type": "integer" + }, + "boundingBoxX2": { + "type": "integer" + }, + "boundingBoxY1": { + "type": "integer" + }, + "boundingBoxY2": { + "type": "integer" + }, + "deletedAt": { + "description": "Face deleted at", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "description": "Asset face ID", + "type": "string" + }, + "imageHeight": { + "type": "integer" + }, + "imageWidth": { + "type": "integer" + }, + "isVisible": { + "description": "Is the face visible in the asset", + "type": "boolean" + }, + "personId": { + "description": "Person ID", + "nullable": true, + "type": "string" + }, + "sourceType": { + "description": "Source type", + "type": "string" + } + }, + "required": [ + "assetId", + "boundingBoxX1", + "boundingBoxX2", + "boundingBoxY1", + "boundingBoxY2", + "deletedAt", + "id", + "imageHeight", + "imageWidth", + "isVisible", + "personId", + "sourceType" + ], + "type": "object" + }, "SyncAssetMetadataDeleteV1": { "properties": { "assetId": { @@ -23132,6 +23196,7 @@ "PersonV1", "PersonDeleteV1", "AssetFaceV1", + "AssetFaceV2", "AssetFaceDeleteV1", "UserMetadataV1", "UserMetadataDeleteV1", @@ -23405,6 +23470,7 @@ "UsersV1", "PeopleV1", "AssetFacesV1", + "AssetFacesV2", "UserMetadataV1" ], "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index acd8109cd3..abf14d5340 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3037,6 +3037,26 @@ export type SyncAssetFaceV1 = { /** Source type */ sourceType: string; }; +export type SyncAssetFaceV2 = { + /** Asset ID */ + assetId: string; + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; + /** Face deleted at */ + deletedAt: string | null; + /** Asset face ID */ + id: string; + imageHeight: number; + imageWidth: number; + /** Is the face visible in the asset */ + isVisible: boolean; + /** Person ID */ + personId: string | null; + /** Source type */ + sourceType: string; +}; export type SyncAssetMetadataDeleteV1 = { /** Asset ID */ assetId: string; @@ -7243,6 +7263,7 @@ export enum SyncEntityType { PersonV1 = "PersonV1", PersonDeleteV1 = "PersonDeleteV1", AssetFaceV1 = "AssetFaceV1", + AssetFaceV2 = "AssetFaceV2", AssetFaceDeleteV1 = "AssetFaceDeleteV1", UserMetadataV1 = "UserMetadataV1", UserMetadataDeleteV1 = "UserMetadataDeleteV1", @@ -7270,6 +7291,7 @@ export enum SyncRequestType { UsersV1 = "UsersV1", PeopleV1 = "PeopleV1", AssetFacesV1 = "AssetFacesV1", + AssetFacesV2 = "AssetFacesV2", UserMetadataV1 = "UserMetadataV1" } export enum TranscodeHWAccel { diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 59d7d373f0..c1b85c0430 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -422,6 +422,20 @@ export class SyncAssetFaceV1 { sourceType!: string; } +@ExtraModel() +export class SyncAssetFaceV2 extends SyncAssetFaceV1 { + @ApiProperty({ description: 'Face deleted at' }) + deletedAt!: Date | null; + @ApiProperty({ description: 'Is the face visible in the asset' }) + isVisible!: boolean; +} + +export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 { + const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2; + + return faceV1; +} + @ExtraModel() export class SyncAssetFaceDeleteV1 { @ApiProperty({ description: 'Asset face ID' }) @@ -497,6 +511,7 @@ export type SyncItem = { [SyncEntityType.PersonV1]: SyncPersonV1; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; [SyncEntityType.AssetFaceV1]: SyncAssetFaceV1; + [SyncEntityType.AssetFaceV2]: SyncAssetFaceV2; [SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1; [SyncEntityType.UserMetadataV1]: SyncUserMetadataV1; [SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index 44b2f564ab..802b3c96e0 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -732,6 +732,7 @@ export enum SyncRequestType { UsersV1 = 'UsersV1', PeopleV1 = 'PeopleV1', AssetFacesV1 = 'AssetFacesV1', + AssetFacesV2 = 'AssetFacesV2', UserMetadataV1 = 'UserMetadataV1', } @@ -790,6 +791,7 @@ export enum SyncEntityType { PersonDeleteV1 = 'PersonDeleteV1', AssetFaceV1 = 'AssetFaceV1', + AssetFaceV2 = 'AssetFaceV2', AssetFaceDeleteV1 = 'AssetFaceDeleteV1', UserMetadataV1 = 'UserMetadataV1', diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index f817ad57b3..68a85e4c0f 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -540,6 +540,8 @@ select "boundingBoxX2", "boundingBoxY2", "sourceType", + "isVisible", + "asset_face"."deletedAt", "asset_face"."updateId" from "asset_face" as "asset_face" diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 511d7b589f..f851038dc6 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -479,6 +479,8 @@ class AssetFaceSync extends BaseSync { 'boundingBoxX2', 'boundingBoxY2', 'sourceType', + 'isVisible', + 'asset_face.deletedAt', 'asset_face.updateId', ]) .leftJoin('asset', 'asset.id', 'asset_face.assetId') diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index f354a71791..76fd129f50 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -12,6 +12,7 @@ import { AssetFullSyncDto, SyncAckDeleteDto, SyncAckSetDto, + syncAssetFaceV2ToV1, SyncAssetV1, SyncItem, SyncStreamDto, @@ -85,6 +86,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.MemoryToAssetsV1, SyncRequestType.PeopleV1, SyncRequestType.AssetFacesV1, + SyncRequestType.AssetFacesV2, SyncRequestType.UserMetadataV1, SyncRequestType.AssetMetadataV1, ]; @@ -189,6 +191,7 @@ export class SyncService extends BaseService { [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap), [SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap), + [SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap), }; @@ -789,6 +792,21 @@ export class SyncService extends BaseService { const upsertType = SyncEntityType.AssetFaceV1; const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + for await (const { updateId, ...data } of upserts) { + const v1 = syncAssetFaceV2ToV1(data); + send(response, { type: upsertType, ids: [updateId], data: v1 }); + } + } + + private async syncAssetFacesV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.AssetFaceDeleteV1; + const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.AssetFaceV2; + const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { send(response, { type: upsertType, ids: [updateId], data }); } diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts index 8b4310e600..34a1e8e73c 100644 --- a/server/test/medium/specs/sync/sync-asset-face.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -97,3 +97,134 @@ describe(SyncEntityType.AssetFaceV1, () => { await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV1]); }); }); + +describe(SyncEntityType.AssetFaceV2, () => { + it('should detect and sync the first asset face', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetFace.id, + assetId: asset.id, + personId: person.id, + imageWidth: assetFace.imageWidth, + imageHeight: assetFace.imageHeight, + boundingBoxX1: assetFace.boundingBoxX1, + boundingBoxY1: assetFace.boundingBoxY1, + boundingBoxX2: assetFace.boundingBoxX2, + boundingBoxY2: assetFace.boundingBoxY2, + sourceType: assetFace.sourceType, + }), + type: 'AssetFaceV2', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + }); + + it('should detect and sync a deleted asset face', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + await personRepo.deleteAssetFace(assetFace.id); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + assetFaceId: assetFace.id, + }, + type: 'AssetFaceDeleteV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + }); + + it('should not sync an asset face or asset face delete for an unrelated user', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { user: user2 } = await ctx.newUser(); + const { session } = await ctx.newSession({ userId: user2.id }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + const auth2 = factory.auth({ session, user: user2 }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV2])).toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetFaceV2 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + + await personRepo.deleteAssetFace(assetFace.id); + + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV2])).toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetFaceDeleteV1 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + }); + + it('should contain the deletedAt and isVisible fields in AssetFaceV2', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + + let response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetFace.id, + assetId: asset.id, + personId: person.id, + imageWidth: assetFace.imageWidth, + imageHeight: assetFace.imageHeight, + boundingBoxX1: assetFace.boundingBoxX1, + boundingBoxY1: assetFace.boundingBoxY1, + boundingBoxX2: assetFace.boundingBoxX2, + boundingBoxY2: assetFace.boundingBoxY2, + sourceType: assetFace.sourceType, + deletedAt: null, + isVisible: true, + }), + type: 'AssetFaceV2', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + + await personRepo.deleteAssetFace(assetFace.id); + + response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + assetFaceId: assetFace.id, + }, + type: 'AssetFaceDeleteV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); + }); +}); From 16c1c3c780cacfb5548a27516d5ff06dfba64eb4 Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 23 Feb 2026 15:51:32 +0100 Subject: [PATCH 112/143] fix(mobile): join local on archived timeline (#26387) --- mobile/lib/infrastructure/repositories/timeline.repository.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 4ddb679a0f..e39dc10a8a 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -323,6 +323,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive), groupBy: groupBy, origin: TimelineOrigin.archive, + joinLocal: true, ); TimelineQuery locked(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder( From 60dafecdc9cae9cb63c119d04922dfc4a1f2a1c3 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 23 Feb 2026 11:56:20 -0500 Subject: [PATCH 113/143] refactor: thumbnail components (#26379) --- e2e/src/specs/web/shared-link.e2e-spec.ts | 3 +- .../ui/specs/timeline/timeline.e2e-spec.ts | 4 +- e2e/src/ui/specs/timeline/utils.ts | 4 +- web/src/lib/components/Image.spec.ts | 87 +++++++ web/src/lib/components/Image.svelte | 54 +++++ .../lib/components/assets/broken-asset.svelte | 8 +- .../assets/thumbnail/image-thumbnail.spec.ts | 89 +++++++ .../assets/thumbnail/image-thumbnail.svelte | 53 ++-- .../assets/thumbnail/thumbnail.svelte | 228 +++++++++--------- .../assets/thumbnail/video-thumbnail.svelte | 5 +- 10 files changed, 383 insertions(+), 152 deletions(-) create mode 100644 web/src/lib/components/Image.spec.ts create mode 100644 web/src/lib/components/Image.svelte create mode 100644 web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts diff --git a/e2e/src/specs/web/shared-link.e2e-spec.ts b/e2e/src/specs/web/shared-link.e2e-spec.ts index 017bc0fcb2..f6d1ec98d4 100644 --- a/e2e/src/specs/web/shared-link.e2e-spec.ts +++ b/e2e/src/specs/web/shared-link.e2e-spec.ts @@ -45,8 +45,7 @@ test.describe('Shared Links', () => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.locator(`[data-asset-id="${asset.id}"]`).hover(); - await page.waitForSelector('[data-group] svg'); - await page.getByRole('checkbox').click(); + await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`); await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]); }); diff --git a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts index 9408f6079a..6a7ce82672 100644 --- a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts +++ b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts @@ -438,7 +438,7 @@ test.describe('Timeline', () => { const asset = getAsset(timelineRestData, album.assetIds[0])!; await pageUtils.goToAsset(page, asset.fileCreatedAt); await thumbnailUtils.expectInViewport(page, asset.id); - await thumbnailUtils.expectSelectedReadonly(page, asset.id); + await thumbnailUtils.expectSelectedDisabled(page, asset.id); }); test('Add photos to album', async ({ page }) => { const album = timelineRestData.album; @@ -447,7 +447,7 @@ test.describe('Timeline', () => { const asset = getAsset(timelineRestData, album.assetIds[0])!; await pageUtils.goToAsset(page, asset.fileCreatedAt); await thumbnailUtils.expectInViewport(page, asset.id); - await thumbnailUtils.expectSelectedReadonly(page, asset.id); + await thumbnailUtils.expectSelectedDisabled(page, asset.id); await pageUtils.selectDay(page, 'Tue, Feb 27, 2024'); const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => { const requestJson = request.postDataJSON(); diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index e3799a7c3b..d3e4e5f7ec 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -102,9 +102,9 @@ export const thumbnailUtils = { async expectThumbnailIsNotArchive(page: Page, assetId: string) { await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0); }, - async expectSelectedReadonly(page: Page, assetId: string) { + async expectSelectedDisabled(page: Page, assetId: string) { await expect( - page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`), + page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected][data-disabled]`), ).toBeVisible(); }, async expectTimelineHasOnScreenAssets(page: Page) { diff --git a/web/src/lib/components/Image.spec.ts b/web/src/lib/components/Image.spec.ts new file mode 100644 index 0000000000..8435e1bb25 --- /dev/null +++ b/web/src/lib/components/Image.spec.ts @@ -0,0 +1,87 @@ +import Image from '$lib/components/Image.svelte'; +import { cancelImageUrl } from '$lib/utils/sw-messaging'; +import { fireEvent, render } from '@testing-library/svelte'; + +vi.mock('$lib/utils/sw-messaging', () => ({ + cancelImageUrl: vi.fn(), +})); + +describe('Image component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders an img element when src is provided', () => { + const { baseElement } = render(Image, { src: '/test.jpg', alt: 'test' }); + const img = baseElement.querySelector('img'); + expect(img).not.toBeNull(); + expect(img!.getAttribute('src')).toBe('/test.jpg'); + }); + + it('does not render an img element when src is undefined', () => { + const { baseElement } = render(Image, { src: undefined }); + const img = baseElement.querySelector('img'); + expect(img).toBeNull(); + }); + + it('calls onStart when src is set', () => { + const onStart = vi.fn(); + render(Image, { src: '/test.jpg', onStart }); + expect(onStart).toHaveBeenCalledOnce(); + }); + + it('calls onLoad when image loads', async () => { + const onLoad = vi.fn(); + const { baseElement } = render(Image, { src: '/test.jpg', onLoad }); + const img = baseElement.querySelector('img')!; + await fireEvent.load(img); + expect(onLoad).toHaveBeenCalledOnce(); + }); + + it('calls onError when image fails to load', async () => { + const onError = vi.fn(); + const { baseElement } = render(Image, { src: '/test.jpg', onError }); + const img = baseElement.querySelector('img')!; + await fireEvent.error(img); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(onError.mock.calls[0][0].message).toBe('Failed to load image: /test.jpg'); + }); + + it('calls cancelImageUrl on unmount', () => { + const { unmount } = render(Image, { src: '/test.jpg' }); + expect(cancelImageUrl).not.toHaveBeenCalled(); + unmount(); + expect(cancelImageUrl).toHaveBeenCalledWith('/test.jpg'); + }); + + it('does not call onLoad after unmount', async () => { + const onLoad = vi.fn(); + const { baseElement, unmount } = render(Image, { src: '/test.jpg', onLoad }); + const img = baseElement.querySelector('img')!; + unmount(); + await fireEvent.load(img); + expect(onLoad).not.toHaveBeenCalled(); + }); + + it('does not call onError after unmount', async () => { + const onError = vi.fn(); + const { baseElement, unmount } = render(Image, { src: '/test.jpg', onError }); + const img = baseElement.querySelector('img')!; + unmount(); + await fireEvent.error(img); + expect(onError).not.toHaveBeenCalled(); + }); + + it('passes through additional HTML attributes', () => { + const { baseElement } = render(Image, { + src: '/test.jpg', + alt: 'test alt', + class: 'my-class', + draggable: false, + }); + const img = baseElement.querySelector('img')!; + expect(img.getAttribute('alt')).toBe('test alt'); + expect(img.getAttribute('draggable')).toBe('false'); + }); +}); diff --git a/web/src/lib/components/Image.svelte b/web/src/lib/components/Image.svelte new file mode 100644 index 0000000000..801a466ca8 --- /dev/null +++ b/web/src/lib/components/Image.svelte @@ -0,0 +1,54 @@ + + +{#if capturedSource} + {#key capturedSource} + + {/key} +{/if} diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte index a15a787e64..f66e80ef6d 100644 --- a/web/src/lib/components/assets/broken-asset.svelte +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -2,9 +2,10 @@ import { Icon } from '@immich/ui'; import { mdiImageBrokenVariant } from '@mdi/js'; import { t } from 'svelte-i18n'; + import type { ClassValue } from 'svelte/elements'; interface Props { - class?: string; + class?: ClassValue; hideMessage?: boolean; width?: string | undefined; height?: string | undefined; @@ -14,7 +15,10 @@
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts new file mode 100644 index 0000000000..04835e9209 --- /dev/null +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts @@ -0,0 +1,89 @@ +import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; +import { fireEvent, render } from '@testing-library/svelte'; + +vi.mock('$lib/utils/sw-messaging', () => ({ + cancelImageUrl: vi.fn(), +})); + +describe('ImageThumbnail component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders an img element with correct attributes', () => { + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + }); + const img = baseElement.querySelector('img'); + expect(img).not.toBeNull(); + expect(img!.getAttribute('src')).toBe('/test-thumbnail.jpg'); + expect(img!.getAttribute('alt')).toBe(''); + }); + + it('shows BrokenAsset on error', async () => { + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + }); + const img = baseElement.querySelector('img')!; + await fireEvent.error(img); + + expect(baseElement.querySelector('img')).toBeNull(); + expect(baseElement.querySelector('span')?.textContent).toEqual('error_loading_image'); + }); + + it('calls onComplete with false on successful load', async () => { + const onComplete = vi.fn(); + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + onComplete, + }); + const img = baseElement.querySelector('img')!; + await fireEvent.load(img); + expect(onComplete).toHaveBeenCalledWith(false); + }); + + it('calls onComplete with true on error', async () => { + const onComplete = vi.fn(); + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + onComplete, + }); + const img = baseElement.querySelector('img')!; + await fireEvent.error(img); + expect(onComplete).toHaveBeenCalledWith(true); + }); + + it('applies hidden styles when hidden is true', () => { + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + hidden: true, + }); + const img = baseElement.querySelector('img')!; + const style = img.getAttribute('style') ?? ''; + expect(style).toContain('grayscale'); + expect(style).toContain('opacity'); + }); + + it('sets alt text after loading', async () => { + const { baseElement } = render(ImageThumbnail, { + url: '/test-thumbnail.jpg', + altText: 'Test image', + widthStyle: '200px', + }); + const img = baseElement.querySelector('img')!; + expect(img.getAttribute('alt')).toBe(''); + + await fireEvent.load(img); + expect(img.getAttribute('alt')).toBe('Test image'); + }); +}); diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index a1dd22f44f..a54ad911fd 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,9 +1,8 @@ {#if errored} - + {:else} - {loaded {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 5604e6f59d..2b5e9cdf93 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -196,13 +196,19 @@ document.removeEventListener('pointermove', moveHandler, true); }; }); + const backgroundColorClass = $derived.by(() => { + if (loaded && !selected) { + return 'bg-transparent'; + } + if (disabled) { + return 'bg-gray-300'; + } + return 'dark:bg-neutral-700 bg-neutral-200'; + });
- -
-
- -
- - {#if !usingMobileDevice && !disabled} -
- {/if} - - - {#if dimmed && !mouseOver} -
- {/if} - - - {#if !authManager.isSharedLink && asset.isFavorite} -
- -
- {/if} - - {#if !!assetOwner} -
-

- {assetOwner.name} -

-
- {/if} - - {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive} -
- -
- {/if} - - {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR} -
- - - -
- {/if} - - {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')} -
- - - -
- {/if} - - - {#if asset.stack && showStackedIcon} -
- -

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

- -
-
- {/if} -
- - - {#if !usingMobileDevice && mouseOver && !disableLinkMouseOver} - evt.preventDefault()} - tabindex={-1} - aria-label="Thumbnail URL" - > - - {/if} - ((loaded = true), (thumbError = errored))} /> {#if asset.isVideo} -
+
{:else if asset.isImage && asset.livePhotoVideoId} -
+
{/if} + + +
+ + {#if !usingMobileDevice && !disabled} +
+ {/if} + + + {#if dimmed && !mouseOver} +
+ {/if} + + + {#if !authManager.isSharedLink && asset.isFavorite} +
+ +
+ {/if} + + {#if !!assetOwner} +
+

+ {assetOwner.name} +

+
+ {/if} + + {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive} +
+ +
+ {/if} + + {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR} +
+ + + +
+ {/if} + + {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')} +
+ + + +
+ {/if} + + + {#if asset.stack && showStackedIcon} +
+ +

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

+ +
+
+ {/if} +
+ + + {#if !usingMobileDevice && mouseOver && !disableLinkMouseOver} + evt.preventDefault()} + tabindex={-1} + aria-label="Thumbnail URL" + > + + {/if}
{#if selectionCandidate}
@@ -411,7 +418,7 @@
- - diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 222fa7a8ec..28b7ef62ff 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -2,6 +2,7 @@ import { Icon, LoadingSpinner } from '@immich/ui'; import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import { Duration } from 'luxon'; + import type { ClassValue } from 'svelte/elements'; interface Props { url: string; @@ -12,6 +13,7 @@ curve?: boolean; playIcon?: string; pauseIcon?: string; + class?: ClassValue; } let { @@ -23,6 +25,7 @@ curve = false, playIcon = mdiPlayCircleOutline, pauseIcon = mdiPauseCircleOutline, + class: className = undefined, }: Props = $props(); let remainingSeconds = $state(durationInSeconds); @@ -57,7 +60,7 @@ {#if enablePlayback}