Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-02-25 11:53:18 +00:00
379 changed files with 14057 additions and 12369 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
+4 -4
View File
@@ -13,7 +13,7 @@
"cli" "cli"
], ],
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.8.0", "@eslint/js": "^10.0.0",
"@immich/sdk": "workspace:*", "@immich/sdk": "workspace:*",
"@types/byte-size": "^8.1.0", "@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
@@ -25,11 +25,11 @@
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"eslint": "^9.14.0", "eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0", "eslint-plugin-unicorn": "^63.0.0",
"globals": "^16.0.0", "globals": "^17.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
+4
View File
@@ -80,6 +80,10 @@ There is an automatic scan job that is scheduled to run once a day. Its schedule
This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page.
### Deleting a Library
When deleting an external library, all assets inside are immediately deleted along with the library. Note that while a library can take a long time to fully delete in the background, it is immediately removed from the library list. If the deletion process is interrupted (for example, due to server restart), it will be cleaned up in the next nightly cron job. The cleanup process can also be manually initiated by clicking the "Scan All Libraries" button in the library list.
## Usage ## Usage
Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add: Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add:
+5 -5
View File
@@ -24,7 +24,7 @@
"author": "", "author": "",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.8.0", "@eslint/js": "^10.0.0",
"@faker-js/faker": "^10.1.0", "@faker-js/faker": "^10.1.0",
"@immich/cli": "workspace:*", "@immich/cli": "workspace:*",
"@immich/e2e-auth-server": "workspace:*", "@immich/e2e-auth-server": "workspace:*",
@@ -37,12 +37,12 @@
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^9.14.0", "eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0", "eslint-plugin-unicorn": "^63.0.0",
"exiftool-vendored": "^34.3.0", "exiftool-vendored": "^35.0.0",
"globals": "^16.0.0", "globals": "^17.0.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"pg": "^8.11.3", "pg": "^8.11.3",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
+1 -2
View File
@@ -45,8 +45,7 @@ test.describe('Shared Links', () => {
await page.goto(`/share/${sharedLink.key}`); await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.locator(`[data-asset-id="${asset.id}"]`).hover(); await page.locator(`[data-asset-id="${asset.id}"]`).hover();
await page.waitForSelector('[data-group] svg'); await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`);
await page.getByRole('checkbox').click();
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]); await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
}); });
@@ -438,7 +438,7 @@ test.describe('Timeline', () => {
const asset = getAsset(timelineRestData, album.assetIds[0])!; const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt); await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id); 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 }) => { test('Add photos to album', async ({ page }) => {
const album = timelineRestData.album; const album = timelineRestData.album;
@@ -447,7 +447,7 @@ test.describe('Timeline', () => {
const asset = getAsset(timelineRestData, album.assetIds[0])!; const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt); await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id); 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'); await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => { const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
const requestJson = request.postDataJSON(); const requestJson = request.postDataJSON();
+2 -2
View File
@@ -102,9 +102,9 @@ export const thumbnailUtils = {
async expectThumbnailIsNotArchive(page: Page, assetId: string) { async expectThumbnailIsNotArchive(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0); 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( 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(); ).toBeVisible();
}, },
async expectTimelineHasOnScreenAssets(page: Page) { async expectTimelineHasOnScreenAssets(page: Page) {
+1
View File
@@ -2322,6 +2322,7 @@
"unstack_action_prompt": "{count} unstacked", "unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"unsupported_field_type": "Unsupported field type", "unsupported_field_type": "Unsupported field type",
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
"untagged": "Untagged", "untagged": "Untagged",
"untitled_workflow": "Untitled workflow", "untitled_workflow": "Untitled workflow",
"up_next": "Up next", "up_next": "Up next",
+3 -17
View File
@@ -654,18 +654,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/25/fab23259a52ece5670dcb8452e1af34b89e6135ecc17cd4b54b4b479eac6/fsspec-2023.12.2-py3-none-any.whl", hash = "sha256:d800d87f72189a745fa3d6b033b9dc4a34ad069f60ca60b943a63599f5501960", size = 168979, upload-time = "2023-12-11T21:19:52.446Z" }, { url = "https://files.pythonhosted.org/packages/70/25/fab23259a52ece5670dcb8452e1af34b89e6135ecc17cd4b54b4b479eac6/fsspec-2023.12.2-py3-none-any.whl", hash = "sha256:d800d87f72189a745fa3d6b033b9dc4a34ad069f60ca60b943a63599f5501960", size = 168979, upload-time = "2023-12-11T21:19:52.446Z" },
] ]
[[package]]
name = "ftfy"
version = "6.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/d3/8650919bc3c7c6e90ee3fa7fd618bf373cbbe55dff043bd67353dbb20cd8/ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec", size = 308927, upload-time = "2024-10-26T00:50:35.149Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821, upload-time = "2024-10-26T00:50:33.425Z" },
]
[[package]] [[package]]
name = "gevent" name = "gevent"
version = "24.10.3" version = "24.10.3"
@@ -788,14 +776,14 @@ wheels = [
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "23.0.0" version = "25.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "packaging" }, { name = "packaging" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, { url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
] ]
[[package]] [[package]]
@@ -939,7 +927,6 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocache" }, { name = "aiocache" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "ftfy" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "huggingface-hub" }, { name = "huggingface-hub" },
{ name = "insightface" }, { name = "insightface" },
@@ -1018,7 +1005,6 @@ types = [
requires-dist = [ requires-dist = [
{ name = "aiocache", specifier = ">=0.12.1,<1.0" }, { name = "aiocache", specifier = ">=0.12.1,<1.0" },
{ name = "fastapi", specifier = ">=0.95.2,<1.0" }, { name = "fastapi", specifier = ">=0.95.2,<1.0" },
{ name = "ftfy", specifier = ">=6.1.1" },
{ name = "gunicorn", specifier = ">=21.1.0" }, { name = "gunicorn", specifier = ">=21.1.0" },
{ name = "huggingface-hub", specifier = ">=0.20.1,<1.0" }, { name = "huggingface-hub", specifier = ">=0.20.1,<1.0" },
{ name = "insightface", specifier = ">=0.7.3,<1.0" }, { name = "insightface", specifier = ">=0.7.3,<1.0" },
+1 -1
View File
@@ -16,7 +16,7 @@ config_roots = [
[tools] [tools]
node = "24.13.1" node = "24.13.1"
flutter = "3.35.7" flutter = "3.35.7"
pnpm = "10.29.3" pnpm = "10.30.0"
terragrunt = "0.98.0" terragrunt = "0.98.0"
opentofu = "1.11.4" opentofu = "1.11.4"
java = "21.0.2" java = "21.0.2"
File diff suppressed because one or more lines are too long
+2
View File
@@ -18,3 +18,5 @@ enum ActionSource { timeline, viewer }
enum CleanupStep { selectDate, scan, delete } enum CleanupStep { selectDate, scan, delete }
enum AssetKeepType { none, photosOnly, videosOnly } enum AssetKeepType { none, photosOnly, videosOnly }
enum AssetDateAggregation { start, end }
@@ -43,8 +43,8 @@ class RemoteAlbumService {
AlbumSortMode.title => albums.sortedBy((album) => album.name), AlbumSortMode.title => albums.sortedBy((album) => album.name),
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums), AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end),
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums), AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start),
}; };
final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder; final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder;
@@ -172,46 +172,25 @@ class RemoteAlbumService {
return _repository.getAlbumsContainingAsset(assetId); return _repository.getAlbumsContainingAsset(assetId);
} }
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async { Future<List<RemoteAlbum>> _sortByAssetDate(
// map album IDs to their newest asset dates List<RemoteAlbum> albums, {
final Map<String, Future<DateTime?>> assetTimestampFutures = {}; required AssetDateAggregation aggregation,
for (final album in albums) { }) async {
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id); if (albums.isEmpty) return [];
final albumIds = albums.map((e) => e.id).toList();
final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation);
final albumMap = Map<String, RemoteAlbum>.fromEntries(albums.map((a) => MapEntry(a.id, a)));
final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType<RemoteAlbum>().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 return sortedAlbums;
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<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their oldest asset dates
final Map<String, Future<DateTime?>> 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;
} }
} }
@@ -68,12 +68,12 @@ class SyncStreamService {
return false; 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 value = Store.get(StoreKey.syncMigrationStatus, "[]");
final migrations = (jsonDecode(value) as List).cast<String>(); final migrations = (jsonDecode(value) as List).cast<String>();
int previousLength = migrations.length; int previousLength = migrations.length;
await _runPreSyncTasks(migrations, semVer); await _runPreSyncTasks(migrations, serverSemVer);
if (migrations.length != previousLength) { if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations"); _logger.info("Updated pre-sync migration status: $migrations");
@@ -82,10 +82,14 @@ class SyncStreamService {
// Start the sync stream and handle events // Start the sync stream and handle events
bool shouldReset = false; bool shouldReset = false;
await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true); await _syncApiRepository.streamChanges(
_handleEvents,
serverVersion: serverSemVer,
onReset: () => shouldReset = true,
);
if (shouldReset) { if (shouldReset) {
_logger.info("Resetting sync state as requested by server"); _logger.info("Resetting sync state as requested by server");
await _syncApiRepository.streamChanges(_handleEvents); await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer);
} }
previousLength = migrations.length; previousLength = migrations.length;
@@ -282,6 +286,8 @@ class SyncStreamService {
return _syncStreamRepository.deletePeopleV1(data.cast()); return _syncStreamRepository.deletePeopleV1(data.cast());
case SyncEntityType.assetFaceV1: case SyncEntityType.assetFaceV1:
return _syncStreamRepository.updateAssetFacesV1(data.cast()); return _syncStreamRepository.updateAssetFacesV1(data.cast());
case SyncEntityType.assetFaceV2:
return _syncStreamRepository.updateAssetFacesV2(data.cast());
case SyncEntityType.assetFaceDeleteV1: case SyncEntityType.assetFaceDeleteV1:
return _syncStreamRepository.deleteAssetFacesV1(data.cast()); return _syncStreamRepository.deleteAssetFacesV1(data.cast());
default: default:
@@ -28,6 +28,10 @@ class AssetFaceEntity extends Table with DriftDefaultsMixin {
TextColumn get sourceType => text()(); TextColumn get sourceType => text()();
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
DateTimeColumn get deletedAt => dateTime().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
+202 -68
View File
@@ -5,11 +5,12 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da
as i1; as i1;
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart' import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'
as i2; 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' import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i3; as i4;
import 'package:drift/internal/modular.dart' as i4; import 'package:drift/internal/modular.dart' as i5;
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i5; as i6;
typedef $$AssetFaceEntityTableCreateCompanionBuilder = typedef $$AssetFaceEntityTableCreateCompanionBuilder =
i1.AssetFaceEntityCompanion Function({ i1.AssetFaceEntityCompanion Function({
@@ -23,6 +24,8 @@ typedef $$AssetFaceEntityTableCreateCompanionBuilder =
required int boundingBoxX2, required int boundingBoxX2,
required int boundingBoxY2, required int boundingBoxY2,
required String sourceType, required String sourceType,
i0.Value<bool> isVisible,
i0.Value<DateTime?> deletedAt,
}); });
typedef $$AssetFaceEntityTableUpdateCompanionBuilder = typedef $$AssetFaceEntityTableUpdateCompanionBuilder =
i1.AssetFaceEntityCompanion Function({ i1.AssetFaceEntityCompanion Function({
@@ -36,6 +39,8 @@ typedef $$AssetFaceEntityTableUpdateCompanionBuilder =
i0.Value<int> boundingBoxX2, i0.Value<int> boundingBoxX2,
i0.Value<int> boundingBoxY2, i0.Value<int> boundingBoxY2,
i0.Value<String> sourceType, i0.Value<String> sourceType,
i0.Value<bool> isVisible,
i0.Value<DateTime?> deletedAt,
}); });
final class $$AssetFaceEntityTableReferences final class $$AssetFaceEntityTableReferences
@@ -51,29 +56,29 @@ final class $$AssetFaceEntityTableReferences
super.$_typedResult, super.$_typedResult,
); );
static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => static i4.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db) i5.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity') .resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias( .createAlias(
i0.$_aliasNameGenerator( i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db) i5.ReadDatabaseContainer(db)
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity') .resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
.assetId, .assetId,
i4.ReadDatabaseContainer( i5.ReadDatabaseContainer(
db, db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id, ).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity').id,
), ),
); );
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId { i4.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!; final $_column = $_itemColumn<String>('asset_id')!;
final manager = i3 final manager = i4
.$$RemoteAssetEntityTableTableManager( .$$RemoteAssetEntityTableTableManager(
$_db, $_db,
i4.ReadDatabaseContainer( i5.ReadDatabaseContainer(
$_db, $_db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'), ).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
) )
.filter((f) => f.id.sqlEquals($_column)); .filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_assetIdTable($_db)); final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
@@ -83,29 +88,29 @@ final class $$AssetFaceEntityTableReferences
); );
} }
static i5.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) => static i6.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db) i5.ReadDatabaseContainer(db)
.resultSet<i5.$PersonEntityTable>('person_entity') .resultSet<i6.$PersonEntityTable>('person_entity')
.createAlias( .createAlias(
i0.$_aliasNameGenerator( i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db) i5.ReadDatabaseContainer(db)
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity') .resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
.personId, .personId,
i4.ReadDatabaseContainer( i5.ReadDatabaseContainer(
db, db,
).resultSet<i5.$PersonEntityTable>('person_entity').id, ).resultSet<i6.$PersonEntityTable>('person_entity').id,
), ),
); );
i5.$$PersonEntityTableProcessedTableManager? get personId { i6.$$PersonEntityTableProcessedTableManager? get personId {
final $_column = $_itemColumn<String>('person_id'); final $_column = $_itemColumn<String>('person_id');
if ($_column == null) return null; if ($_column == null) return null;
final manager = i5 final manager = i6
.$$PersonEntityTableTableManager( .$$PersonEntityTableTableManager(
$_db, $_db,
i4.ReadDatabaseContainer( i5.ReadDatabaseContainer(
$_db, $_db,
).resultSet<i5.$PersonEntityTable>('person_entity'), ).resultSet<i6.$PersonEntityTable>('person_entity'),
) )
.filter((f) => f.id.sqlEquals($_column)); .filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_personIdTable($_db)); final item = $_typedResult.readTableOrNull(_personIdTable($_db));
@@ -165,24 +170,34 @@ class $$AssetFaceEntityTableFilterComposer
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
); );
i3.$$RemoteAssetEntityTableFilterComposer get assetId { i0.ColumnFilters<bool> get isVisible => $composableBuilder(
final i3.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( column: $table.isVisible,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get deletedAt => $composableBuilder(
column: $table.deletedAt,
builder: (column) => i0.ColumnFilters(column),
);
i4.$$RemoteAssetEntityTableFilterComposer get assetId {
final i4.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this, composer: this,
getCurrentColumn: (t) => t.assetId, getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer( referencedTable: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'), ).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id, getReferencedColumn: (t) => t.id,
builder: builder:
( (
joinBuilder, { joinBuilder, {
$addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer, $removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableFilterComposer( }) => i4.$$RemoteAssetEntityTableFilterComposer(
$db: $db, $db: $db,
$table: i4.ReadDatabaseContainer( $table: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'), ).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder, joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer:
@@ -192,24 +207,24 @@ class $$AssetFaceEntityTableFilterComposer
return composer; return composer;
} }
i5.$$PersonEntityTableFilterComposer get personId { i6.$$PersonEntityTableFilterComposer get personId {
final i5.$$PersonEntityTableFilterComposer composer = $composerBuilder( final i6.$$PersonEntityTableFilterComposer composer = $composerBuilder(
composer: this, composer: this,
getCurrentColumn: (t) => t.personId, getCurrentColumn: (t) => t.personId,
referencedTable: i4.ReadDatabaseContainer( referencedTable: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i5.$PersonEntityTable>('person_entity'), ).resultSet<i6.$PersonEntityTable>('person_entity'),
getReferencedColumn: (t) => t.id, getReferencedColumn: (t) => t.id,
builder: builder:
( (
joinBuilder, { joinBuilder, {
$addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer, $removeJoinBuilderFromRootComposer,
}) => i5.$$PersonEntityTableFilterComposer( }) => i6.$$PersonEntityTableFilterComposer(
$db: $db, $db: $db,
$table: i4.ReadDatabaseContainer( $table: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i5.$PersonEntityTable>('person_entity'), ).resultSet<i6.$PersonEntityTable>('person_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder, joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer:
@@ -269,25 +284,35 @@ class $$AssetFaceEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
); );
i3.$$RemoteAssetEntityTableOrderingComposer get assetId { i0.ColumnOrderings<bool> get isVisible => $composableBuilder(
final i3.$$RemoteAssetEntityTableOrderingComposer composer = column: $table.isVisible,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get deletedAt => $composableBuilder(
column: $table.deletedAt,
builder: (column) => i0.ColumnOrderings(column),
);
i4.$$RemoteAssetEntityTableOrderingComposer get assetId {
final i4.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder( $composerBuilder(
composer: this, composer: this,
getCurrentColumn: (t) => t.assetId, getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer( referencedTable: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'), ).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id, getReferencedColumn: (t) => t.id,
builder: builder:
( (
joinBuilder, { joinBuilder, {
$addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer, $removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableOrderingComposer( }) => i4.$$RemoteAssetEntityTableOrderingComposer(
$db: $db, $db: $db,
$table: i4.ReadDatabaseContainer( $table: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'), ).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder, joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer:
@@ -297,24 +322,24 @@ class $$AssetFaceEntityTableOrderingComposer
return composer; return composer;
} }
i5.$$PersonEntityTableOrderingComposer get personId { i6.$$PersonEntityTableOrderingComposer get personId {
final i5.$$PersonEntityTableOrderingComposer composer = $composerBuilder( final i6.$$PersonEntityTableOrderingComposer composer = $composerBuilder(
composer: this, composer: this,
getCurrentColumn: (t) => t.personId, getCurrentColumn: (t) => t.personId,
referencedTable: i4.ReadDatabaseContainer( referencedTable: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i5.$PersonEntityTable>('person_entity'), ).resultSet<i6.$PersonEntityTable>('person_entity'),
getReferencedColumn: (t) => t.id, getReferencedColumn: (t) => t.id,
builder: builder:
( (
joinBuilder, { joinBuilder, {
$addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer, $removeJoinBuilderFromRootComposer,
}) => i5.$$PersonEntityTableOrderingComposer( }) => i6.$$PersonEntityTableOrderingComposer(
$db: $db, $db: $db,
$table: i4.ReadDatabaseContainer( $table: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i5.$PersonEntityTable>('person_entity'), ).resultSet<i6.$PersonEntityTable>('person_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder, joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer:
@@ -372,25 +397,31 @@ class $$AssetFaceEntityTableAnnotationComposer
builder: (column) => column, builder: (column) => column,
); );
i3.$$RemoteAssetEntityTableAnnotationComposer get assetId { i0.GeneratedColumn<bool> get isVisible =>
final i3.$$RemoteAssetEntityTableAnnotationComposer composer = $composableBuilder(column: $table.isVisible, builder: (column) => column);
i0.GeneratedColumn<DateTime> get deletedAt =>
$composableBuilder(column: $table.deletedAt, builder: (column) => column);
i4.$$RemoteAssetEntityTableAnnotationComposer get assetId {
final i4.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder( $composerBuilder(
composer: this, composer: this,
getCurrentColumn: (t) => t.assetId, getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer( referencedTable: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'), ).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id, getReferencedColumn: (t) => t.id,
builder: builder:
( (
joinBuilder, { joinBuilder, {
$addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer, $removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableAnnotationComposer( }) => i4.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db, $db: $db,
$table: i4.ReadDatabaseContainer( $table: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'), ).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder, joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer:
@@ -400,24 +431,24 @@ class $$AssetFaceEntityTableAnnotationComposer
return composer; return composer;
} }
i5.$$PersonEntityTableAnnotationComposer get personId { i6.$$PersonEntityTableAnnotationComposer get personId {
final i5.$$PersonEntityTableAnnotationComposer composer = $composerBuilder( final i6.$$PersonEntityTableAnnotationComposer composer = $composerBuilder(
composer: this, composer: this,
getCurrentColumn: (t) => t.personId, getCurrentColumn: (t) => t.personId,
referencedTable: i4.ReadDatabaseContainer( referencedTable: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i5.$PersonEntityTable>('person_entity'), ).resultSet<i6.$PersonEntityTable>('person_entity'),
getReferencedColumn: (t) => t.id, getReferencedColumn: (t) => t.id,
builder: builder:
( (
joinBuilder, { joinBuilder, {
$addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer, $removeJoinBuilderFromRootComposer,
}) => i5.$$PersonEntityTableAnnotationComposer( }) => i6.$$PersonEntityTableAnnotationComposer(
$db: $db, $db: $db,
$table: i4.ReadDatabaseContainer( $table: i5.ReadDatabaseContainer(
$db, $db,
).resultSet<i5.$PersonEntityTable>('person_entity'), ).resultSet<i6.$PersonEntityTable>('person_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder, joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer:
@@ -468,6 +499,8 @@ class $$AssetFaceEntityTableTableManager
i0.Value<int> boundingBoxX2 = const i0.Value.absent(), i0.Value<int> boundingBoxX2 = const i0.Value.absent(),
i0.Value<int> boundingBoxY2 = const i0.Value.absent(), i0.Value<int> boundingBoxY2 = const i0.Value.absent(),
i0.Value<String> sourceType = const i0.Value.absent(), i0.Value<String> sourceType = const i0.Value.absent(),
i0.Value<bool> isVisible = const i0.Value.absent(),
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
}) => i1.AssetFaceEntityCompanion( }) => i1.AssetFaceEntityCompanion(
id: id, id: id,
assetId: assetId, assetId: assetId,
@@ -479,6 +512,8 @@ class $$AssetFaceEntityTableTableManager
boundingBoxX2: boundingBoxX2, boundingBoxX2: boundingBoxX2,
boundingBoxY2: boundingBoxY2, boundingBoxY2: boundingBoxY2,
sourceType: sourceType, sourceType: sourceType,
isVisible: isVisible,
deletedAt: deletedAt,
), ),
createCompanionCallback: createCompanionCallback:
({ ({
@@ -492,6 +527,8 @@ class $$AssetFaceEntityTableTableManager
required int boundingBoxX2, required int boundingBoxX2,
required int boundingBoxY2, required int boundingBoxY2,
required String sourceType, required String sourceType,
i0.Value<bool> isVisible = const i0.Value.absent(),
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
}) => i1.AssetFaceEntityCompanion.insert( }) => i1.AssetFaceEntityCompanion.insert(
id: id, id: id,
assetId: assetId, assetId: assetId,
@@ -503,6 +540,8 @@ class $$AssetFaceEntityTableTableManager
boundingBoxX2: boundingBoxX2, boundingBoxX2: boundingBoxX2,
boundingBoxY2: boundingBoxY2, boundingBoxY2: boundingBoxY2,
sourceType: sourceType, sourceType: sourceType,
isVisible: isVisible,
deletedAt: deletedAt,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
.map( .map(
@@ -709,6 +748,33 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
type: i0.DriftSqlType.string, type: i0.DriftSqlType.string,
requiredDuringInsert: true, requiredDuringInsert: true,
); );
static const i0.VerificationMeta _isVisibleMeta = const i0.VerificationMeta(
'isVisible',
);
@override
late final i0.GeneratedColumn<bool> isVisible = i0.GeneratedColumn<bool>(
'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<DateTime> deletedAt =
i0.GeneratedColumn<DateTime>(
'deleted_at',
aliasedName,
true,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
);
@override @override
List<i0.GeneratedColumn> get $columns => [ List<i0.GeneratedColumn> get $columns => [
id, id,
@@ -721,6 +787,8 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
boundingBoxX2, boundingBoxX2,
boundingBoxY2, boundingBoxY2,
sourceType, sourceType,
isVisible,
deletedAt,
]; ];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@@ -824,6 +892,18 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
} else if (isInserting) { } else if (isInserting) {
context.missing(_sourceTypeMeta); 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; return context;
} }
@@ -873,6 +953,14 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
i0.DriftSqlType.string, i0.DriftSqlType.string,
data['${effectivePrefix}source_type'], 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 boundingBoxX2;
final int boundingBoxY2; final int boundingBoxY2;
final String sourceType; final String sourceType;
final bool isVisible;
final DateTime? deletedAt;
const AssetFaceEntityData({ const AssetFaceEntityData({
required this.id, required this.id,
required this.assetId, required this.assetId,
@@ -910,6 +1000,8 @@ class AssetFaceEntityData extends i0.DataClass
required this.boundingBoxX2, required this.boundingBoxX2,
required this.boundingBoxY2, required this.boundingBoxY2,
required this.sourceType, required this.sourceType,
required this.isVisible,
this.deletedAt,
}); });
@override @override
Map<String, i0.Expression> toColumns(bool nullToAbsent) { Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -926,6 +1018,10 @@ class AssetFaceEntityData extends i0.DataClass
map['bounding_box_x2'] = i0.Variable<int>(boundingBoxX2); map['bounding_box_x2'] = i0.Variable<int>(boundingBoxX2);
map['bounding_box_y2'] = i0.Variable<int>(boundingBoxY2); map['bounding_box_y2'] = i0.Variable<int>(boundingBoxY2);
map['source_type'] = i0.Variable<String>(sourceType); map['source_type'] = i0.Variable<String>(sourceType);
map['is_visible'] = i0.Variable<bool>(isVisible);
if (!nullToAbsent || deletedAt != null) {
map['deleted_at'] = i0.Variable<DateTime>(deletedAt);
}
return map; return map;
} }
@@ -945,6 +1041,8 @@ class AssetFaceEntityData extends i0.DataClass
boundingBoxX2: serializer.fromJson<int>(json['boundingBoxX2']), boundingBoxX2: serializer.fromJson<int>(json['boundingBoxX2']),
boundingBoxY2: serializer.fromJson<int>(json['boundingBoxY2']), boundingBoxY2: serializer.fromJson<int>(json['boundingBoxY2']),
sourceType: serializer.fromJson<String>(json['sourceType']), sourceType: serializer.fromJson<String>(json['sourceType']),
isVisible: serializer.fromJson<bool>(json['isVisible']),
deletedAt: serializer.fromJson<DateTime?>(json['deletedAt']),
); );
} }
@override @override
@@ -961,6 +1059,8 @@ class AssetFaceEntityData extends i0.DataClass
'boundingBoxX2': serializer.toJson<int>(boundingBoxX2), 'boundingBoxX2': serializer.toJson<int>(boundingBoxX2),
'boundingBoxY2': serializer.toJson<int>(boundingBoxY2), 'boundingBoxY2': serializer.toJson<int>(boundingBoxY2),
'sourceType': serializer.toJson<String>(sourceType), 'sourceType': serializer.toJson<String>(sourceType),
'isVisible': serializer.toJson<bool>(isVisible),
'deletedAt': serializer.toJson<DateTime?>(deletedAt),
}; };
} }
@@ -975,6 +1075,8 @@ class AssetFaceEntityData extends i0.DataClass
int? boundingBoxX2, int? boundingBoxX2,
int? boundingBoxY2, int? boundingBoxY2,
String? sourceType, String? sourceType,
bool? isVisible,
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
}) => i1.AssetFaceEntityData( }) => i1.AssetFaceEntityData(
id: id ?? this.id, id: id ?? this.id,
assetId: assetId ?? this.assetId, assetId: assetId ?? this.assetId,
@@ -986,6 +1088,8 @@ class AssetFaceEntityData extends i0.DataClass
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
sourceType: sourceType ?? this.sourceType, sourceType: sourceType ?? this.sourceType,
isVisible: isVisible ?? this.isVisible,
deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt,
); );
AssetFaceEntityData copyWithCompanion(i1.AssetFaceEntityCompanion data) { AssetFaceEntityData copyWithCompanion(i1.AssetFaceEntityCompanion data) {
return AssetFaceEntityData( return AssetFaceEntityData(
@@ -1013,6 +1117,8 @@ class AssetFaceEntityData extends i0.DataClass
sourceType: data.sourceType.present sourceType: data.sourceType.present
? data.sourceType.value ? data.sourceType.value
: this.sourceType, : 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('boundingBoxY1: $boundingBoxY1, ')
..write('boundingBoxX2: $boundingBoxX2, ') ..write('boundingBoxX2: $boundingBoxX2, ')
..write('boundingBoxY2: $boundingBoxY2, ') ..write('boundingBoxY2: $boundingBoxY2, ')
..write('sourceType: $sourceType') ..write('sourceType: $sourceType, ')
..write('isVisible: $isVisible, ')
..write('deletedAt: $deletedAt')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -1045,6 +1153,8 @@ class AssetFaceEntityData extends i0.DataClass
boundingBoxX2, boundingBoxX2,
boundingBoxY2, boundingBoxY2,
sourceType, sourceType,
isVisible,
deletedAt,
); );
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -1059,7 +1169,9 @@ class AssetFaceEntityData extends i0.DataClass
other.boundingBoxY1 == this.boundingBoxY1 && other.boundingBoxY1 == this.boundingBoxY1 &&
other.boundingBoxX2 == this.boundingBoxX2 && other.boundingBoxX2 == this.boundingBoxX2 &&
other.boundingBoxY2 == this.boundingBoxY2 && other.boundingBoxY2 == this.boundingBoxY2 &&
other.sourceType == this.sourceType); other.sourceType == this.sourceType &&
other.isVisible == this.isVisible &&
other.deletedAt == this.deletedAt);
} }
class AssetFaceEntityCompanion class AssetFaceEntityCompanion
@@ -1074,6 +1186,8 @@ class AssetFaceEntityCompanion
final i0.Value<int> boundingBoxX2; final i0.Value<int> boundingBoxX2;
final i0.Value<int> boundingBoxY2; final i0.Value<int> boundingBoxY2;
final i0.Value<String> sourceType; final i0.Value<String> sourceType;
final i0.Value<bool> isVisible;
final i0.Value<DateTime?> deletedAt;
const AssetFaceEntityCompanion({ const AssetFaceEntityCompanion({
this.id = const i0.Value.absent(), this.id = const i0.Value.absent(),
this.assetId = const i0.Value.absent(), this.assetId = const i0.Value.absent(),
@@ -1085,6 +1199,8 @@ class AssetFaceEntityCompanion
this.boundingBoxX2 = const i0.Value.absent(), this.boundingBoxX2 = const i0.Value.absent(),
this.boundingBoxY2 = const i0.Value.absent(), this.boundingBoxY2 = const i0.Value.absent(),
this.sourceType = const i0.Value.absent(), this.sourceType = const i0.Value.absent(),
this.isVisible = const i0.Value.absent(),
this.deletedAt = const i0.Value.absent(),
}); });
AssetFaceEntityCompanion.insert({ AssetFaceEntityCompanion.insert({
required String id, required String id,
@@ -1097,6 +1213,8 @@ class AssetFaceEntityCompanion
required int boundingBoxX2, required int boundingBoxX2,
required int boundingBoxY2, required int boundingBoxY2,
required String sourceType, required String sourceType,
this.isVisible = const i0.Value.absent(),
this.deletedAt = const i0.Value.absent(),
}) : id = i0.Value(id), }) : id = i0.Value(id),
assetId = i0.Value(assetId), assetId = i0.Value(assetId),
imageWidth = i0.Value(imageWidth), imageWidth = i0.Value(imageWidth),
@@ -1117,6 +1235,8 @@ class AssetFaceEntityCompanion
i0.Expression<int>? boundingBoxX2, i0.Expression<int>? boundingBoxX2,
i0.Expression<int>? boundingBoxY2, i0.Expression<int>? boundingBoxY2,
i0.Expression<String>? sourceType, i0.Expression<String>? sourceType,
i0.Expression<bool>? isVisible,
i0.Expression<DateTime>? deletedAt,
}) { }) {
return i0.RawValuesInsertable({ return i0.RawValuesInsertable({
if (id != null) 'id': id, if (id != null) 'id': id,
@@ -1129,6 +1249,8 @@ class AssetFaceEntityCompanion
if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2, if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2,
if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2, if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2,
if (sourceType != null) 'source_type': sourceType, 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<int>? boundingBoxX2, i0.Value<int>? boundingBoxX2,
i0.Value<int>? boundingBoxY2, i0.Value<int>? boundingBoxY2,
i0.Value<String>? sourceType, i0.Value<String>? sourceType,
i0.Value<bool>? isVisible,
i0.Value<DateTime?>? deletedAt,
}) { }) {
return i1.AssetFaceEntityCompanion( return i1.AssetFaceEntityCompanion(
id: id ?? this.id, id: id ?? this.id,
@@ -1155,6 +1279,8 @@ class AssetFaceEntityCompanion
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
sourceType: sourceType ?? this.sourceType, sourceType: sourceType ?? this.sourceType,
isVisible: isVisible ?? this.isVisible,
deletedAt: deletedAt ?? this.deletedAt,
); );
} }
@@ -1191,6 +1317,12 @@ class AssetFaceEntityCompanion
if (sourceType.present) { if (sourceType.present) {
map['source_type'] = i0.Variable<String>(sourceType.value); map['source_type'] = i0.Variable<String>(sourceType.value);
} }
if (isVisible.present) {
map['is_visible'] = i0.Variable<bool>(isVisible.value);
}
if (deletedAt.present) {
map['deleted_at'] = i0.Variable<DateTime>(deletedAt.value);
}
return map; return map;
} }
@@ -1206,7 +1338,9 @@ class AssetFaceEntityCompanion
..write('boundingBoxY1: $boundingBoxY1, ') ..write('boundingBoxY1: $boundingBoxY1, ')
..write('boundingBoxX2: $boundingBoxX2, ') ..write('boundingBoxX2: $boundingBoxX2, ')
..write('boundingBoxY2: $boundingBoxY2, ') ..write('boundingBoxY2: $boundingBoxY2, ')
..write('sourceType: $sourceType') ..write('sourceType: $sourceType, ')
..write('isVisible: $isVisible, ')
..write('deletedAt: $deletedAt')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 19; int get schemaVersion => 20;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -226,6 +226,10 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth); await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
await m.createIndex(v19.idxStackPrimaryAssetId); 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);
},
), ),
); );
@@ -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<i1.DatabaseSchemaEntity> 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<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get personId =>
columnsByName['person_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get imageWidth =>
columnsByName['image_width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get imageHeight =>
columnsByName['image_height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get boundingBoxX1 =>
columnsByName['bounding_box_x1']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get boundingBoxY1 =>
columnsByName['bounding_box_y1']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get boundingBoxX2 =>
columnsByName['bounding_box_x2']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get boundingBoxY2 =>
columnsByName['bounding_box_y2']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get sourceType =>
columnsByName['source_type']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isVisible =>
columnsByName['is_visible']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<DateTime> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<bool> _column_102(String aliasedName) =>
i1.GeneratedColumn<bool>(
'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({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -8379,6 +8923,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17, required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18, required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19, required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -8472,6 +9017,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from18To19(migrator, schema); await from18To19(migrator, schema);
return 19; return 19;
case 19:
final schema = Schema20(database: database);
final migrator = i1.Migrator(database, schema);
await from19To20(migrator, schema);
return 20;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -8497,6 +9047,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17, required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18, required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19, required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -8517,5 +9068,6 @@ i1.OnUpgrade stepByStep({
from16To17: from16To17, from16To17: from16To17,
from17To18: from17To18, from17To18: from17To18,
from18To19: from18To19, from18To19: from18To19,
from19To20: from19To20,
), ),
); );
@@ -184,7 +184,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
} }
if (keepFavorites) { if (keepFavorites) {
whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false); whereClause =
whereClause & _db.localAssetEntity.isFavorite.equals(false) & _db.remoteAssetEntity.isFavorite.equals(false);
} }
query.where(whereClause); query.where(whereClause);
@@ -16,9 +16,15 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
} }
Future<List<DriftPerson>> getAssetPeople(String assetId) async { Future<List<DriftPerson>> getAssetPeople(String assetId) async {
final query = _db.select(_db.assetFaceEntity).join([ final query =
_db.select(_db.assetFaceEntity).join([
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)), innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
])..where(_db.assetFaceEntity.assetId.equals(assetId) & _db.personEntity.isHidden.equals(false)); ])..where(
_db.assetFaceEntity.assetId.equals(assetId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull() &
_db.personEntity.isHidden.equals(false),
);
return query.map((row) { return query.map((row) {
final person = row.readTable(_db.personEntity); final person = row.readTable(_db.personEntity);
@@ -39,7 +45,9 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
..where( ..where(
people.isHidden.equals(false) & people.isHidden.equals(false) &
assets.deletedAt.isNull() & 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()) ..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(3) | people.name.equals('').not())
..orderBy([ ..orderBy([
@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart'; 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/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.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/domain/models/user.model.dart';
@@ -321,26 +323,32 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}).watchSingleOrNull(); }).watchSingleOrNull();
} }
Future<DateTime?> getNewestAssetTimestamp(String albumId) { Future<List<String>> getSortedAlbumIds(List<String> albumIds, {required AssetDateAggregation aggregation}) async {
final query = _db.remoteAlbumAssetEntity.selectOnly() if (albumIds.isEmpty) return [];
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull(); final jsonIds = jsonEncode(albumIds);
} final sqlAgg = aggregation == AssetDateAggregation.start ? 'MIN' : 'MAX';
Future<DateTime?> getOldestAssetTimestamp(String albumId) { final rows = await _db
final query = _db.remoteAlbumAssetEntity.selectOnly() .customSelect(
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) '''
..addColumns([_db.remoteAssetEntity.localDateTime.min()]) SELECT
..join([ raae.album_id,
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)), $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<String>(jsonIds)],
readsFrom: {_db.remoteAlbumAssetEntity, _db.remoteAssetEntity},
)
.get();
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull(); return rows.map((row) => row.read<String>('album_id')).toList();
} }
Future<int> getCount() { Future<int> getCount() {
@@ -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/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -25,6 +26,7 @@ class SyncApiRepository {
Future<void> streamChanges( Future<void> streamChanges(
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, { Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, {
required SemVer serverVersion,
Function()? onReset, Function()? onReset,
int batchSize = kSyncEventBatchSize, int batchSize = kSyncEventBatchSize,
http.Client? httpClient, http.Client? httpClient,
@@ -64,7 +66,8 @@ class SyncApiRepository {
SyncRequestType.partnerStacksV1, SyncRequestType.partnerStacksV1,
SyncRequestType.userMetadataV1, SyncRequestType.userMetadataV1,
SyncRequestType.peopleV1, 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, reset: shouldReset,
).toJson(), ).toJson(),
@@ -190,6 +193,7 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.personV1: SyncPersonV1.fromJson, SyncEntityType.personV1: SyncPersonV1.fromJson,
SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson, SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson,
SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson, SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson,
SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson,
SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson, SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson,
SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson, SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson,
}; };
@@ -652,6 +652,37 @@ class SyncStreamRepository extends DriftDatabaseRepository {
} }
} }
Future<void> updateAssetFacesV2(Iterable<SyncAssetFaceV2> 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<void> deleteAssetFacesV1(Iterable<SyncAssetFaceDeleteV1> data) async { Future<void> deleteAssetFacesV1(Iterable<SyncAssetFaceDeleteV1> data) async {
try { try {
await _db.batch((batch) { await _db.batch((batch) {
@@ -323,6 +323,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive), row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
groupBy: groupBy, groupBy: groupBy,
origin: TimelineOrigin.archive, origin: TimelineOrigin.archive,
joinLocal: true,
); );
TimelineQuery locked(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder( TimelineQuery locked(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
@@ -421,7 +422,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & _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) { return query.map((row) {
@@ -446,7 +449,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & _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]) ..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]); ..orderBy([OrderingTerm.desc(dateExp)]);
@@ -476,7 +481,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & _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)]) ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset); ..limit(count, offset: offset);
@@ -109,10 +109,44 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
if (context.router.current.name == SplashScreenRoute.name) { if (context.router.current.name == SplashScreenRoute.name) {
final needBetaMigration = Store.get(StoreKey.needBetaMigration, false); final needBetaMigration = Store.get(StoreKey.needBetaMigration, false);
if (needBetaMigration) { if (needBetaMigration) {
bool migrate =
(await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("New Timeline Experience"),
content: const Text(
"The old timeline has been deprecated and will be removed in an upcoming release. Would you like to switch to the new timeline now?",
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
],
),
)) ??
false;
if (migrate != true) {
migrate =
(await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Are you sure?"),
content: const Text(
"If you choose to remain on the old timeline, you will be automatically migrated to the new timeline in an upcoming release. Would you like to switch now?",
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
],
),
)) ??
false;
}
await Store.put(StoreKey.needBetaMigration, false); await Store.put(StoreKey.needBetaMigration, false);
if (migrate) {
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)])); unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]));
return; return;
} }
}
unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute())); unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute()));
} }
@@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.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'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior: /// This delete action has the following behavior:
@@ -22,6 +23,18 @@ class DeleteTrashActionButton extends ConsumerWidget {
return; return;
} }
final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
final confirmDelete =
await showDialog<bool>(
context: context,
builder: (context) => TrashDeleteDialog(count: selectCount),
) ??
false;
if (!confirmDelete) {
return;
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source); final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
@@ -56,7 +56,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial); final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
double _snapOffset = 0.0; double _snapOffset = 0.0;
double _lastScrollOffset = 0.0;
DragStartDetails? _dragStart; DragStartDetails? _dragStart;
_DragIntent _dragIntent = _DragIntent.none; _DragIntent _dragIntent = _DragIntent.none;
@@ -95,7 +94,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _showDetails() { void _showDetails() {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return; if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
_lastScrollOffset = _proxyScrollController.offset;
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); _proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
} }
@@ -109,17 +107,15 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _onScroll() { void _onScroll() {
final offset = _proxyScrollController.offset; final offset = _proxyScrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance && offset > _lastScrollOffset) { if (offset > SnapScrollPhysics.minSnapDistance) {
_viewer.setShowingDetails(true); _viewer.setShowingDetails(true);
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) { } else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
_viewer.setShowingDetails(false); _viewer.setShowingDetails(false);
} }
_lastScrollOffset = offset;
} }
void _beginDrag(DragStartDetails details) { void _beginDrag(DragStartDetails details) {
_dragStart = details; _dragStart = details;
_lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0;
if (_viewController != null) { if (_viewController != null) {
_initialPhotoViewState = _viewController!.value; _initialPhotoViewState = _viewController!.value;
@@ -25,7 +25,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedDate = widget.person.birthDate ?? DateTime.now(); _selectedDate = widget.person.birthDate ?? DateTime(DateTime.now().year - 30, 1, 1);
} }
void saveBirthday() async { void saveBirthday() async {
@@ -90,6 +90,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
selectedDate: _selectedDate, selectedDate: _selectedDate,
locale: context.locale, locale: context.locale,
minimumDate: DateTime(1800, 1, 1), minimumDate: DateTime(1800, 1, 1),
maximumDate: DateTime.now(),
onDateTimeChanged: (DateTime value) { onDateTimeChanged: (DateTime value) {
setState(() { setState(() {
_selectedDate = value; _selectedDate = value;
@@ -29,38 +29,7 @@ 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/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
class _TimelineRestorationState extends ChangeNotifier { class Timeline extends StatelessWidget {
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({ const Timeline({
super.key, super.key,
this.topSliverWidget, this.topSliverWidget,
@@ -90,68 +59,38 @@ class Timeline extends StatefulWidget {
final bool readOnly; final bool readOnly;
final bool persistentBottomBar; final bool persistentBottomBar;
@override
State<Timeline> createState() => _TimelineState();
}
class _TimelineState extends State<Timeline> {
double? _lastWidth;
late final _TimelineRestorationState _restorationState;
@override
void initState() {
super.initState();
_restorationState = _TimelineRestorationState();
}
@override
void dispose() {
_restorationState.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(), floatingActionButton: const DownloadStatusFloatingButton(),
body: LayoutBuilder( body: LayoutBuilder(
builder: (_, constraints) { builder: (_, constraints) => ProviderScope(
if (_lastWidth != null && _lastWidth != constraints.maxWidth) {
_restorationState.setShouldRestoreAssetPosition(true);
}
_lastWidth = constraints.maxWidth;
return _TimelineRestorationProvider(
notifier: _restorationState,
child: ProviderScope(
key: ValueKey(_lastWidth),
overrides: [ overrides: [
timelineArgsProvider.overrideWith( timelineArgsProvider.overrideWith(
(ref) => TimelineArgs( (ref) => TimelineArgs(
maxWidth: constraints.maxWidth, maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight, maxHeight: constraints.maxHeight,
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
showStorageIndicator: widget.showStorageIndicator, showStorageIndicator: showStorageIndicator,
withStack: widget.withStack, withStack: withStack,
groupBy: widget.groupBy, groupBy: groupBy,
), ),
), ),
if (widget.readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
], ],
child: _SliverTimeline( child: _SliverTimeline(
key: const ValueKey('_sliver_timeline'), topSliverWidget: topSliverWidget,
topSliverWidget: widget.topSliverWidget, topSliverWidgetHeight: topSliverWidgetHeight,
topSliverWidgetHeight: widget.topSliverWidgetHeight, appBar: appBar,
appBar: widget.appBar, bottomSheet: bottomSheet,
bottomSheet: widget.bottomSheet, withScrubber: withScrubber,
withScrubber: widget.withScrubber, persistentBottomBar: persistentBottomBar,
persistentBottomBar: widget.persistentBottomBar, snapToMonth: snapToMonth,
snapToMonth: widget.snapToMonth, initialScrollOffset: initialScrollOffset,
initialScrollOffset: widget.initialScrollOffset, maxWidth: constraints.maxWidth,
), ),
), ),
);
},
), ),
); );
} }
@@ -170,7 +109,6 @@ class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
class _SliverTimeline extends ConsumerStatefulWidget { class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({ const _SliverTimeline({
super.key,
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.appBar, this.appBar,
@@ -179,6 +117,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
this.persistentBottomBar = false, this.persistentBottomBar = false,
this.snapToMonth = true, this.snapToMonth = true,
this.initialScrollOffset, this.initialScrollOffset,
this.maxWidth,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@@ -189,6 +128,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final bool persistentBottomBar; final bool persistentBottomBar;
final bool snapToMonth; final bool snapToMonth;
final double? initialScrollOffset; final double? initialScrollOffset;
final double? maxWidth;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
@@ -207,6 +147,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
int _perRow = 4; int _perRow = 4;
double _scaleFactor = 3.0; double _scaleFactor = 3.0;
double _baseScaleFactor = 3.0; double _baseScaleFactor = 3.0;
int? _restoreAssetIndex;
@override @override
void initState() { void initState() {
@@ -225,6 +166,20 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled); ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);
} }
@override
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.maxWidth != oldWidget.maxWidth) {
final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
final index = _getCurrentAssetIndex(segments);
// Refresh to wait for new segments to be generated with the updated width before restoring the scroll position
final _ = ref.refresh(timelineArgsProvider);
_restoreAssetIndex = index;
});
}
}
void _onEvent(Event event) { void _onEvent(Event event) {
switch (event) { switch (event) {
case ScrollToTopEvent(): case ScrollToTopEvent():
@@ -242,21 +197,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
} }
} }
void _onMultiSelectionToggled(_, bool isEnabled) {
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
}
void _restoreAssetPosition(_) { void _restoreAssetPosition(_) {
final restorationState = _TimelineRestorationProvider.of(context); if (_restoreAssetIndex == null) return;
if (!restorationState.shouldRestoreAssetPosition || restorationState.restoreAssetIndex == null) return;
final asyncSegments = ref.read(timelineSegmentProvider); final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) { asyncSegments.whenData((segments) {
final targetSegment = segments.lastWhereOrNull( final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!);
(segment) => segment.firstAssetIndex <= restorationState.restoreAssetIndex!,
);
if (targetSegment != null) { if (targetSegment != null) {
final assetIndexInSegment = restorationState.restoreAssetIndex! - targetSegment.firstAssetIndex; final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex;
final newColumnCount = ref.read(timelineArgsProvider).columnCount; final newColumnCount = ref.read(timelineArgsProvider).columnCount;
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor(); final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment; final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
@@ -268,7 +216,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}); });
} }
}); });
restorationState.clearRestoreAssetIndex(); _restoreAssetIndex = null;
}
void _onMultiSelectionToggled(_, bool isEnabled) {
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
} }
int? _getCurrentAssetIndex(List<Segment> segments) { int? _getCurrentAssetIndex(List<Segment> segments) {
@@ -478,14 +430,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
return PrimaryScrollController( return PrimaryScrollController(
controller: _scrollController, controller: _scrollController,
child: NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final currentIndex = _getCurrentAssetIndex(segments);
if (currentIndex != null && mounted) {
_TimelineRestorationProvider.of(context).setRestoreAssetIndex(currentIndex);
}
return false;
},
child: RawGestureDetector( child: RawGestureDetector(
gestures: { gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>( CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
@@ -498,17 +442,15 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
scale.onUpdate = (details) { scale.onUpdate = (details) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0); final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt(); final newPerRow = 7 - newScaleFactor.toInt();
final targetAssetIndex = _getCurrentAssetIndex(segments);
if (newPerRow != _perRow) { if (newPerRow != _perRow) {
final restorationState = _TimelineRestorationProvider.of(context); final targetAssetIndex = _getCurrentAssetIndex(segments);
setState(() { setState(() {
_scaleFactor = newScaleFactor; _scaleFactor = newScaleFactor;
_perRow = newPerRow; _perRow = newPerRow;
_restoreAssetIndex = targetAssetIndex;
}); });
restorationState.setRestoreAssetIndex(targetAssetIndex);
restorationState.setShouldRestoreAssetPosition(true);
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
} }
}; };
@@ -527,7 +469,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: Stack( child: Stack(
children: [ children: [
timeline, timeline,
if (isMultiSelectStatusVisible) if (isBottomWidgetVisible)
Positioned( Positioned(
top: MediaQuery.paddingOf(context).top, top: MediaQuery.paddingOf(context).top,
left: 25, left: 25,
@@ -541,7 +483,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
), ),
), ),
), ),
),
); );
}, },
), ),
+5 -2
View File
@@ -30,11 +30,10 @@ import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager // ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 21; const int targetVersion = 22;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async { Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null; final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -100,6 +99,10 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
} }
} }
if (version < 22 && !Store.isBetaTimelineEnabled) {
await Store.put(StoreKey.needBetaMigration, true);
}
if (targetVersion >= 12) { if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion); await Store.put(StoreKey.version, targetVersion);
return; return;
+58
View File
@@ -0,0 +1,58 @@
sealed class Option<T> {
const Option();
const factory Option.some(T value) = Some<T>;
const factory Option.none() = None<T>;
factory Option.fromNullable(T? value) => value != null ? Some(value) : None<T>();
@pragma('vm:prefer-inline')
bool get isSome => this is Some<T>;
@pragma('vm:prefer-inline')
bool get isNone => this is None<T>;
@pragma('vm:prefer-inline')
T? get unwrapOrNull => switch (this) {
Some(:final value) => value,
None() => null,
};
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
Some(:final value) => onSome(value),
None() => onNone(),
};
@override
String toString() => switch (this) {
Some(:final value) => 'Some($value)',
None() => 'None',
};
}
final class Some<T> extends Option<T> {
final T value;
const Some(this.value);
@override
bool operator ==(Object other) => other is Some<T> && other.value == value;
@override
int get hashCode => value.hashCode;
}
final class None<T> extends Option<T> {
const None();
@override
bool operator ==(Object other) => other is None<T>;
@override
int get hashCode => 0;
}
extension ObjectOptionExtension<T> on T? {
Option<T> toOption() => Option.fromNullable(this);
}
@@ -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: ImmichFormattedText(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)),
),
),
],
);
}
}
@@ -135,7 +135,7 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_enable_alternate_media_filter_title".tr(), title: "advanced_settings_enable_alternate_media_filter_title".tr(),
subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(), subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(),
), ),
const BetaTimelineListTile(), if (!Store.isBetaTimelineEnabled) const BetaTimelineListTile(),
if (Store.isBetaTimelineEnabled) if (Store.isBetaTimelineEnabled)
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: readonlyModeEnabled, valueNotifier: readonlyModeEnabled,
+9 -9
View File
@@ -421,14 +421,14 @@ class AssetsApi {
/// ///
/// * [String] id (required): /// * [String] id (required):
/// ///
/// * [AssetEditActionListDto] assetEditActionListDto (required): /// * [AssetEditsCreateDto] assetEditsCreateDto (required):
Future<Response> editAssetWithHttpInfo(String id, AssetEditActionListDto assetEditActionListDto,) async { Future<Response> editAssetWithHttpInfo(String id, AssetEditsCreateDto assetEditsCreateDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/edits' final apiPath = r'/assets/{id}/edits'
.replaceAll('{id}', id); .replaceAll('{id}', id);
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = assetEditActionListDto; Object? postBody = assetEditsCreateDto;
final queryParams = <QueryParam>[]; final queryParams = <QueryParam>[];
final headerParams = <String, String>{}; final headerParams = <String, String>{};
@@ -456,9 +456,9 @@ class AssetsApi {
/// ///
/// * [String] id (required): /// * [String] id (required):
/// ///
/// * [AssetEditActionListDto] assetEditActionListDto (required): /// * [AssetEditsCreateDto] assetEditsCreateDto (required):
Future<AssetEditsDto?> editAsset(String id, AssetEditActionListDto assetEditActionListDto,) async { Future<AssetEditsResponseDto?> editAsset(String id, AssetEditsCreateDto assetEditsCreateDto,) async {
final response = await editAssetWithHttpInfo(id, assetEditActionListDto,); final response = await editAssetWithHttpInfo(id, assetEditsCreateDto,);
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
@@ -466,7 +466,7 @@ class AssetsApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string. // FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto; return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsResponseDto',) as AssetEditsResponseDto;
} }
return null; return null;
@@ -576,7 +576,7 @@ class AssetsApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] id (required): /// * [String] id (required):
Future<AssetEditsDto?> getAssetEdits(String id,) async { Future<AssetEditsResponseDto?> getAssetEdits(String id,) async {
final response = await getAssetEditsWithHttpInfo(id,); final response = await getAssetEditsWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
@@ -585,7 +585,7 @@ class AssetsApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string. // FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto; return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsResponseDto',) as AssetEditsResponseDto;
} }
return null; return null;
@@ -10,9 +10,9 @@
part of openapi.api; part of openapi.api;
class AssetEditActionCrop { class AssetEditActionItemDto {
/// Returns a new [AssetEditActionCrop] instance. /// Returns a new [AssetEditActionItemDto] instance.
AssetEditActionCrop({ AssetEditActionItemDto({
required this.action, required this.action,
required this.parameters, required this.parameters,
}); });
@@ -20,10 +20,10 @@ class AssetEditActionCrop {
/// Type of edit action to perform /// Type of edit action to perform
AssetEditAction action; AssetEditAction action;
CropParameters parameters; AssetEditActionItemDtoParameters parameters;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionCrop && bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemDto &&
other.action == action && other.action == action &&
other.parameters == parameters; other.parameters == parameters;
@@ -34,7 +34,7 @@ class AssetEditActionCrop {
(parameters.hashCode); (parameters.hashCode);
@override @override
String toString() => 'AssetEditActionCrop[action=$action, parameters=$parameters]'; String toString() => 'AssetEditActionItemDto[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -43,27 +43,27 @@ class AssetEditActionCrop {
return json; return json;
} }
/// Returns a new [AssetEditActionCrop] instance and imports its values from /// Returns a new [AssetEditActionItemDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AssetEditActionCrop? fromJson(dynamic value) { static AssetEditActionItemDto? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionCrop"); upgradeDto(value, "AssetEditActionItemDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AssetEditActionCrop( return AssetEditActionItemDto(
action: AssetEditAction.fromJson(json[r'action'])!, action: AssetEditAction.fromJson(json[r'action'])!,
parameters: CropParameters.fromJson(json[r'parameters'])!, parameters: AssetEditActionItemDtoParameters.fromJson(json[r'parameters'])!,
); );
} }
return null; return null;
} }
static List<AssetEditActionCrop> listFromJson(dynamic json, {bool growable = false,}) { static List<AssetEditActionItemDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionCrop>[]; final result = <AssetEditActionItemDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = AssetEditActionCrop.fromJson(row); final value = AssetEditActionItemDto.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@@ -72,12 +72,12 @@ class AssetEditActionCrop {
return result.toList(growable: growable); return result.toList(growable: growable);
} }
static Map<String, AssetEditActionCrop> mapFromJson(dynamic json) { static Map<String, AssetEditActionItemDto> mapFromJson(dynamic json) {
final map = <String, AssetEditActionCrop>{}; final map = <String, AssetEditActionItemDto>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AssetEditActionCrop.fromJson(entry.value); final value = AssetEditActionItemDto.fromJson(entry.value);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -86,14 +86,14 @@ class AssetEditActionCrop {
return map; return map;
} }
// maps a json object with a list of AssetEditActionCrop-objects as value to a dart map // maps a json object with a list of AssetEditActionItemDto-objects as value to a dart map
static Map<String, List<AssetEditActionCrop>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<AssetEditActionItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionCrop>>{}; final map = <String, List<AssetEditActionItemDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = AssetEditActionCrop.listFromJson(entry.value, growable: growable,); map[entry.key] = AssetEditActionItemDto.listFromJson(entry.value, growable: growable,);
} }
} }
return map; return map;
@@ -0,0 +1,153 @@
//
// 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 AssetEditActionItemDtoParameters {
/// Returns a new [AssetEditActionItemDtoParameters] instance.
AssetEditActionItemDtoParameters({
required this.height,
required this.width,
required this.x,
required this.y,
required this.angle,
required this.axis,
});
/// Height of the crop
///
/// Minimum value: 1
num height;
/// Width of the crop
///
/// Minimum value: 1
num width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
num x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
num y;
/// Rotation angle in degrees
num angle;
/// Axis to mirror along
MirrorAxis axis;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemDtoParameters &&
other.height == height &&
other.width == width &&
other.x == x &&
other.y == y &&
other.angle == angle &&
other.axis == axis;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(height.hashCode) +
(width.hashCode) +
(x.hashCode) +
(y.hashCode) +
(angle.hashCode) +
(axis.hashCode);
@override
String toString() => 'AssetEditActionItemDtoParameters[height=$height, width=$width, x=$x, y=$y, angle=$angle, axis=$axis]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'height'] = this.height;
json[r'width'] = this.width;
json[r'x'] = this.x;
json[r'y'] = this.y;
json[r'angle'] = this.angle;
json[r'axis'] = this.axis;
return json;
}
/// Returns a new [AssetEditActionItemDtoParameters] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditActionItemDtoParameters? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionItemDtoParameters");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditActionItemDtoParameters(
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
angle: num.parse('${json[r'angle']}'),
axis: MirrorAxis.fromJson(json[r'axis'])!,
);
}
return null;
}
static List<AssetEditActionItemDtoParameters> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionItemDtoParameters>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditActionItemDtoParameters.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditActionItemDtoParameters> mapFromJson(dynamic json) {
final map = <String, AssetEditActionItemDtoParameters>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditActionItemDtoParameters.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditActionItemDtoParameters-objects as value to a dart map
static Map<String, List<AssetEditActionItemDtoParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionItemDtoParameters>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditActionItemDtoParameters.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'height',
'width',
'x',
'y',
'angle',
'axis',
};
}
@@ -10,60 +10,67 @@
part of openapi.api; part of openapi.api;
class AssetEditActionListDtoEditsInner { class AssetEditActionItemResponseDto {
/// Returns a new [AssetEditActionListDtoEditsInner] instance. /// Returns a new [AssetEditActionItemResponseDto] instance.
AssetEditActionListDtoEditsInner({ AssetEditActionItemResponseDto({
required this.action, required this.action,
required this.id,
required this.parameters, required this.parameters,
}); });
/// Type of edit action to perform /// Type of edit action to perform
AssetEditAction action; AssetEditAction action;
MirrorParameters parameters; String id;
AssetEditActionItemDtoParameters parameters;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner && bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemResponseDto &&
other.action == action && other.action == action &&
other.id == id &&
other.parameters == parameters; other.parameters == parameters;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(action.hashCode) + (action.hashCode) +
(id.hashCode) +
(parameters.hashCode); (parameters.hashCode);
@override @override
String toString() => 'AssetEditActionListDtoEditsInner[action=$action, parameters=$parameters]'; String toString() => 'AssetEditActionItemResponseDto[action=$action, id=$id, parameters=$parameters]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'action'] = this.action; json[r'action'] = this.action;
json[r'id'] = this.id;
json[r'parameters'] = this.parameters; json[r'parameters'] = this.parameters;
return json; return json;
} }
/// Returns a new [AssetEditActionListDtoEditsInner] instance and imports its values from /// Returns a new [AssetEditActionItemResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AssetEditActionListDtoEditsInner? fromJson(dynamic value) { static AssetEditActionItemResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionListDtoEditsInner"); upgradeDto(value, "AssetEditActionItemResponseDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AssetEditActionListDtoEditsInner( return AssetEditActionItemResponseDto(
action: AssetEditAction.fromJson(json[r'action'])!, action: AssetEditAction.fromJson(json[r'action'])!,
parameters: MirrorParameters.fromJson(json[r'parameters'])!, id: mapValueOfType<String>(json, r'id')!,
parameters: AssetEditActionItemDtoParameters.fromJson(json[r'parameters'])!,
); );
} }
return null; return null;
} }
static List<AssetEditActionListDtoEditsInner> listFromJson(dynamic json, {bool growable = false,}) { static List<AssetEditActionItemResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionListDtoEditsInner>[]; final result = <AssetEditActionItemResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = AssetEditActionListDtoEditsInner.fromJson(row); final value = AssetEditActionItemResponseDto.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@@ -72,12 +79,12 @@ class AssetEditActionListDtoEditsInner {
return result.toList(growable: growable); return result.toList(growable: growable);
} }
static Map<String, AssetEditActionListDtoEditsInner> mapFromJson(dynamic json) { static Map<String, AssetEditActionItemResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetEditActionListDtoEditsInner>{}; final map = <String, AssetEditActionItemResponseDto>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AssetEditActionListDtoEditsInner.fromJson(entry.value); final value = AssetEditActionItemResponseDto.fromJson(entry.value);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -86,14 +93,14 @@ class AssetEditActionListDtoEditsInner {
return map; return map;
} }
// maps a json object with a list of AssetEditActionListDtoEditsInner-objects as value to a dart map // maps a json object with a list of AssetEditActionItemResponseDto-objects as value to a dart map
static Map<String, List<AssetEditActionListDtoEditsInner>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<AssetEditActionItemResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionListDtoEditsInner>>{}; final map = <String, List<AssetEditActionItemResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = AssetEditActionListDtoEditsInner.listFromJson(entry.value, growable: growable,); map[entry.key] = AssetEditActionItemResponseDto.listFromJson(entry.value, growable: growable,);
} }
} }
return map; return map;
@@ -102,6 +109,7 @@ class AssetEditActionListDtoEditsInner {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'action', 'action',
'id',
'parameters', 'parameters',
}; };
} }
-108
View File
@@ -1,108 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditActionMirror {
/// Returns a new [AssetEditActionMirror] instance.
AssetEditActionMirror({
required this.action,
required this.parameters,
});
/// Type of edit action to perform
AssetEditAction action;
MirrorParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionMirror &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'AssetEditActionMirror[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [AssetEditActionMirror] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditActionMirror? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionMirror");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditActionMirror(
action: AssetEditAction.fromJson(json[r'action'])!,
parameters: MirrorParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<AssetEditActionMirror> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionMirror>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditActionMirror.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditActionMirror> mapFromJson(dynamic json) {
final map = <String, AssetEditActionMirror>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditActionMirror.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditActionMirror-objects as value to a dart map
static Map<String, List<AssetEditActionMirror>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionMirror>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditActionMirror.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}
-108
View File
@@ -1,108 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditActionRotate {
/// Returns a new [AssetEditActionRotate] instance.
AssetEditActionRotate({
required this.action,
required this.parameters,
});
/// Type of edit action to perform
AssetEditAction action;
RotateParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionRotate &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'AssetEditActionRotate[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [AssetEditActionRotate] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditActionRotate? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionRotate");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditActionRotate(
action: AssetEditAction.fromJson(json[r'action'])!,
parameters: RotateParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<AssetEditActionRotate> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionRotate>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditActionRotate.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditActionRotate> mapFromJson(dynamic json) {
final map = <String, AssetEditActionRotate>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditActionRotate.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditActionRotate-objects as value to a dart map
static Map<String, List<AssetEditActionRotate>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionRotate>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditActionRotate.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}
@@ -10,17 +10,17 @@
part of openapi.api; part of openapi.api;
class AssetEditActionListDto { class AssetEditsCreateDto {
/// Returns a new [AssetEditActionListDto] instance. /// Returns a new [AssetEditsCreateDto] instance.
AssetEditActionListDto({ AssetEditsCreateDto({
this.edits = const [], this.edits = const [],
}); });
/// List of edit actions to apply (crop, rotate, or mirror) /// List of edit actions to apply (crop, rotate, or mirror)
List<AssetEditActionListDtoEditsInner> edits; List<AssetEditActionItemDto> edits;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDto && bool operator ==(Object other) => identical(this, other) || other is AssetEditsCreateDto &&
_deepEquality.equals(other.edits, edits); _deepEquality.equals(other.edits, edits);
@override @override
@@ -29,7 +29,7 @@ class AssetEditActionListDto {
(edits.hashCode); (edits.hashCode);
@override @override
String toString() => 'AssetEditActionListDto[edits=$edits]'; String toString() => 'AssetEditsCreateDto[edits=$edits]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -37,26 +37,26 @@ class AssetEditActionListDto {
return json; return json;
} }
/// Returns a new [AssetEditActionListDto] instance and imports its values from /// Returns a new [AssetEditsCreateDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AssetEditActionListDto? fromJson(dynamic value) { static AssetEditsCreateDto? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionListDto"); upgradeDto(value, "AssetEditsCreateDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AssetEditActionListDto( return AssetEditsCreateDto(
edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']), edits: AssetEditActionItemDto.listFromJson(json[r'edits']),
); );
} }
return null; return null;
} }
static List<AssetEditActionListDto> listFromJson(dynamic json, {bool growable = false,}) { static List<AssetEditsCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionListDto>[]; final result = <AssetEditsCreateDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = AssetEditActionListDto.fromJson(row); final value = AssetEditsCreateDto.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@@ -65,12 +65,12 @@ class AssetEditActionListDto {
return result.toList(growable: growable); return result.toList(growable: growable);
} }
static Map<String, AssetEditActionListDto> mapFromJson(dynamic json) { static Map<String, AssetEditsCreateDto> mapFromJson(dynamic json) {
final map = <String, AssetEditActionListDto>{}; final map = <String, AssetEditsCreateDto>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AssetEditActionListDto.fromJson(entry.value); final value = AssetEditsCreateDto.fromJson(entry.value);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -79,14 +79,14 @@ class AssetEditActionListDto {
return map; return map;
} }
// maps a json object with a list of AssetEditActionListDto-objects as value to a dart map // maps a json object with a list of AssetEditsCreateDto-objects as value to a dart map
static Map<String, List<AssetEditActionListDto>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<AssetEditsCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionListDto>>{}; final map = <String, List<AssetEditsCreateDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = AssetEditActionListDto.listFromJson(entry.value, growable: growable,); map[entry.key] = AssetEditsCreateDto.listFromJson(entry.value, growable: growable,);
} }
} }
return map; return map;
@@ -10,21 +10,21 @@
part of openapi.api; part of openapi.api;
class AssetEditsDto { class AssetEditsResponseDto {
/// Returns a new [AssetEditsDto] instance. /// Returns a new [AssetEditsResponseDto] instance.
AssetEditsDto({ AssetEditsResponseDto({
required this.assetId, required this.assetId,
this.edits = const [], this.edits = const [],
}); });
/// Asset ID to apply edits to /// Asset ID these edits belong to
String assetId; String assetId;
/// List of edit actions to apply (crop, rotate, or mirror) /// List of edit actions applied to the asset
List<AssetEditActionListDtoEditsInner> edits; List<AssetEditActionItemResponseDto> edits;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AssetEditsDto && bool operator ==(Object other) => identical(this, other) || other is AssetEditsResponseDto &&
other.assetId == assetId && other.assetId == assetId &&
_deepEquality.equals(other.edits, edits); _deepEquality.equals(other.edits, edits);
@@ -35,7 +35,7 @@ class AssetEditsDto {
(edits.hashCode); (edits.hashCode);
@override @override
String toString() => 'AssetEditsDto[assetId=$assetId, edits=$edits]'; String toString() => 'AssetEditsResponseDto[assetId=$assetId, edits=$edits]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -44,27 +44,27 @@ class AssetEditsDto {
return json; return json;
} }
/// Returns a new [AssetEditsDto] instance and imports its values from /// Returns a new [AssetEditsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AssetEditsDto? fromJson(dynamic value) { static AssetEditsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AssetEditsDto"); upgradeDto(value, "AssetEditsResponseDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AssetEditsDto( return AssetEditsResponseDto(
assetId: mapValueOfType<String>(json, r'assetId')!, assetId: mapValueOfType<String>(json, r'assetId')!,
edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']), edits: AssetEditActionItemResponseDto.listFromJson(json[r'edits']),
); );
} }
return null; return null;
} }
static List<AssetEditsDto> listFromJson(dynamic json, {bool growable = false,}) { static List<AssetEditsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditsDto>[]; final result = <AssetEditsResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = AssetEditsDto.fromJson(row); final value = AssetEditsResponseDto.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@@ -73,12 +73,12 @@ class AssetEditsDto {
return result.toList(growable: growable); return result.toList(growable: growable);
} }
static Map<String, AssetEditsDto> mapFromJson(dynamic json) { static Map<String, AssetEditsResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetEditsDto>{}; final map = <String, AssetEditsResponseDto>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AssetEditsDto.fromJson(entry.value); final value = AssetEditsResponseDto.fromJson(entry.value);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -87,14 +87,14 @@ class AssetEditsDto {
return map; return map;
} }
// maps a json object with a list of AssetEditsDto-objects as value to a dart map // maps a json object with a list of AssetEditsResponseDto-objects as value to a dart map
static Map<String, List<AssetEditsDto>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<AssetEditsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditsDto>>{}; final map = <String, List<AssetEditsResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = AssetEditsDto.listFromJson(entry.value, growable: growable,); map[entry.key] = AssetEditsResponseDto.listFromJson(entry.value, growable: growable,);
} }
} }
return map; return map;
+1 -1
View File
@@ -156,7 +156,7 @@ class AssetResponseDto {
List<TagResponseDto> tags; List<TagResponseDto> tags;
/// Thumbhash for thumbnail generation /// Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.
String? thumbhash; String? thumbhash;
/// Asset type /// Asset type
+37 -1
View File
@@ -15,9 +15,11 @@ class MemoryCreateDto {
MemoryCreateDto({ MemoryCreateDto({
this.assetIds = const [], this.assetIds = const [],
required this.data, required this.data,
this.hideAt,
this.isSaved, this.isSaved,
required this.memoryAt, required this.memoryAt,
this.seenAt, this.seenAt,
this.showAt,
required this.type, required this.type,
}); });
@@ -26,6 +28,15 @@ class MemoryCreateDto {
OnThisDayDto data; OnThisDayDto data;
/// Date when memory should be hidden
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? hideAt;
/// Is memory saved /// Is memory saved
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
@@ -47,6 +58,15 @@ class MemoryCreateDto {
/// ///
DateTime? seenAt; DateTime? seenAt;
/// Date when memory should be shown
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? showAt;
/// Memory type /// Memory type
MemoryType type; MemoryType type;
@@ -54,9 +74,11 @@ class MemoryCreateDto {
bool operator ==(Object other) => identical(this, other) || other is MemoryCreateDto && bool operator ==(Object other) => identical(this, other) || other is MemoryCreateDto &&
_deepEquality.equals(other.assetIds, assetIds) && _deepEquality.equals(other.assetIds, assetIds) &&
other.data == data && other.data == data &&
other.hideAt == hideAt &&
other.isSaved == isSaved && other.isSaved == isSaved &&
other.memoryAt == memoryAt && other.memoryAt == memoryAt &&
other.seenAt == seenAt && other.seenAt == seenAt &&
other.showAt == showAt &&
other.type == type; other.type == type;
@override @override
@@ -64,18 +86,25 @@ class MemoryCreateDto {
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(assetIds.hashCode) + (assetIds.hashCode) +
(data.hashCode) + (data.hashCode) +
(hideAt == null ? 0 : hideAt!.hashCode) +
(isSaved == null ? 0 : isSaved!.hashCode) + (isSaved == null ? 0 : isSaved!.hashCode) +
(memoryAt.hashCode) + (memoryAt.hashCode) +
(seenAt == null ? 0 : seenAt!.hashCode) + (seenAt == null ? 0 : seenAt!.hashCode) +
(showAt == null ? 0 : showAt!.hashCode) +
(type.hashCode); (type.hashCode);
@override @override
String toString() => 'MemoryCreateDto[assetIds=$assetIds, data=$data, isSaved=$isSaved, memoryAt=$memoryAt, seenAt=$seenAt, type=$type]'; String toString() => 'MemoryCreateDto[assetIds=$assetIds, data=$data, hideAt=$hideAt, isSaved=$isSaved, memoryAt=$memoryAt, seenAt=$seenAt, showAt=$showAt, type=$type]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'assetIds'] = this.assetIds; json[r'assetIds'] = this.assetIds;
json[r'data'] = this.data; json[r'data'] = this.data;
if (this.hideAt != null) {
json[r'hideAt'] = this.hideAt!.toUtc().toIso8601String();
} else {
// json[r'hideAt'] = null;
}
if (this.isSaved != null) { if (this.isSaved != null) {
json[r'isSaved'] = this.isSaved; json[r'isSaved'] = this.isSaved;
} else { } else {
@@ -86,6 +115,11 @@ class MemoryCreateDto {
json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String(); json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String();
} else { } else {
// json[r'seenAt'] = null; // json[r'seenAt'] = null;
}
if (this.showAt != null) {
json[r'showAt'] = this.showAt!.toUtc().toIso8601String();
} else {
// json[r'showAt'] = null;
} }
json[r'type'] = this.type; json[r'type'] = this.type;
return json; return json;
@@ -104,9 +138,11 @@ class MemoryCreateDto {
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
data: OnThisDayDto.fromJson(json[r'data'])!, data: OnThisDayDto.fromJson(json[r'data'])!,
hideAt: mapDateTime(json, r'hideAt', r''),
isSaved: mapValueOfType<bool>(json, r'isSaved'), isSaved: mapValueOfType<bool>(json, r'isSaved'),
memoryAt: mapDateTime(json, r'memoryAt', r'')!, memoryAt: mapDateTime(json, r'memoryAt', r'')!,
seenAt: mapDateTime(json, r'seenAt', r''), seenAt: mapDateTime(json, r'seenAt', r''),
showAt: mapDateTime(json, r'showAt', r''),
type: MemoryType.fromJson(json[r'type'])!, type: MemoryType.fromJson(json[r'type'])!,
); );
} }
+201
View File
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SyncAssetFaceV2(
assetId: mapValueOfType<String>(json, r'assetId')!,
boundingBoxX1: mapValueOfType<int>(json, r'boundingBoxX1')!,
boundingBoxX2: mapValueOfType<int>(json, r'boundingBoxX2')!,
boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!,
boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!,
deletedAt: mapDateTime(json, r'deletedAt', r''),
id: mapValueOfType<String>(json, r'id')!,
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
isVisible: mapValueOfType<bool>(json, r'isVisible')!,
personId: mapValueOfType<String>(json, r'personId'),
sourceType: mapValueOfType<String>(json, r'sourceType')!,
);
}
return null;
}
static List<SyncAssetFaceV2> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetFaceV2>[];
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<String, SyncAssetFaceV2> mapFromJson(dynamic json) {
final map = <String, SyncAssetFaceV2>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SyncAssetFaceV2>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetFaceV2>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'assetId',
'boundingBoxX1',
'boundingBoxX2',
'boundingBoxY1',
'boundingBoxY2',
'deletedAt',
'id',
'imageHeight',
'imageWidth',
'isVisible',
'personId',
'sourceType',
};
}
+3
View File
@@ -64,6 +64,7 @@ class SyncEntityType {
static const personV1 = SyncEntityType._(r'PersonV1'); static const personV1 = SyncEntityType._(r'PersonV1');
static const personDeleteV1 = SyncEntityType._(r'PersonDeleteV1'); static const personDeleteV1 = SyncEntityType._(r'PersonDeleteV1');
static const assetFaceV1 = SyncEntityType._(r'AssetFaceV1'); static const assetFaceV1 = SyncEntityType._(r'AssetFaceV1');
static const assetFaceV2 = SyncEntityType._(r'AssetFaceV2');
static const assetFaceDeleteV1 = SyncEntityType._(r'AssetFaceDeleteV1'); static const assetFaceDeleteV1 = SyncEntityType._(r'AssetFaceDeleteV1');
static const userMetadataV1 = SyncEntityType._(r'UserMetadataV1'); static const userMetadataV1 = SyncEntityType._(r'UserMetadataV1');
static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1'); static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1');
@@ -114,6 +115,7 @@ class SyncEntityType {
personV1, personV1,
personDeleteV1, personDeleteV1,
assetFaceV1, assetFaceV1,
assetFaceV2,
assetFaceDeleteV1, assetFaceDeleteV1,
userMetadataV1, userMetadataV1,
userMetadataDeleteV1, userMetadataDeleteV1,
@@ -199,6 +201,7 @@ class SyncEntityTypeTypeTransformer {
case r'PersonV1': return SyncEntityType.personV1; case r'PersonV1': return SyncEntityType.personV1;
case r'PersonDeleteV1': return SyncEntityType.personDeleteV1; case r'PersonDeleteV1': return SyncEntityType.personDeleteV1;
case r'AssetFaceV1': return SyncEntityType.assetFaceV1; case r'AssetFaceV1': return SyncEntityType.assetFaceV1;
case r'AssetFaceV2': return SyncEntityType.assetFaceV2;
case r'AssetFaceDeleteV1': return SyncEntityType.assetFaceDeleteV1; case r'AssetFaceDeleteV1': return SyncEntityType.assetFaceDeleteV1;
case r'UserMetadataV1': return SyncEntityType.userMetadataV1; case r'UserMetadataV1': return SyncEntityType.userMetadataV1;
case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1; case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1;
+3
View File
@@ -42,6 +42,7 @@ class SyncRequestType {
static const usersV1 = SyncRequestType._(r'UsersV1'); static const usersV1 = SyncRequestType._(r'UsersV1');
static const peopleV1 = SyncRequestType._(r'PeopleV1'); static const peopleV1 = SyncRequestType._(r'PeopleV1');
static const assetFacesV1 = SyncRequestType._(r'AssetFacesV1'); static const assetFacesV1 = SyncRequestType._(r'AssetFacesV1');
static const assetFacesV2 = SyncRequestType._(r'AssetFacesV2');
static const userMetadataV1 = SyncRequestType._(r'UserMetadataV1'); static const userMetadataV1 = SyncRequestType._(r'UserMetadataV1');
/// List of all possible values in this [enum][SyncRequestType]. /// List of all possible values in this [enum][SyncRequestType].
@@ -65,6 +66,7 @@ class SyncRequestType {
usersV1, usersV1,
peopleV1, peopleV1,
assetFacesV1, assetFacesV1,
assetFacesV2,
userMetadataV1, userMetadataV1,
]; ];
@@ -123,6 +125,7 @@ class SyncRequestTypeTypeTransformer {
case r'UsersV1': return SyncRequestType.usersV1; case r'UsersV1': return SyncRequestType.usersV1;
case r'PeopleV1': return SyncRequestType.peopleV1; case r'PeopleV1': return SyncRequestType.peopleV1;
case r'AssetFacesV1': return SyncRequestType.assetFacesV1; case r'AssetFacesV1': return SyncRequestType.assetFacesV1;
case r'AssetFacesV2': return SyncRequestType.assetFacesV2;
case r'UserMetadataV1': return SyncRequestType.userMetadataV1; case r'UserMetadataV1': return SyncRequestType.userMetadataV1;
default: default:
if (!allowNull) { if (!allowNull) {
+1 -1
View File
@@ -1,6 +1,6 @@
export 'src/components/close_button.dart'; export 'src/components/close_button.dart';
export 'src/components/form.dart'; export 'src/components/form.dart';
export 'src/components/html_text.dart'; export 'src/components/formatted_text.dart';
export 'src/components/icon_button.dart'; export 'src/components/icon_button.dart';
export 'src/components/password_input.dart'; export 'src/components/password_input.dart';
export 'src/components/text_button.dart'; export 'src/components/text_button.dart';
@@ -0,0 +1,141 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
class FormattedSpan {
final TextStyle? style;
final VoidCallback? onTap;
const FormattedSpan({this.style, this.onTap});
}
/// A widget that renders text with optional HTML-style formatting.
///
/// Supports the following tags:
/// - `<b>` for bold text
/// - `<link>` or any tag ending with `-link` for tappable links
///
/// Tags must not be nested. Each tag is matched independently left-to-right.
///
/// By default, `<b>` renders as [FontWeight.bold] and link tags render with an
/// underline and no tap handler. Provide [spanBuilder] to attach tap callbacks
/// or override styles per tag.
///
/// Bold-only example (no [spanBuilder] needed):
/// ```dart
/// ImmichFormattedText('Delete <b>{count}</b> items?')
/// ```
///
/// Link example:
/// ```dart
/// ImmichFormattedText(
/// 'Refer to <docs-link>docs</docs-link> and <other-link>other</other-link>',
/// spanBuilder: (tag) => FormattedSpan(
/// onTap: switch (tag) {
/// 'docs-link' => () => launchUrl(docsUrl),
/// 'other-link' => () => launchUrl(otherUrl),
/// _ => null,
/// },
/// ),
/// )
/// ```
class ImmichFormattedText extends StatefulWidget {
final String text;
final TextStyle? style;
final TextAlign? textAlign;
final TextOverflow? overflow;
final int? maxLines;
final bool? softWrap;
final FormattedSpan Function(String tag)? spanBuilder;
const ImmichFormattedText(
this.text, {
this.spanBuilder,
super.key,
this.style,
this.textAlign,
this.overflow,
this.maxLines,
this.softWrap,
});
@override
State<ImmichFormattedText> createState() => _ImmichFormattedTextState();
}
class _ImmichFormattedTextState extends State<ImmichFormattedText> {
final _recognizers = <GestureRecognizer>[];
// Matches <b>, <link>, or any *-link tag and its content.
static final _tagPattern = RegExp(r'<(b|link|[\w]+-link)>(.*?)</\1>', caseSensitive: false, dotAll: true);
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
List<InlineSpan> _buildSpans() {
_disposeRecognizers();
final spans = <InlineSpan>[];
int cursor = 0;
for (final match in _tagPattern.allMatches(widget.text)) {
if (match.start > cursor) {
spans.add(TextSpan(text: widget.text.substring(cursor, match.start)));
}
final tag = match.group(1)!.toLowerCase();
final content = match.group(2)!;
final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag);
final style = formattedSpan.style ?? _defaultTextStyle(tag);
GestureRecognizer? recognizer;
if (formattedSpan.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap;
_recognizers.add(recognizer);
}
spans.add(TextSpan(text: content, style: style, recognizer: recognizer));
cursor = match.end;
}
if (cursor < widget.text.length) {
spans.add(TextSpan(text: widget.text.substring(cursor)));
}
return spans;
}
FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) {
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ => const FormattedSpan(),
};
TextStyle? _defaultTextStyle(String tag) => switch (tag) {
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => 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,
);
}
}
@@ -1,189 +0,0 @@
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 <a> 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:
/// - `<b>` or `<strong>` for bold text
/// - `<link>` or any tag ending with `-link` for tappable links
///
/// Example:
/// ```dart
/// ImmichHtmlText(
/// 'Refer to <link>docs</link> and <other-link>other</other-link>',
/// 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<String, VoidCallback>? 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<ImmichHtmlText> createState() => _ImmichHtmlTextState();
}
class _ImmichHtmlTextState extends State<ImmichHtmlText> {
final _recognizers = <GestureRecognizer>[];
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));
}
}
/// `<link>` tags are preprocessed to `<a>` tags because `<link>` 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)>(.*?)</\1>', caseSensitive: false),
(match) => '<a>${match.group(2)}</a>',
)
.replaceAllMapped(
RegExp(r'<(link)\s*/>', caseSensitive: false),
(match) => '<a></a>',
);
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
List<InlineSpan> _buildSpans() {
_disposeRecognizers();
return _document.nodes.expand((node) => _buildNode(node, null, null)).toList();
}
Iterable<InlineSpan> _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,
);
}
}
-16
View File
@@ -41,14 +41,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -67,14 +59,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
-1
View File
@@ -7,7 +7,6 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
html: ^0.15.6
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
class FormattedTextBoldText extends StatelessWidget {
const FormattedTextBoldText({super.key});
@override
Widget build(BuildContext context) {
return ImmichFormattedText('This is <b>bold text</b>.');
}
}
@@ -1,25 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart'; import 'package:immich_ui/immich_ui.dart';
class HtmlTextLinks extends StatelessWidget { class FormattedTextLinks extends StatelessWidget {
const HtmlTextLinks({super.key}); const FormattedTextLinks({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ImmichHtmlText( return ImmichFormattedText(
'Read the <docs-link>documentation</docs-link> or visit <github-link>GitHub</github-link>.', 'Read the <docs-link>documentation</docs-link> or visit <github-link>GitHub</github-link>.',
linkHandlers: { spanBuilder: (tag) => FormattedSpan(
'docs-link': () { onTap: switch (tag) {
ScaffoldMessenger.of( 'docs-link' => () => ScaffoldMessenger.of(
context, context,
).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))); ).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))),
}, 'github-link' => () => ScaffoldMessenger.of(
'github-link': () {
ScaffoldMessenger.of(
context, context,
).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))); ).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))),
}, _ => null,
}, },
),
); );
} }
} }
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
class FormattedTextMixedContent extends StatelessWidget {
const FormattedTextMixedContent({super.key});
@override
Widget build(BuildContext context) {
return ImmichFormattedText(
'You can use <b>bold text</b> and <link>links</link> together.',
spanBuilder: (tag) => switch (tag) {
'b' => const FormattedSpan(
style: TextStyle(fontWeight: FontWeight.bold),
),
_ => FormattedSpan(
onTap: () => ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Link clicked!'))),
),
},
);
}
}
@@ -1,13 +0,0 @@
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 <b>bold text</b> and <strong>strong text</strong>.',
);
}
}
@@ -1,20 +0,0 @@
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 <b>combine <link>bold and links</link></b> together.',
linkHandlers: {
'link': () {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Nested link clicked!')));
},
},
);
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:showcase/pages/components/examples/formatted_text_bold_text.dart';
import 'package:showcase/pages/components/examples/formatted_text_links.dart';
import 'package:showcase/pages/components/examples/formatted_text_mixed_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 FormattedTextPage extends StatelessWidget {
const FormattedTextPage({super.key});
@override
Widget build(BuildContext context) {
return PageTitle(
title: AppRoute.formattedText.name,
child: ComponentExamples(
title: 'ImmichFormattedText',
subtitle: 'Render text with HTML formatting (bold, links).',
examples: [
ExampleCard(
title: 'Bold Text',
preview: const FormattedTextBoldText(),
code: 'formatted_text_bold_text.dart',
),
ExampleCard(
title: 'Links',
preview: const FormattedTextLinks(),
code: 'formatted_text_links.dart',
),
ExampleCard(
title: 'Mixed Content',
preview: const FormattedTextMixedContent(),
code: 'formatted_text_mixed_tags.dart',
),
],
),
);
}
}
@@ -1,40 +0,0 @@
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',
),
],
),
);
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:showcase/pages/components/close_button_page.dart'; import 'package:showcase/pages/components/close_button_page.dart';
import 'package:showcase/pages/components/form_page.dart'; import 'package:showcase/pages/components/form_page.dart';
import 'package:showcase/pages/components/html_text_page.dart'; import 'package:showcase/pages/components/formatted_text_page.dart';
import 'package:showcase/pages/components/icon_button_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/password_input_page.dart';
import 'package:showcase/pages/components/text_button_page.dart'; import 'package:showcase/pages/components/text_button_page.dart';
@@ -34,7 +34,7 @@ class AppRouter {
AppRoute.textInput => const TextInputPage(), AppRoute.textInput => const TextInputPage(),
AppRoute.passwordInput => const PasswordInputPage(), AppRoute.passwordInput => const PasswordInputPage(),
AppRoute.form => const FormPage(), AppRoute.form => const FormPage(),
AppRoute.htmlText => const HtmlTextPage(), AppRoute.formattedText => const FormattedTextPage(),
AppRoute.constants => const ConstantsPage(), AppRoute.constants => const ConstantsPage(),
}, },
), ),
+3 -3
View File
@@ -60,10 +60,10 @@ enum AppRoute {
category: AppRouteCategory.forms, category: AppRouteCategory.forms,
icon: Icons.description_outlined, icon: Icons.description_outlined,
), ),
htmlText( formattedText(
name: 'Html Text', name: 'Formatted Text',
description: 'Render text with HTML formatting', description: 'Render text with HTML formatting',
path: '/html-text', path: '/formatted-text',
category: AppRouteCategory.forms, category: AppRouteCategory.forms,
icon: Icons.code_rounded, icon: Icons.code_rounded,
), ),
+4 -20
View File
@@ -49,14 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" 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: device_info_plus:
dependency: transitive dependency: transitive
description: description:
@@ -136,14 +128,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "17.0.1" version: "17.0.1"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
immich_ui: immich_ui:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -227,10 +211,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.16.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -328,10 +312,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.6"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1,21 +1,16 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/components/html_text.dart'; import 'package:immich_ui/src/components/formatted_text.dart';
import 'test_utils.dart'; import 'test_utils.dart';
/// Text.rich creates a nested structure: root -> wrapper -> actual children /// Text.rich creates a nested structure: root (DefaultTextStyle) -> wrapper (ImmichFormattedText) -> actual children
List<InlineSpan> _getContentSpans(WidgetTester tester) { List<InlineSpan> _getContentSpans(WidgetTester tester) {
final richText = tester.widget<RichText>(find.byType(RichText)); final richText = tester.widget<RichText>(find.byType(RichText));
final root = richText.text as TextSpan; final root = richText.text as TextSpan;
final wrapper = root.children?.firstOrNull;
if (root.children?.isNotEmpty ?? false) { if (wrapper is TextSpan) return wrapper.children ?? [];
final wrapper = root.children!.first;
if (wrapper is TextSpan && wrapper.children != null) {
return wrapper.children!;
}
}
return []; return [];
} }
@@ -38,42 +33,18 @@ void _triggerTap(TextSpan span) {
} }
void main() { void main() {
group('ImmichHtmlText', () { group('ImmichFormattedText', () {
testWidgets('renders plain text without HTML tags', (tester) async { testWidgets('renders plain text without HTML tags', (tester) async {
await tester.pumpTestWidget( await tester.pumpTestWidget(
const ImmichHtmlText('This is plain text'), const ImmichFormattedText('This is plain text'),
); );
expect(find.text('This is plain text'), findsOneWidget); 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 <b>example</b> of <b><link>HTML text</link></b> with <b>bold</b>.',
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<TapGestureRecognizer>());
expect(_concatenateText(spans), 'This is an example of HTML text with bold.');
});
testWidgets('applies text style properties', (tester) async { testWidgets('applies text style properties', (tester) async {
await tester.pumpTestWidget( await tester.pumpTestWidget(
const ImmichHtmlText( const ImmichFormattedText(
'Test text', 'Test text',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
@@ -97,7 +68,7 @@ void main() {
testWidgets('handles text with special characters', (tester) async { testWidgets('handles text with special characters', (tester) async {
await tester.pumpTestWidget( await tester.pumpTestWidget(
const ImmichHtmlText('Text with & < > " \' characters'), const ImmichFormattedText('Text with & < > " \' characters'),
); );
expect(find.byType(RichText), findsOneWidget); expect(find.byType(RichText), findsOneWidget);
@@ -109,7 +80,7 @@ void main() {
group('bold', () { group('bold', () {
testWidgets('renders bold text with <b> tag', (tester) async { testWidgets('renders bold text with <b> tag', (tester) async {
await tester.pumpTestWidget( await tester.pumpTestWidget(
const ImmichHtmlText('This is <b>bold</b> text'), const ImmichFormattedText('This is <b>bold</b> text'),
); );
final spans = _getContentSpans(tester); final spans = _getContentSpans(tester);
@@ -118,41 +89,14 @@ void main() {
expect(boldSpan.style?.fontWeight, FontWeight.bold); expect(boldSpan.style?.fontWeight, FontWeight.bold);
expect(_concatenateText(spans), 'This is bold text'); expect(_concatenateText(spans), 'This is bold text');
}); });
testWidgets('renders bold text with <strong> tag', (tester) async {
await tester.pumpTestWidget(
const ImmichHtmlText('This is <strong>strong</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 <b>bold and <strong>nested</strong></b>'),
);
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', () { group('link', () {
testWidgets('renders link text with <link> tag', (tester) async { testWidgets('renders link text with <link> tag', (tester) async {
await tester.pumpTestWidget( await tester.pumpTestWidget(
ImmichHtmlText( ImmichFormattedText(
'This is a <link>custom link</link> text', 'This is a <link>custom link</link> text',
linkHandlers: {'link': () {}}, spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'link' => () {}, _ => null }),
), ),
); );
@@ -167,9 +111,9 @@ void main() {
var linkTapped = false; var linkTapped = false;
await tester.pumpTestWidget( await tester.pumpTestWidget(
ImmichHtmlText( ImmichFormattedText(
'Tap <link>here</link>', 'Tap <link>here</link>',
linkHandlers: {'link': () => linkTapped = true}, spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'link' => () => linkTapped = true, _ => null }),
), ),
); );
@@ -183,12 +127,13 @@ void main() {
testWidgets('handles custom prefixed link tags', (tester) async { testWidgets('handles custom prefixed link tags', (tester) async {
await tester.pumpTestWidget( await tester.pumpTestWidget(
ImmichHtmlText( ImmichFormattedText(
'Refer to <docs-link>docs</docs-link> and <other-link>other</other-link>', 'Refer to <docs-link>docs</docs-link> and <other-link>other</other-link>',
linkHandlers: { spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) {
'docs-link': () {}, 'docs-link' => () {},
'other-link': () {}, 'other-link' => () {},
}, _ => null,
},),
), ),
); );
@@ -207,10 +152,9 @@ void main() {
); );
await tester.pumpTestWidget( await tester.pumpTestWidget(
ImmichHtmlText( ImmichFormattedText(
'Click <link>here</link>', 'Click <link>here</link>',
linkStyle: customLinkStyle, spanBuilder: (tag) => FormattedSpan(style: customLinkStyle, onTap: () {}),
linkHandlers: {'link': () {}},
), ),
); );
@@ -223,9 +167,9 @@ void main() {
testWidgets('link without handler renders but is not tappable', (tester) async { testWidgets('link without handler renders but is not tappable', (tester) async {
await tester.pumpTestWidget( await tester.pumpTestWidget(
ImmichHtmlText( ImmichFormattedText(
'Link without handler: <link>click me</link>', 'Link without handler: <link>click me</link>',
linkHandlers: {'other-link': () {}}, spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'other-link' => () {}, _ => null }),
), ),
); );
@@ -241,12 +185,13 @@ void main() {
var secondLinkTapped = false; var secondLinkTapped = false;
await tester.pumpTestWidget( await tester.pumpTestWidget(
ImmichHtmlText( ImmichFormattedText(
'Go to <docs-link>docs</docs-link> or <help-link>help</help-link>', 'Go to <docs-link>docs</docs-link> or <help-link>help</help-link>',
linkHandlers: { spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) {
'docs-link': () => firstLinkTapped = true, 'docs-link' => () => firstLinkTapped = true,
'help-link': () => secondLinkTapped = true, 'help-link' => () => secondLinkTapped = true,
}, _ => null,
},),
), ),
); );
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.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/album/album.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
@@ -13,38 +14,6 @@ void main() {
late DriftRemoteAlbumRepository mockRemoteAlbumRepo; late DriftRemoteAlbumRepository mockRemoteAlbumRepo;
late DriftAlbumApiRepository mockAlbumApiRepo; 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( final albumA = RemoteAlbum(
id: '1', id: '1',
name: 'Album A', name: 'Album A',
@@ -73,6 +42,21 @@ void main() {
isShared: false, 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', () { group('sortAlbums', () {
test('should sort correctly based on name', () async { test('should sort correctly based on name', () async {
final albums = [albumB, albumA]; final albums = [albumB, albumA];
@@ -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/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.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/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -66,6 +67,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android; debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1); registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0));
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db)); await StoreService.init(storeRepository: DriftStoreRepository(db));
@@ -94,11 +96,19 @@ void main() {
when(() => mockAbortCallbackWrapper()).thenReturn(false); 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; 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; handleEventsCallback = invocation.positionalArguments.first;
}); });
@@ -106,9 +116,9 @@ void main() {
when(() => mockSyncApiRepo.deleteSyncAck(any())).thenAnswer((_) async => {}); when(() => mockSyncApiRepo.deleteSyncAck(any())).thenAnswer((_) async => {});
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi); when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
when(() => mockServerApi.getServerVersion()).thenAnswer( when(
(_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0), () => mockServerApi.getServerVersion(),
); ).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0));
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
+4
View File
@@ -22,6 +22,7 @@ import 'schema_v16.dart' as v16;
import 'schema_v17.dart' as v17; import 'schema_v17.dart' as v17;
import 'schema_v18.dart' as v18; import 'schema_v18.dart' as v18;
import 'schema_v19.dart' as v19; import 'schema_v19.dart' as v19;
import 'schema_v20.dart' as v20;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -65,6 +66,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v18.DatabaseAtV18(db); return v18.DatabaseAtV18(db);
case 19: case 19:
return v19.DatabaseAtV19(db); return v19.DatabaseAtV19(db);
case 20:
return v20.DatabaseAtV20(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@@ -90,5 +93,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
17, 17,
18, 18,
19, 19,
20,
]; ];
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,244 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import '../../medium/repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late DriftBackupRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = DriftBackupRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('getAllCounts', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
});
test('returns zeros when no albums exist', () async {
final result = await sut.getAllCounts(userId);
expect(result.total, 0);
expect(result.remainder, 0);
expect(result.processing, 0);
});
test('returns zeros when no selected albums exist', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 0);
expect(result.remainder, 0);
expect(result.processing, 0);
});
test('counts asset in selected album as total and remainder', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 1);
expect(result.processing, 0);
});
test('backed up asset reduces remainder', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: userId);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 0);
expect(result.processing, 0);
});
test('asset with null checksum is counted as processing', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 1);
expect(result.processing, 1);
});
test('asset in excluded album is not counted even if also in selected album', () async {
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final excludedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 0);
expect(result.remainder, 0);
});
test('counts assets across multiple selected albums without duplicates', () async {
final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
// Same asset in two selected albums
await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
});
test('backed up asset for different user is still counted as remainder', () async {
final otherUser = await ctx.newUser();
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: otherUser.id);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 1);
});
test('mixed assets produce correct combined counts', () async {
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
// backed up
final remote1 = await ctx.newRemoteAsset(ownerId: userId);
final local1 = await ctx.newLocalAsset(checksum: remote1.checksum);
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local1.id);
// not backed up, has checksum
final local2 = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local2.id);
// processing (null checksum)
final local3 = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local3.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 3);
expect(result.remainder, 2); // local2 + local3
expect(result.processing, 1); // local3
});
});
group('getCandidates', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
});
test('returns empty list when no selected albums exist', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('returns asset in selected album that is not backed up', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, asset.id);
});
test('excludes asset already backed up for the same user', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: userId);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('includes asset backed up for a different user', () async {
final otherUser = await ctx.newUser();
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: otherUser.id);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, local.id);
});
test('excludes asset in excluded album even if also in selected album', () async {
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final excludedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('excludes asset with null checksum when onlyHashed is true', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('includes asset with null checksum when onlyHashed is false', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId, onlyHashed: false);
expect(result.length, 1);
expect(result.first.id, asset.id);
});
test('returns assets ordered by createdAt descending', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset1 = await ctx.newLocalAsset(createdAt: DateTime(2024, 1, 1));
final asset2 = await ctx.newLocalAsset(createdAt: DateTime(2024, 3, 1));
final asset3 = await ctx.newLocalAsset(createdAt: DateTime(2024, 2, 1));
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset1.id);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset2.id);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset3.id);
final result = await sut.getCandidates(userId);
expect(result.map((a) => a.id).toList(), [asset2.id, asset3.id, asset1.id]);
});
test('does not return duplicate when asset is in multiple selected albums', () async {
final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, asset.id);
});
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,210 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import '../../medium/repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late DriftRemoteAlbumRepository sut;
setUp(() async {
ctx = MediumRepositoryContext();
sut = DriftRemoteAlbumRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('getSortedAlbumIds', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
});
test('returns empty list when albumIds is empty', () async {
final result = await sut.getSortedAlbumIds([], aggregation: AssetDateAggregation.start);
expect(result, isEmpty);
});
test('returns single album when only one album exists', () async {
final album = await ctx.newRemoteAlbum(ownerId: userId);
final asset = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 1));
await ctx.insertRemoteAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getSortedAlbumIds([album.id], aggregation: AssetDateAggregation.start);
expect(result, [album.id]);
});
test('sorts albums by start date (MIN) ascending', () async {
// Album 1: Assets from Jan 10 to Jan 20 (start: Jan 10)
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id);
// Album 2: Assets from Jan 5 to Jan 15 (start: Jan 5)
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset3.id);
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset4.id);
// Album 3: Assets from Jan 25 to Jan 30 (start: Jan 25)
final album3 = await ctx.newRemoteAlbum(ownerId: userId);
final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25));
final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 30));
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset5.id);
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset6.id);
final result = await sut.getSortedAlbumIds([
album1.id,
album2.id,
album3.id,
], aggregation: AssetDateAggregation.start);
// Expected order: album2 (Jan 5), album1 (Jan 10), album3 (Jan 25)
expect(result, [album2.id, album1.id, album3.id]);
});
test('sorts albums by end date (MAX) ascending', () async {
// Album 1: Assets from Jan 10 to Jan 20 (end: Jan 20)
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id);
// Album 2: Assets from Jan 5 to Jan 15 (end: Jan 15)
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset3.id);
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset4.id);
// Album 3: Assets from Jan 25 to Jan 30 (end: Jan 30)
final album3 = await ctx.newRemoteAlbum(ownerId: userId);
final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25));
final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 30));
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset5.id);
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset6.id);
final result = await sut.getSortedAlbumIds([
album1.id,
album2.id,
album3.id,
], aggregation: AssetDateAggregation.end);
// Expected order: album2 (Jan 15), album1 (Jan 20), album3 (Jan 30)
expect(result, [album2.id, album1.id, album3.id]);
});
test('handles albums with single asset', () async {
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start);
expect(result, [album2.id, album1.id]);
});
test('only returns requested album IDs in the result', () async {
// Create 3 albums
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
final album3 = await ctx.newRemoteAlbum(ownerId: userId);
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset3.id);
// Only request album1 and album3
final result = await sut.getSortedAlbumIds([album1.id, album3.id], aggregation: AssetDateAggregation.start);
// Should only return album1 and album3, not album2
expect(result, [album1.id, album3.id]);
});
test('handles albums with same date correctly', () async {
final sameDate = DateTime(2024, 1, 10);
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: sameDate);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: sameDate);
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start);
// Both albums have the same date, so both should be returned
expect(result, hasLength(2));
expect(result, containsAll([album1.id, album2.id]));
});
test('handles albums across different years', () async {
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2023, 12, 25));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
final album3 = await ctx.newRemoteAlbum(ownerId: userId);
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2025, 1, 1));
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset3.id);
final result = await sut.getSortedAlbumIds([
album1.id,
album2.id,
album3.id,
], aggregation: AssetDateAggregation.start);
expect(result, [album1.id, album2.id, album3.id]);
});
test('handles album with multiple assets correctly', () async {
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
// Album 1 has 5 assets from Jan 5 to Jan 25
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20));
final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset3.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset4.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset5.id);
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 1));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset6.id);
final resultStart = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start);
// album2 (Jan 1) should come before album1 (Jan 5)
expect(resultStart, [album2.id, album1.id]);
final resultEnd = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.end);
// album2 (Jan 1) should come before album1 (Jan 25)
expect(resultEnd, [album2.id, album1.id]);
});
});
}
@@ -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/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -72,8 +73,14 @@ void main() {
Future<void> streamChanges( Future<void> streamChanges(
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onDataCallback, Future<void> Function(List<SyncEvent>, 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 { 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) // Give the stream subscription time to start (longer delay to account for mock delay)
await Future.delayed(const Duration(milliseconds: 50)); 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)); 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)); await Future.delayed(const Duration(milliseconds: 50));
@@ -244,7 +251,7 @@ void main() {
onDataCallCount++; onDataCallCount++;
} }
final streamChangesFuture = streamChanges(onDataCallback); final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0));
await Future.delayed(const Duration(milliseconds: 50)); await Future.delayed(const Duration(milliseconds: 50));
@@ -271,7 +278,7 @@ void main() {
onDataCallCount++; onDataCallCount++;
} }
final future = streamChanges(onDataCallback); final future = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0));
errorBodyController.add(utf8.encode('{"error":"Unauthorized"}')); errorBodyController.add(utf8.encode('{"error":"Unauthorized"}'));
await errorBodyController.close(); await errorBodyController.close();
+246
View File
@@ -0,0 +1,246 @@
import 'dart:math';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.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/remote_asset_cloud_id.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/utils/option.dart';
import 'package:uuid/uuid.dart';
class MediumRepositoryContext {
final Drift db;
final Random _random = Random();
MediumRepositoryContext() : db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
Future<void> dispose() async {
await db.close();
}
static Value<T> _resolveUndefined<T>(T? plain, Option<T>? option, T fallback) {
if (plain != null) {
return Value(plain);
}
return _resolveOption(option, fallback);
}
static Value<T> _resolveOption<T>(Option<T>? option, T fallback) {
if (option != null) {
return option.fold(Value.new, Value.absent);
}
return Value(fallback);
}
Future<UserEntityData> newUser({
String? id,
String? email,
AvatarColor? avatarColor,
DateTime? profileChangedAt,
bool? hasProfileImage,
}) async {
id = id ?? const Uuid().v4();
return await db
.into(db.userEntity)
.insertReturning(
UserEntityCompanion(
id: Value(id),
email: Value(email ?? '$id@test.com'),
name: Value(email ?? 'user_$id'),
avatarColor: Value(avatarColor ?? AvatarColor.values[_random.nextInt(AvatarColor.values.length)]),
profileChangedAt: Value(profileChangedAt ?? DateTime.now()),
hasProfileImage: Value(hasProfileImage ?? false),
),
);
}
Future<RemoteAssetEntityData> newRemoteAsset({
String? id,
String? checksum,
String? ownerId,
DateTime? createdAt,
DateTime? updatedAt,
DateTime? deletedAt,
AssetType? type,
AssetVisibility? visibility,
int? durationInSeconds,
int? width,
int? height,
bool? isFavorite,
bool? isEdited,
String? livePhotoVideoId,
String? stackId,
String? thumbHash,
String? libraryId,
}) async {
id = id ?? const Uuid().v4();
createdAt = createdAt ?? DateTime.now();
return db
.into(db.remoteAssetEntity)
.insertReturning(
RemoteAssetEntityCompanion(
id: Value(id),
name: Value('remote_$id.jpg'),
checksum: Value(checksum ?? const Uuid().v4()),
type: Value(type ?? AssetType.image),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt ?? DateTime.now()),
ownerId: Value(ownerId ?? const Uuid().v4()),
visibility: Value(visibility ?? AssetVisibility.timeline),
deletedAt: Value(deletedAt),
durationInSeconds: Value(durationInSeconds ?? 0),
width: Value(width ?? _random.nextInt(1000)),
height: Value(height ?? _random.nextInt(1000)),
isFavorite: Value(isFavorite ?? false),
isEdited: Value(isEdited ?? false),
livePhotoVideoId: Value(livePhotoVideoId),
stackId: Value(stackId),
localDateTime: Value(createdAt.toLocal()),
thumbHash: Value(thumbHash ?? const Uuid().v4()),
libraryId: Value(libraryId ?? const Uuid().v4()),
),
);
}
Future<RemoteAssetCloudIdEntityData> newRemoteAssetCloudId({
String? id,
String? cloudId,
DateTime? createdAt,
DateTime? adjustmentTime,
Option<DateTime>? adjustmentTimeOption,
Option<double>? latitude,
Option<double>? longitude,
}) {
return db
.into(db.remoteAssetCloudIdEntity)
.insertReturning(
RemoteAssetCloudIdEntityCompanion(
assetId: Value(id ?? const Uuid().v4()),
cloudId: Value(cloudId ?? const Uuid().v4()),
createdAt: Value(createdAt ?? DateTime.now()),
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
latitude: _resolveOption(latitude, _random.nextDouble() * 180 - 90),
longitude: _resolveOption(longitude, _random.nextDouble() * 360 - 180),
),
);
}
Future<RemoteAlbumEntityData> newRemoteAlbum({
String? id,
String? name,
String? ownerId,
DateTime? createdAt,
DateTime? updatedAt,
String? description,
bool? isActivityEnabled,
AlbumAssetOrder? order,
String? thumbnailAssetId,
}) async {
id = id ?? const Uuid().v4();
return db
.into(db.remoteAlbumEntity)
.insertReturning(
RemoteAlbumEntityCompanion(
id: Value(id),
name: Value(name ?? 'remote_album_$id'),
ownerId: Value(ownerId ?? const Uuid().v4()),
createdAt: Value(createdAt ?? DateTime.now()),
updatedAt: Value(updatedAt ?? DateTime.now()),
description: Value(description ?? 'Description for album $id'),
isActivityEnabled: Value(isActivityEnabled ?? false),
order: Value(order ?? AlbumAssetOrder.asc),
thumbnailAssetId: Value(thumbnailAssetId),
),
);
}
Future<void> insertRemoteAlbumAsset({required String albumId, required String assetId}) {
return db
.into(db.remoteAlbumAssetEntity)
.insert(RemoteAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
}
Future<LocalAssetEntityData> newLocalAsset({
String? id,
String? name,
String? checksum,
Option<String>? checksumOption,
DateTime? createdAt,
AssetType? type,
bool? isFavorite,
String? iCloudId,
DateTime? adjustmentTime,
Option<DateTime>? adjustmentTimeOption,
double? latitude,
double? longitude,
int? width,
int? height,
int? durationInSeconds,
int? orientation,
DateTime? updatedAt,
}) async {
id = id ?? const Uuid().v4();
return db
.into(db.localAssetEntity)
.insertReturning(
LocalAssetEntityCompanion(
id: Value(id),
name: Value(name ?? 'local_$id.jpg'),
height: Value(height ?? _random.nextInt(1000)),
width: Value(width ?? _random.nextInt(1000)),
durationInSeconds: Value(durationInSeconds ?? 0),
orientation: Value(orientation ?? 0),
updatedAt: Value(updatedAt ?? DateTime.now()),
checksum: _resolveUndefined(checksum, checksumOption, const Uuid().v4()),
createdAt: Value(createdAt ?? DateTime.now()),
type: Value(type ?? AssetType.image),
isFavorite: Value(isFavorite ?? false),
iCloudId: Value(iCloudId ?? const Uuid().v4()),
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
latitude: Value(latitude ?? _random.nextDouble() * 180 - 90),
longitude: Value(longitude ?? _random.nextDouble() * 360 - 180),
),
);
}
Future<LocalAlbumEntityData> newLocalAlbum({
String? id,
String? name,
DateTime? updatedAt,
BackupSelection? backupSelection,
bool? isIosSharedAlbum,
String? linkedRemoteAlbumId,
}) {
id = id ?? const Uuid().v4();
return db
.into(db.localAlbumEntity)
.insertReturning(
LocalAlbumEntityCompanion(
id: Value(id),
name: Value(name ?? 'local_album_$id'),
updatedAt: Value(updatedAt ?? DateTime.now()),
backupSelection: Value(backupSelection ?? BackupSelection.none),
isIosSharedAlbum: Value(isIosSharedAlbum ?? false),
linkedRemoteAlbumId: Value(linkedRemoteAlbumId),
),
);
}
Future<void> newLocalAlbumAsset({required String albumId, required String assetId}) {
return db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
}
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/option.dart';
void main() {
group('Option', () {
group('constructors', () {
test('Option.some creates a Some instance', () {
const option = Option.some(42);
expect(option, isA<Some<int>>());
expect((option as Some).value, 42);
});
test('Option.none creates a None instance', () {
const option = Option<int>.none();
expect(option, isA<None<int>>());
});
test('Option.fromNullable returns Some for non-null value', () {
final option = Option.fromNullable('hello');
expect(option, isA<Some<String>>());
expect((option as Some).value, 'hello');
});
test('Option.fromNullable returns None for null value', () {
final option = Option.fromNullable(null);
expect(option, isA<None>());
});
});
group('isSome / isNone', () {
test('Some.isSome is true', () {
expect(const Option.some(1).isSome, isTrue);
});
test('Some.isNone is false', () {
expect(const Option.some(1).isNone, isFalse);
});
test('None.isSome is false', () {
expect(const Option.none().isSome, isFalse);
});
test('None.isNone is true', () {
expect(const Option.none().isNone, isTrue);
});
});
group('unwrapOrNull', () {
test('returns value for Some', () {
expect(const Option.some('hi').unwrapOrNull, 'hi');
});
test('returns null for None', () {
expect(const Option.none().unwrapOrNull, isNull);
});
});
group('fold', () {
test('calls onSome with value for Some', () {
final result = const Option.some('world').fold((v) => 'some: $v', () => 'none');
expect(result, 'some: world');
});
test('calls onNone for None', () {
final result = const Option.none().fold((v) => 'some: $v', () => 'none');
expect(result, 'none');
});
});
group('equality', () {
test('Some equals Some with same value', () {
expect(const Option.some(1) == const Option.some(1), isTrue);
});
test('Some does not equal Some with different value', () {
expect(const Option.some(1) == const Option.some(2), isFalse);
});
test('None equals None of same type', () {
expect(const Option<int>.none() == const Option<int>.none(), isTrue);
});
test('None does not equal None of different type', () {
expect(const Option<int>.none() == (const Option<String>.none() as Object), isFalse);
});
test('Some does not equal None', () {
expect(const Option.some(0) == const Option.none(), isFalse);
});
});
group('hashCode', () {
test('Some hashCode equals value hashCode', () {
expect(const Option.some('abc').hashCode, 'abc'.hashCode);
});
test('None hashCode is 0', () {
expect(const Option.none().hashCode, 0);
});
});
});
group('ObjectOptionExtension', () {
test('non-null value.toOption() returns Some', () {
final option = 'hello'.toOption();
expect(option, isA<Some<String>>());
expect((option as Some).value, 'hello');
});
test('null value.toOption() returns None', () {
const String? value = null;
final option = value.toOption();
expect(option, isA<None<String>>());
});
});
}
+156 -88
View File
@@ -3993,7 +3993,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/AssetEditsDto" "$ref": "#/components/schemas/AssetEditsResponseDto"
} }
} }
}, },
@@ -4046,7 +4046,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/AssetEditActionListDto" "$ref": "#/components/schemas/AssetEditsCreateDto"
} }
} }
}, },
@@ -4057,7 +4057,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/AssetEditsDto" "$ref": "#/components/schemas/AssetEditsResponseDto"
} }
} }
}, },
@@ -16376,7 +16376,7 @@
], ],
"type": "string" "type": "string"
}, },
"AssetEditActionCrop": { "AssetEditActionItemDto": {
"properties": { "properties": {
"action": { "action": {
"allOf": [ "allOf": [
@@ -16387,7 +16387,18 @@
"description": "Type of edit action to perform" "description": "Type of edit action to perform"
}, },
"parameters": { "parameters": {
"anyOf": [
{
"$ref": "#/components/schemas/CropParameters" "$ref": "#/components/schemas/CropParameters"
},
{
"$ref": "#/components/schemas/RotateParameters"
},
{
"$ref": "#/components/schemas/MirrorParameters"
}
],
"description": "List of edit actions to apply (crop, rotate, or mirror)"
} }
}, },
"required": [ "required": [
@@ -16396,30 +16407,48 @@
], ],
"type": "object" "type": "object"
}, },
"AssetEditActionListDto": { "AssetEditActionItemResponseDto": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
],
"description": "Type of edit action to perform"
},
"id": {
"format": "uuid",
"type": "string"
},
"parameters": {
"anyOf": [
{
"$ref": "#/components/schemas/CropParameters"
},
{
"$ref": "#/components/schemas/RotateParameters"
},
{
"$ref": "#/components/schemas/MirrorParameters"
}
],
"description": "List of edit actions to apply (crop, rotate, or mirror)"
}
},
"required": [
"action",
"id",
"parameters"
],
"type": "object"
},
"AssetEditsCreateDto": {
"properties": { "properties": {
"edits": { "edits": {
"description": "List of edit actions to apply (crop, rotate, or mirror)", "description": "List of edit actions to apply (crop, rotate, or mirror)",
"items": { "items": {
"anyOf": [ "$ref": "#/components/schemas/AssetEditActionItemDto"
{
"$ref": "#/components/schemas/AssetEditActionCrop"
},
{
"$ref": "#/components/schemas/AssetEditActionRotate"
},
{
"$ref": "#/components/schemas/AssetEditActionMirror"
}
],
"discriminator": {
"mapping": {
"crop": "#/components/schemas/AssetEditActionCrop",
"mirror": "#/components/schemas/AssetEditActionMirror",
"rotate": "#/components/schemas/AssetEditActionRotate"
},
"propertyName": "action"
}
}, },
"minItems": 1, "minItems": 1,
"type": "array" "type": "array"
@@ -16430,77 +16459,18 @@
], ],
"type": "object" "type": "object"
}, },
"AssetEditActionMirror": { "AssetEditsResponseDto": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
],
"description": "Type of edit action to perform"
},
"parameters": {
"$ref": "#/components/schemas/MirrorParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"AssetEditActionRotate": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
],
"description": "Type of edit action to perform"
},
"parameters": {
"$ref": "#/components/schemas/RotateParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"AssetEditsDto": {
"properties": { "properties": {
"assetId": { "assetId": {
"description": "Asset ID to apply edits to", "description": "Asset ID these edits belong to",
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
}, },
"edits": { "edits": {
"description": "List of edit actions to apply (crop, rotate, or mirror)", "description": "List of edit actions applied to the asset",
"items": { "items": {
"anyOf": [ "$ref": "#/components/schemas/AssetEditActionItemResponseDto"
{
"$ref": "#/components/schemas/AssetEditActionCrop"
}, },
{
"$ref": "#/components/schemas/AssetEditActionRotate"
},
{
"$ref": "#/components/schemas/AssetEditActionMirror"
}
],
"discriminator": {
"mapping": {
"crop": "#/components/schemas/AssetEditActionCrop",
"mirror": "#/components/schemas/AssetEditActionMirror",
"rotate": "#/components/schemas/AssetEditActionRotate"
},
"propertyName": "action"
}
},
"minItems": 1,
"type": "array" "type": "array"
} }
}, },
@@ -17394,7 +17364,7 @@
"type": "array" "type": "array"
}, },
"thumbhash": { "thumbhash": {
"description": "Thumbhash for thumbnail generation", "description": "Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.",
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
@@ -19061,6 +19031,22 @@
"data": { "data": {
"$ref": "#/components/schemas/OnThisDayDto" "$ref": "#/components/schemas/OnThisDayDto"
}, },
"hideAt": {
"description": "Date when memory should be hidden",
"format": "date-time",
"type": "string",
"x-immich-history": [
{
"version": "v2.6.0",
"state": "Added"
},
{
"version": "v2.6.0",
"state": "Stable"
}
],
"x-immich-state": "Stable"
},
"isSaved": { "isSaved": {
"description": "Is memory saved", "description": "Is memory saved",
"type": "boolean" "type": "boolean"
@@ -19075,6 +19061,22 @@
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
"showAt": {
"description": "Date when memory should be shown",
"format": "date-time",
"type": "string",
"x-immich-history": [
{
"version": "v2.6.0",
"state": "Added"
},
{
"version": "v2.6.0",
"state": "Stable"
}
],
"x-immich-state": "Stable"
},
"type": { "type": {
"allOf": [ "allOf": [
{ {
@@ -23224,6 +23226,70 @@
], ],
"type": "object" "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": { "SyncAssetMetadataDeleteV1": {
"properties": { "properties": {
"assetId": { "assetId": {
@@ -23517,6 +23583,7 @@
"PersonV1", "PersonV1",
"PersonDeleteV1", "PersonDeleteV1",
"AssetFaceV1", "AssetFaceV1",
"AssetFaceV2",
"AssetFaceDeleteV1", "AssetFaceDeleteV1",
"UserMetadataV1", "UserMetadataV1",
"UserMetadataDeleteV1", "UserMetadataDeleteV1",
@@ -23790,6 +23857,7 @@
"UsersV1", "UsersV1",
"PeopleV1", "PeopleV1",
"AssetFacesV1", "AssetFacesV1",
"AssetFacesV2",
"UserMetadataV1" "UserMetadataV1"
], ],
"type": "string" "type": "string"
+48 -24
View File
@@ -630,7 +630,7 @@ export type AssetResponseDto = {
resized?: boolean; resized?: boolean;
stack?: (AssetStackResponseDto) | null; stack?: (AssetStackResponseDto) | null;
tags?: TagResponseDto[]; tags?: TagResponseDto[];
/** Thumbhash for thumbnail generation */ /** Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting. */
thumbhash: string | null; thumbhash: string | null;
/** Asset type */ /** Asset type */
"type": AssetTypeEnum; "type": AssetTypeEnum;
@@ -973,38 +973,36 @@ export type CropParameters = {
/** Top-Left Y coordinate of crop */ /** Top-Left Y coordinate of crop */
y: number; y: number;
}; };
export type AssetEditActionCrop = {
/** Type of edit action to perform */
action: AssetEditAction;
parameters: CropParameters;
};
export type RotateParameters = { export type RotateParameters = {
/** Rotation angle in degrees */ /** Rotation angle in degrees */
angle: number; angle: number;
}; };
export type AssetEditActionRotate = {
/** Type of edit action to perform */
action: AssetEditAction;
parameters: RotateParameters;
};
export type MirrorParameters = { export type MirrorParameters = {
/** Axis to mirror along */ /** Axis to mirror along */
axis: MirrorAxis; axis: MirrorAxis;
}; };
export type AssetEditActionMirror = { export type AssetEditActionItemResponseDto = {
/** Type of edit action to perform */ /** Type of edit action to perform */
action: AssetEditAction; action: AssetEditAction;
parameters: MirrorParameters; id: string;
/** List of edit actions to apply (crop, rotate, or mirror) */
parameters: CropParameters | RotateParameters | MirrorParameters;
}; };
export type AssetEditsDto = { export type AssetEditsResponseDto = {
/** Asset ID to apply edits to */ /** Asset ID these edits belong to */
assetId: string; assetId: string;
/** List of edit actions to apply (crop, rotate, or mirror) */ /** List of edit actions applied to the asset */
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[]; edits: AssetEditActionItemResponseDto[];
}; };
export type AssetEditActionListDto = { export type AssetEditActionItemDto = {
/** Type of edit action to perform */
action: AssetEditAction;
/** List of edit actions to apply (crop, rotate, or mirror) */ /** List of edit actions to apply (crop, rotate, or mirror) */
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[]; parameters: CropParameters | RotateParameters | MirrorParameters;
};
export type AssetEditsCreateDto = {
/** List of edit actions to apply (crop, rotate, or mirror) */
edits: AssetEditActionItemDto[];
}; };
export type AssetMetadataResponseDto = { export type AssetMetadataResponseDto = {
/** Metadata key */ /** Metadata key */
@@ -1421,12 +1419,16 @@ export type MemoryCreateDto = {
/** Asset IDs to associate with memory */ /** Asset IDs to associate with memory */
assetIds?: string[]; assetIds?: string[];
data: OnThisDayDto; data: OnThisDayDto;
/** Date when memory should be hidden */
hideAt?: string;
/** Is memory saved */ /** Is memory saved */
isSaved?: boolean; isSaved?: boolean;
/** Memory date */ /** Memory date */
memoryAt: string; memoryAt: string;
/** Date when memory was seen */ /** Date when memory was seen */
seenAt?: string; seenAt?: string;
/** Date when memory should be shown */
showAt?: string;
/** Memory type */ /** Memory type */
"type": MemoryType; "type": MemoryType;
}; };
@@ -3069,6 +3071,26 @@ export type SyncAssetFaceV1 = {
/** Source type */ /** Source type */
sourceType: string; 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 = { export type SyncAssetMetadataDeleteV1 = {
/** Asset ID */ /** Asset ID */
assetId: string; assetId: string;
@@ -4228,7 +4250,7 @@ export function getAssetEdits({ id }: {
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetEditsDto; data: AssetEditsResponseDto;
}>(`/assets/${encodeURIComponent(id)}/edits`, { }>(`/assets/${encodeURIComponent(id)}/edits`, {
...opts ...opts
})); }));
@@ -4236,17 +4258,17 @@ export function getAssetEdits({ id }: {
/** /**
* Apply edits to an existing asset * Apply edits to an existing asset
*/ */
export function editAsset({ id, assetEditActionListDto }: { export function editAsset({ id, assetEditsCreateDto }: {
id: string; id: string;
assetEditActionListDto: AssetEditActionListDto; assetEditsCreateDto: AssetEditsCreateDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetEditsDto; data: AssetEditsResponseDto;
}>(`/assets/${encodeURIComponent(id)}/edits`, oazapfts.json({ }>(`/assets/${encodeURIComponent(id)}/edits`, oazapfts.json({
...opts, ...opts,
method: "PUT", method: "PUT",
body: assetEditActionListDto body: assetEditsCreateDto
}))); })));
} }
/** /**
@@ -7367,6 +7389,7 @@ export enum SyncEntityType {
PersonV1 = "PersonV1", PersonV1 = "PersonV1",
PersonDeleteV1 = "PersonDeleteV1", PersonDeleteV1 = "PersonDeleteV1",
AssetFaceV1 = "AssetFaceV1", AssetFaceV1 = "AssetFaceV1",
AssetFaceV2 = "AssetFaceV2",
AssetFaceDeleteV1 = "AssetFaceDeleteV1", AssetFaceDeleteV1 = "AssetFaceDeleteV1",
UserMetadataV1 = "UserMetadataV1", UserMetadataV1 = "UserMetadataV1",
UserMetadataDeleteV1 = "UserMetadataDeleteV1", UserMetadataDeleteV1 = "UserMetadataDeleteV1",
@@ -7394,6 +7417,7 @@ export enum SyncRequestType {
UsersV1 = "UsersV1", UsersV1 = "UsersV1",
PeopleV1 = "PeopleV1", PeopleV1 = "PeopleV1",
AssetFacesV1 = "AssetFacesV1", AssetFacesV1 = "AssetFacesV1",
AssetFacesV2 = "AssetFacesV2",
UserMetadataV1 = "UserMetadataV1" UserMetadataV1 = "UserMetadataV1"
} }
export enum TranscodeHWAccel { export enum TranscodeHWAccel {
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "2.5.6", "version": "2.5.6",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"private": true, "private": true,
"packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc", "packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
"engines": { "engines": {
"pnpm": ">=10.0.0" "pnpm": ">=10.0.0"
} }
+1056 -934
View File
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -35,6 +35,7 @@
}, },
"dependencies": { "dependencies": {
"@extism/extism": "2.0.0-rc13", "@extism/extism": "2.0.0-rc13",
"@immich/sql-tools": "^0.2.0",
"@nestjs/bullmq": "^11.0.1", "@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4", "@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4", "@nestjs/core": "^11.0.4",
@@ -45,14 +46,14 @@
"@nestjs/websockets": "^11.0.4", "@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.211.0", "@opentelemetry/exporter-prometheus": "^0.212.0",
"@opentelemetry/instrumentation-http": "^0.211.0", "@opentelemetry/instrumentation-http": "^0.212.0",
"@opentelemetry/instrumentation-ioredis": "^0.59.0", "@opentelemetry/instrumentation-ioredis": "^0.60.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.57.0", "@opentelemetry/instrumentation-nestjs-core": "^0.58.0",
"@opentelemetry/instrumentation-pg": "^0.63.0", "@opentelemetry/instrumentation-pg": "^0.64.0",
"@opentelemetry/resources": "^2.0.1", "@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.211.0", "@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0", "@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2", "@react-email/render": "^1.1.2",
@@ -70,7 +71,7 @@
"cookie": "^1.0.2", "cookie": "^1.0.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cron": "4.4.0", "cron": "4.4.0",
"exiftool-vendored": "^34.3.0", "exiftool-vendored": "^35.0.0",
"express": "^5.1.0", "express": "^5.1.0",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
@@ -116,7 +117,7 @@
"validator": "^13.12.0" "validator": "^13.12.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.8.0", "@eslint/js": "^10.0.0",
"@nestjs/cli": "^11.0.2", "@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.4", "@nestjs/testing": "^11.0.4",
@@ -146,11 +147,11 @@
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@types/validator": "^13.15.2", "@types/validator": "^13.15.2",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"eslint": "^9.14.0", "eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0", "eslint-plugin-unicorn": "^63.0.0",
"globals": "^16.0.0", "globals": "^17.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"node-gyp": "^12.0.0", "node-gyp": "^12.0.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
+3 -5
View File
@@ -1,16 +1,15 @@
#!/usr/bin/env node #!/usr/bin/env node
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich'; 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 { Kysely, sql } from 'kysely';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { basename, dirname, extname, join } from 'node:path'; import { basename, dirname, extname, join } from 'node:path';
import postgres from 'postgres';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository'; import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema'; import 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools'; import { getKyselyConfig } from 'src/utils/database';
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
const main = async () => { const main = async () => {
const command = process.argv[2]; const command = process.argv[2];
@@ -130,10 +129,9 @@ const create = (path: string, up: string[], down: string[]) => {
const compare = async () => { const compare = async () => {
const configRepository = new ConfigRepository(); const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv(); const { database } = configRepository.getEnv();
const db = postgres(asPostgresConnectionConfig(database.config));
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); 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')); console.log(source.warnings.join('\n'));
+1 -1
View File
@@ -1,7 +1,7 @@
import { asHuman } from '@immich/sql-tools';
import { Command, CommandRunner } from 'nest-commander'; import { Command, CommandRunner } from 'nest-commander';
import { ErrorMessages } from 'src/constants'; import { ErrorMessages } from 'src/constants';
import { CliService } from 'src/services/cli.service'; import { CliService } from 'src/services/cli.service';
import { asHuman } from 'src/sql-tools/schema-diff';
@Command({ @Command({
name: 'schema-check', name: 'schema-check',
@@ -369,6 +369,31 @@ describe(AssetController.name, () => {
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
}); });
it('should check the action and parameters discriminator', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/edits`)
.send({
edits: [
{
action: 'rotate',
parameters: {
x: 0,
y: 0,
width: 100,
height: 100,
},
},
],
});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('parameters.angle must be one of the following values')]),
),
);
});
it('should require at least one edit', async () => { it('should require at least one edit', async () => {
const { status, body } = await request(ctx.getHttpServer()) const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/edits`) .put(`/assets/${factory.uuid()}/edits`)
+4 -4
View File
@@ -20,7 +20,7 @@ import {
UpdateAssetDto, UpdateAssetDto,
} from 'src/dtos/asset.dto'; } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto'; import { AssetEditsCreateDto, AssetEditsResponseDto } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ApiTag, Permission, RouteKey } from 'src/enum'; import { ApiTag, Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
@@ -235,7 +235,7 @@ export class AssetController {
description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.', description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.',
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'), history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
}) })
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsDto> { getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsResponseDto> {
return this.service.getAssetEdits(auth, id); return this.service.getAssetEdits(auth, id);
} }
@@ -249,8 +249,8 @@ export class AssetController {
editAsset( editAsset(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Body() dto: AssetEditActionListDto, @Body() dto: AssetEditsCreateDto,
): Promise<AssetEditsDto> { ): Promise<AssetEditsResponseDto> {
return this.service.editAsset(auth, id, dto); return this.service.editAsset(auth, id, dto);
} }
@@ -51,6 +51,20 @@ describe(MemoryController.name, () => {
errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']), errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']),
); );
}); });
it('should accept showAt and hideAt', async () => {
const { status } = await request(ctx.getHttpServer())
.post('/memories')
.send({
type: 'on_this_day',
data: { year: 2020 },
memoryAt: new Date(2021).toISOString(),
showAt: new Date(2022).toISOString(),
hideAt: new Date(2023).toISOString(),
});
expect(status).toBe(201);
});
}); });
describe('GET /memories/statistics', () => { describe('GET /memories/statistics', () => {
+1
View File
@@ -352,6 +352,7 @@ export const columns = {
'asset_file.type', 'asset_file.type',
'asset_file.isEdited', 'asset_file.isEdited',
'asset_file.isProgressive', 'asset_file.isProgressive',
'asset_file.isTransparent',
], ],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'], authApiKey: ['api_key.id', 'api_key.permissions'],
+1 -1
View File
@@ -1,10 +1,10 @@
import { BeforeUpdateTrigger, Column, ColumnOptions } from '@immich/sql-tools';
import { SetMetadata, applyDecorators } from '@nestjs/common'; import { SetMetadata, applyDecorators } from '@nestjs/common';
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
import _ from 'lodash'; import _ from 'lodash';
import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { EmitEvent } from 'src/repositories/event.repository'; import { EmitEvent } from 'src/repositories/event.repository';
import { immich_uuid_v7, updated_at } from 'src/schema/functions'; import { immich_uuid_v7, updated_at } from 'src/schema/functions';
import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools';
import { setUnion } from 'src/utils/set'; import { setUnion } from 'src/utils/set';
const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) => const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
+4 -1
View File
@@ -25,7 +25,10 @@ export class SanitizedAssetResponseDto {
id!: string; id!: string;
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', description: 'Asset type' }) @ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', description: 'Asset type' })
type!: AssetType; type!: AssetType;
@ApiProperty({ description: 'Thumbhash for thumbnail generation' }) @ApiProperty({
description:
'Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.',
})
thumbhash!: string | null; thumbhash!: string | null;
@ApiPropertyOptional({ description: 'Original MIME type' }) @ApiPropertyOptional({ description: 'Original MIME type' })
originalMimeType?: string; originalMimeType?: string;
+44 -68
View File
@@ -1,7 +1,8 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator'; import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation'; import { ExtraModel } from 'src/dtos/sync.dto';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation';
export enum AssetEditAction { export enum AssetEditAction {
Crop = 'crop', Crop = 'crop',
@@ -14,6 +15,7 @@ export enum MirrorAxis {
Vertical = 'vertical', Vertical = 'vertical',
} }
@ExtraModel()
export class CropParameters { export class CropParameters {
@IsInt() @IsInt()
@Min(0) @Min(0)
@@ -36,48 +38,21 @@ export class CropParameters {
height!: number; height!: number;
} }
@ExtraModel()
export class RotateParameters { export class RotateParameters {
@IsAxisAlignedRotation() @IsAxisAlignedRotation()
@ApiProperty({ description: 'Rotation angle in degrees' }) @ApiProperty({ description: 'Rotation angle in degrees' })
angle!: number; angle!: number;
} }
@ExtraModel()
export class MirrorParameters { export class MirrorParameters {
@IsEnum(MirrorAxis) @IsEnum(MirrorAxis)
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' }) @ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
axis!: MirrorAxis; axis!: MirrorAxis;
} }
class AssetEditActionBase { export type AssetEditParameters = CropParameters | RotateParameters | MirrorParameters;
@IsEnum(AssetEditAction)
@ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction', description: 'Type of edit action to perform' })
action!: AssetEditAction;
}
export class AssetEditActionCrop extends AssetEditActionBase {
@ValidateNested()
@Type(() => CropParameters)
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
parameters!: CropParameters;
}
export class AssetEditActionRotate extends AssetEditActionBase {
@ValidateNested()
@Type(() => RotateParameters)
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
parameters!: RotateParameters;
}
export class AssetEditActionMirror extends AssetEditActionBase {
@ValidateNested()
@Type(() => MirrorParameters)
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
parameters!: MirrorParameters;
}
export type AssetEditActionItem = export type AssetEditActionItem =
| { | {
action: AssetEditAction.Crop; action: AssetEditAction.Crop;
@@ -92,47 +67,48 @@ export type AssetEditActionItem =
parameters: MirrorParameters; parameters: MirrorParameters;
}; };
export type AssetEditActionParameter = { export class AssetEditActionItemDto {
[AssetEditAction.Crop]: CropParameters; @ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' })
[AssetEditAction.Rotate]: RotateParameters; action!: AssetEditAction;
[AssetEditAction.Mirror]: MirrorParameters;
@ApiProperty({
description: 'List of edit actions to apply (crop, rotate, or mirror)',
anyOf: [CropParameters, RotateParameters, MirrorParameters].map((type) => ({
$ref: getSchemaPath(type),
})),
})
@ValidateNested()
@Type((options) => actionParameterMap[options?.object.action as keyof AssetEditActionParameter])
parameters!: AssetEditActionItem['parameters'];
}
export class AssetEditActionItemResponseDto extends AssetEditActionItemDto {
@ValidateUUID()
id!: string;
}
export type AssetEditActionParameter = typeof actionParameterMap;
const actionParameterMap = {
[AssetEditAction.Crop]: CropParameters,
[AssetEditAction.Rotate]: RotateParameters,
[AssetEditAction.Mirror]: MirrorParameters,
}; };
type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror; export class AssetEditsCreateDto {
const actionToClass: Record<AssetEditAction, ClassConstructor<AssetEditActions>> = {
[AssetEditAction.Crop]: AssetEditActionCrop,
[AssetEditAction.Rotate]: AssetEditActionRotate,
[AssetEditAction.Mirror]: AssetEditActionMirror,
} as const;
const getActionClass = (item: { action: AssetEditAction }): ClassConstructor<AssetEditActions> =>
actionToClass[item.action];
@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop)
export class AssetEditActionListDto {
/** list of edits */
@ArrayMinSize(1) @ArrayMinSize(1)
@IsUniqueEditActions() @IsUniqueEditActions()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Transform(({ value: edits }) => @Type(() => AssetEditActionItemDto)
Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits, @ApiProperty({ description: 'List of edit actions to apply (crop, rotate, or mirror)' })
) edits!: AssetEditActionItemDto[];
@ApiProperty({
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[];
} }
export class AssetEditsDto extends AssetEditActionListDto { export class AssetEditsResponseDto {
@ValidateUUID({ description: 'Asset ID to apply edits to' }) @ValidateUUID({ description: 'Asset ID these edits belong to' })
assetId!: string; assetId!: string;
@ApiProperty({
description: 'List of edit actions applied to the asset',
})
edits!: AssetEditActionItemResponseDto[];
} }
+10 -1
View File
@@ -1,8 +1,17 @@
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, IsString, Matches } from 'class-validator'; 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'; 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 { export class EnvDto {
@IsInt() @IsInt()
@Optional() @Optional()
+15
View File
@@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator'; import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { Memory } from 'src/database'; import { Memory } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOrderWithRandom, MemoryType } from 'src/enum'; import { AssetOrderWithRandom, MemoryType } from 'src/enum';
@@ -77,6 +78,20 @@ export class MemoryCreateDto extends MemoryBaseDto {
@ValidateDate({ description: 'Memory date' }) @ValidateDate({ description: 'Memory date' })
memoryAt!: Date; memoryAt!: Date;
@ValidateDate({
optional: true,
description: 'Date when memory should be shown',
history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'),
})
showAt?: Date;
@ValidateDate({
optional: true,
description: 'Date when memory should be hidden',
history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'),
})
hideAt?: Date;
@ValidateUUID({ optional: true, each: true, description: 'Asset IDs to associate with memory' }) @ValidateUUID({ optional: true, each: true, description: 'Asset IDs to associate with memory' })
assetIds?: string[]; assetIds?: string[];
} }
+15
View File
@@ -422,6 +422,20 @@ export class SyncAssetFaceV1 {
sourceType!: string; 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() @ExtraModel()
export class SyncAssetFaceDeleteV1 { export class SyncAssetFaceDeleteV1 {
@ApiProperty({ description: 'Asset face ID' }) @ApiProperty({ description: 'Asset face ID' })
@@ -497,6 +511,7 @@ export type SyncItem = {
[SyncEntityType.PersonV1]: SyncPersonV1; [SyncEntityType.PersonV1]: SyncPersonV1;
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
[SyncEntityType.AssetFaceV1]: SyncAssetFaceV1; [SyncEntityType.AssetFaceV1]: SyncAssetFaceV1;
[SyncEntityType.AssetFaceV2]: SyncAssetFaceV2;
[SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1; [SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1;
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1; [SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1; [SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
+2 -8
View File
@@ -762,6 +762,7 @@ export enum SyncRequestType {
UsersV1 = 'UsersV1', UsersV1 = 'UsersV1',
PeopleV1 = 'PeopleV1', PeopleV1 = 'PeopleV1',
AssetFacesV1 = 'AssetFacesV1', AssetFacesV1 = 'AssetFacesV1',
AssetFacesV2 = 'AssetFacesV2',
UserMetadataV1 = 'UserMetadataV1', UserMetadataV1 = 'UserMetadataV1',
} }
@@ -820,6 +821,7 @@ export enum SyncEntityType {
PersonDeleteV1 = 'PersonDeleteV1', PersonDeleteV1 = 'PersonDeleteV1',
AssetFaceV1 = 'AssetFaceV1', AssetFaceV1 = 'AssetFaceV1',
AssetFaceV2 = 'AssetFaceV2',
AssetFaceDeleteV1 = 'AssetFaceDeleteV1', AssetFaceDeleteV1 = 'AssetFaceDeleteV1',
UserMetadataV1 = 'UserMetadataV1', UserMetadataV1 = 'UserMetadataV1',
@@ -851,14 +853,6 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretBasic = 'client_secret_basic', ClientSecretBasic = 'client_secret_basic',
} }
export enum DatabaseSslMode {
Disable = 'disable',
Allow = 'allow',
Prefer = 'prefer',
Require = 'require',
VerifyFull = 'verify-full',
}
export enum AssetVisibility { export enum AssetVisibility {
Archive = 'archive', Archive = 'archive',
Timeline = 'timeline', Timeline = 'timeline',
+2 -2
View File
@@ -52,9 +52,9 @@ class Workers {
try { try {
const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode); const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
return value?.isMaintenanceMode || false; return value?.isMaintenanceMode || false;
} catch (error) { } catch (error: Error | any) {
// Table doesn't exist (migrations haven't run yet) // Table doesn't exist (migrations haven't run yet)
if (error instanceof PostgresError && error.code === '42P01') { if ((error as PostgresError).code === '42P01') {
return false; return false;
} }
@@ -9,6 +9,7 @@ rollback
-- AssetEditRepository.getAll -- AssetEditRepository.getAll
select select
"id",
"action", "action",
"parameters" "parameters"
from from
+2 -1
View File
@@ -216,7 +216,8 @@ select
"asset_file"."path", "asset_file"."path",
"asset_file"."type", "asset_file"."type",
"asset_file"."isEdited", "asset_file"."isEdited",
"asset_file"."isProgressive" "asset_file"."isProgressive",
"asset_file"."isTransparent"
from from
"asset_file" "asset_file"
where where
+2
View File
@@ -540,6 +540,8 @@ select
"boundingBoxX2", "boundingBoxX2",
"boundingBoxY2", "boundingBoxY2",
"sourceType", "sourceType",
"isVisible",
"asset_face"."deletedAt",
"asset_face"."updateId" "asset_face"."updateId"
from from
"asset_face" as "asset_face" "asset_face" as "asset_face"
@@ -31,7 +31,7 @@ export class ApiKeyRepository {
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
getKey(hashedToken: string) { getKey(hashedToken: Buffer) {
return this.db return this.db
.selectFrom('api_key') .selectFrom('api_key')
.select((eb) => [ .select((eb) => [
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
@Injectable() @Injectable()
@@ -12,7 +12,7 @@ export class AssetEditRepository {
@GenerateSql({ @GenerateSql({
params: [DummyValue.UUID], params: [DummyValue.UUID],
}) })
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> { replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItemResponseDto[]> {
return this.db.transaction().execute(async (trx) => { return this.db.transaction().execute(async (trx) => {
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute(); await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
@@ -20,8 +20,8 @@ export class AssetEditRepository {
return trx return trx
.insertInto('asset_edit') .insertInto('asset_edit')
.values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit }))) .values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit })))
.returning(['action', 'parameters']) .returning(['id', 'action', 'parameters'])
.execute() as Promise<AssetEditActionItem[]>; .execute();
} }
return []; return [];
@@ -31,12 +31,12 @@ export class AssetEditRepository {
@GenerateSql({ @GenerateSql({
params: [DummyValue.UUID], params: [DummyValue.UUID],
}) })
getAll(assetId: string): Promise<AssetEditActionItem[]> { getAll(assetId: string): Promise<AssetEditActionItemResponseDto[]> {
return this.db return this.db
.selectFrom('asset_edit') .selectFrom('asset_edit')
.select(['action', 'parameters']) .select(['id', 'action', 'parameters'])
.where('assetId', '=', assetId) .where('assetId', '=', assetId)
.orderBy('sequence', 'asc') .orderBy('sequence', 'asc')
.execute() as Promise<AssetEditActionItem[]>; .execute();
} }
} }
+9 -2
View File
@@ -903,7 +903,10 @@ export class AssetRepository {
} }
async upsertFile( async upsertFile(
file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>, file: Pick<
Insertable<AssetFileTable>,
'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive' | 'isTransparent'
>,
): Promise<void> { ): Promise<void> {
await this.db await this.db
.insertInto('asset_file') .insertInto('asset_file')
@@ -917,7 +920,10 @@ export class AssetRepository {
} }
async upsertFiles( async upsertFiles(
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>[], files: Pick<
Insertable<AssetFileTable>,
'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive' | 'isTransparent'
>[],
): Promise<void> { ): Promise<void> {
if (files.length === 0) { if (files.length === 0) {
return; return;
@@ -930,6 +936,7 @@ export class AssetRepository {
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({ oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'), path: eb.ref('excluded.path'),
isProgressive: eb.ref('excluded.isProgressive'), isProgressive: eb.ref('excluded.isProgressive'),
isTransparent: eb.ref('excluded.isTransparent'),
})), })),
) )
.execute(); .execute();
+3 -2
View File
@@ -1,3 +1,4 @@
import { DatabaseConnectionParams } from '@immich/sql-tools';
import { RegisterQueueOptions } from '@nestjs/bullmq'; import { RegisterQueueOptions } from '@nestjs/bullmq';
import { Inject, Injectable, Optional } from '@nestjs/common'; import { Inject, Injectable, Optional } from '@nestjs/common';
import { QueueOptions } from 'bullmq'; import { QueueOptions } from 'bullmq';
@@ -21,7 +22,7 @@ import {
LogLevel, LogLevel,
QueueName, QueueName,
} from 'src/enum'; } from 'src/enum';
import { DatabaseConnectionParams, VectorExtension } from 'src/types'; import { VectorExtension } from 'src/types';
import { setDifference } from 'src/utils/set'; import { setDifference } from 'src/utils/set';
export interface EnvData { export interface EnvData {
@@ -184,7 +185,7 @@ const getEnv = (): EnvData => {
try { try {
redisConfig = JSON.parse(Buffer.from(redisUrl.slice(10), 'base64').toString()); redisConfig = JSON.parse(Buffer.from(redisUrl.slice(10), 'base64').toString());
} catch (error) { } catch (error) {
throw new Error(`Failed to decode redis options: ${error}`); throw new Error('Failed to decode redis options', { cause: error });
} }
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export class CryptoRepository {
} }
hashSha256(value: string) { hashSha256(value: string) {
return createHash('sha256').update(value).digest('base64'); return createHash('sha256').update(value).digest();
} }
verifySha256(value: string, encryptedValue: string, publicKey: string) { verifySha256(value: string, encryptedValue: string, publicKey: string) {
@@ -1,3 +1,4 @@
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import AsyncLock from 'async-lock'; import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely'; 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 { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
import { vectorIndexQuery } from 'src/utils/database'; import { vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation'; import { isValidInteger } from 'src/validation';
@@ -289,7 +289,8 @@ export class DatabaseRepository {
async getSchemaDrift() { async getSchemaDrift() {
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); 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, { const drift = schemaDiff(source, target, {
tables: { ignoreExtra: true }, tables: { ignoreExtra: true },

Some files were not shown because too many files have changed in this diff Show More