Compare commits

..

3 Commits

Author SHA1 Message Date
mertalev 4423a8f8a4 pass cancel token to icloud download 2026-05-28 01:57:39 -04:00
mertalev 77fd2ba919 more cleanup 2026-05-28 00:44:11 -04:00
mertalev 1318dafdc4 use pattern matching 2026-05-27 20:02:51 -04:00
67 changed files with 494 additions and 1028 deletions
+24 -15
View File
@@ -1,46 +1,46 @@
dev: dev:
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n" >&2 && exit 1 @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-down: dev-down:
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n" >&2 && exit 1 docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
dev-update: dev-update:
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n" >&2 && exit 1 @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale: dev-scale:
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n" >&2 && exit 1 @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
dev-docs: dev-docs:
npm --prefix docs run start npm --prefix docs run start
.PHONY: e2e .PHONY: e2e
e2e: e2e:
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n" >&2 && exit 1 @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
e2e-dev: e2e-dev:
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n" >&2 && exit 1 @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
e2e-update: e2e-update:
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n" >&2 && exit 1 @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-down: e2e-down:
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n" >&2 && exit 1 docker compose -f ./e2e/docker-compose.yml down --remove-orphans
prod: prod:
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n" >&2 && exit 1 @trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-down: prod-down:
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n" >&2 && exit 1 docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
prod-scale: prod-scale:
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n" >&2 && exit 1 @trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
.PHONY: open-api .PHONY: open-api
open-api: open-api:
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n" >&2 && exit 1 @printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n"\n\n >&2 && exit 1
sql: sql:
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n" >&2 && exit 1 @printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n"\n\n >&2 && exit 1
renovate: renovate:
@@ -52,7 +52,16 @@ renovate:
MODULES = e2e server web cli sdk docs .github MODULES = e2e server web cli sdk docs .github
test-e2e: test-e2e:
@printf "This command has been removed. Please use:\n\n mise //e2e:test # or mise //e2e:test-web for web tests, respectively\n\n" >&2 && exit 1 docker compose -f ./e2e/docker-compose.yml build
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
clean: clean:
@printf "This command has been removed. Please use:\n\n mise clean # or mise //:clean from another directory\n\n" >&2 && exit 1 find . -name "node_modules" -type d -prune -exec rm -rf {} +
find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
+1 -16
View File
@@ -1,21 +1,11 @@
[tasks.install] [tasks.install]
run = "pnpm install --filter immich-e2e --frozen-lockfile" run = "pnpm install --filter immich-e2e --frozen-lockfile"
[tasks.build]
dir = "{{ config_root }}"
run = "docker compose build"
[tasks.test] [tasks.test]
depends = ["//e2e:build", "//e2e:ci-setup"]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "vitest --run" run = "vitest --run"
[tasks.playwright-install]
env._.path = "./node_modules/.bin"
run = "playwright install"
[tasks."test-web"] [tasks."test-web"]
depends = ["//e2e:build", "//e2e:ci-setup", "//e2e:playwright-install"]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "playwright test" run = "playwright test"
@@ -40,12 +30,7 @@ run = "tsc --noEmit"
[tasks.ci-setup] [tasks.ci-setup]
depends = [ depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"]
"//:sdk:install",
"//:sdk:build",
"//packages/cli:install",
"//packages/cli:build",
]
run = { task = ":install" } run = { task = ":install" }
@@ -55,8 +55,8 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
result.duration.push(asset.duration); result.duration.push(asset.duration);
result.projectionType.push(asset.projectionType); result.projectionType.push(asset.projectionType);
result.livePhotoVideoId.push(asset.livePhotoVideoId); result.livePhotoVideoId.push(asset.livePhotoVideoId);
result.city?.push(asset.city); result.city.push(asset.city);
result.country?.push(asset.country); result.country.push(asset.country);
result.visibility.push(asset.visibility); result.visibility.push(asset.visibility);
} }
-1
View File
@@ -2233,7 +2233,6 @@
"slideshow_repeat": "Repeat slideshow", "slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings", "slideshow_settings": "Slideshow settings",
"smart_album": "Smart album",
"sort_albums_by": "Sort albums by...", "sort_albums_by": "Sort albums by...",
"sort_created": "Date created", "sort_created": "Date created",
"sort_items": "Number of items", "sort_items": "Number of items",
@@ -64,7 +64,6 @@ class TextRecognizer(InferenceModel):
rec_batch_num=max_batch_size if max_batch_size else 6, rec_batch_num=max_batch_size if max_batch_size else 6,
rec_img_shape=(3, 48, 320), rec_img_shape=(3, 48, 320),
lang_type=self.language, lang_type=self.language,
model_root_dir=self.cache_dir,
) )
) )
return session return session
+3 -18
View File
@@ -1028,12 +1028,7 @@ class TestOcr:
text_recognizer.load() text_recognizer.load()
rapid_recognizer.assert_called_once_with( rapid_recognizer.assert_called_once_with(
OcrOptions( OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
session=ort_session.return_value,
rec_batch_num=6,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
) )
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None: def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
@@ -1046,12 +1041,7 @@ class TestOcr:
text_recognizer.load() text_recognizer.load()
rapid_recognizer.assert_called_once_with( rapid_recognizer.assert_called_once_with(
OcrOptions( OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
session=ort_session.return_value,
rec_batch_num=4,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
) )
def test_ignore_other_custom_max_batch_size( def test_ignore_other_custom_max_batch_size(
@@ -1066,12 +1056,7 @@ class TestOcr:
text_recognizer.load() text_recognizer.load()
rapid_recognizer.assert_called_once_with( rapid_recognizer.assert_called_once_with(
OcrOptions( OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
session=ort_session.return_value,
rec_batch_num=6,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
) )
+2 -79
View File
@@ -54,8 +54,8 @@ lockfile = true
[tasks.plugins] [tasks.plugins]
run = [ run = [
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile", "pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core build", "pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build",
] ]
[tasks.open-api-typescript] [tasks.open-api-typescript]
@@ -84,72 +84,6 @@ run = [
dir = "server" dir = "server"
run = "node ./dist/bin/sync-sql.js" run = "node ./dist/bin/sync-sql.js"
# TODO dev, prod, and e2e should be de-duplicated by using env but for some reason I ran into issues
[tasks.dev]
depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
depends_post = "//:dev-down"
[tasks.dev-update]
run = { task = "//:dev", args = ["--build", "-V"] }
[tasks.dev-scale]
run = { task = "//:dev", args = ["--build", "-V", "--scale immich-server=3"] }
[tasks.dev-down]
dir = "docker"
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
[tasks.prod]
depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.prod.yml up --build --remove-orphans"
depends_post = "//:prod-down"
[tasks.prod-scale]
run = { task = "//:prod", args = [
"--build",
"-V",
"--scale immich-server=3",
"--scale immich-microservices",
] }
[tasks.prod-down]
dir = "docker"
run = "docker compose -f ./docker-compose.prod.yml down --remove-orphans"
[tasks.e2e]
depends = "//:plugins"
dir = "e2e"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.yml up --remove-orphans"
depends_post = "//:e2e-down"
[tasks.e2e-dev]
depends = "//:plugins"
dir = "e2e"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
depends_post = "//:e2e-dev-down"
[tasks.e2e-update]
run = { task = "//:e2e", args = ["--build", '-V'] }
[tasks.e2e-down]
dir = "e2e"
run = "docker compose -f ./docker-compose.yml down --remove-orphans"
[tasks.e2e-dev-down]
dir = "e2e"
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
# SDK tasks # SDK tasks
[tasks."sdk:install"] [tasks."sdk:install"]
dir = "packages/sdk" dir = "packages/sdk"
@@ -165,14 +99,3 @@ run = "pnpm format"
[tasks."i18n:format-fix"] [tasks."i18n:format-fix"]
run = "pnpm format:fix" run = "pnpm format:fix"
[tasks.clean]
run = [
"find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
"find . -name 'dist' -type d -prune -exec rm -rf '{}' +",
"find . -name 'build' -type d -prune -exec rm -rf '{}' +",
"find . -name '.svelte-kit' -type d -prune -exec rm -rf '{}' +",
"find . -name 'coverage' -type d -prune -exec rm -rf '{}' +",
"find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' +",
{ task = "//:*-down" },
]
@@ -143,8 +143,13 @@ class AppConfig {
}) })
as T; as T;
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> overrides) => factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> entries) {
overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value)); var config = const AppConfig();
for (final MapEntry(key: key, value: value) in entries.entries) {
config = config.write(key, value);
}
return config;
}
AppConfig write<T extends Object>(MetadataKey<T> key, T value) { AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
return switch (key) { return switch (key) {
+18 -7
View File
@@ -7,6 +7,13 @@ import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataScope {
user, // keys with this scope are deleted on logout
system;
const MetadataScope();
}
enum MetadataKey<T extends Object> { enum MetadataKey<T extends Object> {
// Theme // Theme
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)), themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
@@ -25,11 +32,14 @@ enum MetadataKey<T extends Object> {
viewerTapToNavigate<bool>(), viewerTapToNavigate<bool>(),
// Network // Network
networkAutoEndpointSwitching<bool>(), networkAutoEndpointSwitching<bool>(scope: .system),
networkPreferredWifiName<String>(), networkPreferredWifiName<String>(scope: .system),
networkLocalEndpoint<String>(), networkLocalEndpoint<String>(scope: .system),
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)), networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)), networkCustomHeaders<Map<String, String>>(
scope: .system,
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
// Album // Album
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)), albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
@@ -50,7 +60,7 @@ enum MetadataKey<T extends Object> {
timelineStorageIndicator<bool>(), timelineStorageIndicator<bool>(),
// Log // Log
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)), logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
// Map // Map
mapShowFavoriteOnly<bool>(), mapShowFavoriteOnly<bool>(),
@@ -73,9 +83,10 @@ enum MetadataKey<T extends Object> {
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)), slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)); slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final MetadataScope scope;
final _MetadataCodec<T>? _codecOverride; final _MetadataCodec<T>? _codecOverride;
const MetadataKey({_MetadataCodec<T>? codec}) : _codecOverride = codec; const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T); _MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
@@ -234,24 +234,13 @@ class RemoteAlbumService {
final pendingAdds = <Future<void>>[]; final pendingAdds = <Future<void>>[];
final localById = {for (final a in localAssets) a.id: a}; final localById = {for (final a in localAssets) a.id: a};
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = userCallbacks;
final wrappedCallbacks = UploadCallbacks( final wrappedCallbacks = UploadCallbacks(
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback( onProgress: onProgress,
'Upload progress callback failed for $localId', onICloudProgress: onICloudProgress,
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes), onError: onError,
),
onICloudProgress: (localId, progress) => _runUploadCallback(
'iCloud progress callback failed for $localId',
() => userCallbacks.onICloudProgress?.call(localId, progress),
),
onError: (localId, errorMessage) => _runUploadCallback(
'Upload error callback failed for $localId',
() => userCallbacks.onError?.call(localId, errorMessage),
),
onSuccess: (localId, remoteId) { onSuccess: (localId, remoteId) {
_runUploadCallback( onSuccess?.call(localId, remoteId);
'Upload success callback failed for $localId',
() => userCallbacks.onSuccess?.call(localId, remoteId),
);
final source = localById[localId]; final source = localById[localId];
if (source == null) { if (source == null) {
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link'); _logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
@@ -259,29 +248,22 @@ class RemoteAlbumService {
} }
pendingAdds.add( pendingAdds.add(
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source) _linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
.then<void>((added) { .then<void>((added) => addedCount += added)
addedCount += added; .onError(
}) (error, stack) =>
.catchError((Object error, StackTrace stack) { _logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack),
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack); ),
}),
); );
}, },
); );
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks); await _uploadService.uploadManual(localAssets, cancelToken: null, callbacks: wrappedCallbacks);
await Future.wait(pendingAdds); await Future.wait(pendingAdds);
return addedCount; return addedCount;
} }
void _runUploadCallback(String message, void Function() callback) { // TODO: this is a poorly designed flow; adding a "stub" just to satisfy FK constraints is hacky,
try { // it goes out of its way to insert one at a time, and it swallows errors that should be surfaced to the user.
callback();
} catch (error, stack) {
_logger.warning(message, error, stack);
}
}
/// Links a freshly-uploaded asset to an album, ensuring the local DB /// Links a freshly-uploaded asset to an album, ensuring the local DB
/// reflects the change without waiting for the next sync. We call the API /// reflects the change without waiting for the next sync. We call the API
/// (server is the source of truth), then upsert a placeholder /// (server is the source of truth), then upsert a placeholder
@@ -34,33 +34,21 @@ class MetadataRepository extends DriftDatabaseRepository {
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get()); Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
Future<void> clear(Iterable<MetadataKey> keys) async {
if (keys.isEmpty) {
return;
}
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.metadataEntity)..where((row) => row.key.isIn(names))).go();
for (final key in keys) {
_appConfig = _appConfig.write(key, defaultConfig.read(key));
}
}
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async { Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
if (value == _appConfig.read(key)) { if (value == _appConfig.read(key)) {
return; return;
} }
if (value == defaultConfig.read(key)) { if (value == defaultConfig.read(key)) {
return clear([key]); await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
} else {
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
} }
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
_appConfig = _appConfig.write(key, value); _appConfig = _appConfig.write(key, value);
} }
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -5,66 +6,54 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
typedef OnProgress = void Function(String id, double progress);
class StorageRepository { class StorageRepository {
final log = Logger('StorageRepository'); static final log = Logger('StorageRepository');
StorageRepository(); const StorageRepository();
Future<File?> getFileForAsset(String assetId) async { Future<File?> getAssetFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
File? file; return _getFileForAsset(assetId, isMotion: false, onProgress: onProgress, cancelToken: cancelToken);
final log = Logger('StorageRepository');
try {
final entity = await AssetEntity.fromId(assetId);
file = await entity?.originFile;
if (file == null) {
log.warning("Cannot get file for asset $assetId");
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("File for asset $assetId does not exist");
return null;
}
} catch (error, stackTrace) {
log.warning("Error getting file for asset $assetId", error, stackTrace);
}
return file;
} }
Future<File?> getMotionFileForAsset(LocalAsset asset) async { Future<File?> getMotionFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
File? file; return _getFileForAsset(assetId, isMotion: true, onProgress: onProgress, cancelToken: cancelToken);
final log = Logger('StorageRepository'); }
Future<File?> _getFileForAsset(
String assetId, {
bool isMotion = false,
OnProgress? onProgress,
Completer<void>? cancelToken,
}) async {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
PMProgressHandler? progressHandler;
StreamSubscription<PMProgressState>? progressSubscription;
PMCancelToken? pmCancelToken;
if (cancelToken != null) {
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) => onProgress?.call(assetId, event.progress));
pmCancelToken = PMCancelToken();
unawaited(cancelToken.future.then((_) => pmCancelToken!.cancelRequest()));
}
try { try {
final entity = await AssetEntity.fromId(asset.id); return await entity.loadFile(withSubtype: isMotion, progressHandler: progressHandler, cancelToken: pmCancelToken);
file = await entity?.originFileWithSubtype;
if (file == null) {
log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("Motion file for asset ${asset.id} does not exist");
return null;
}
} catch (error, stackTrace) { } catch (error, stackTrace) {
log.warning( log.warning("Error loading file for asset $assetId", error, stackTrace);
"Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", return null;
error, } finally {
stackTrace, unawaited(progressSubscription?.cancel());
);
} }
return file;
} }
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async { Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
final log = Logger('StorageRepository');
AssetEntity? entity; AssetEntity? entity;
try { try {
@@ -99,39 +88,7 @@ class StorageRepository {
} }
} }
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<void> clearCache() async { Future<void> clearCache() async {
final log = Logger('StorageRepository');
try { try {
await PhotoManager.clearFileCache(); await PhotoManager.clearFileCache();
} catch (error, stackTrace) { } catch (error, stackTrace) {
@@ -6,13 +6,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart'; import 'package:native_video_player/native_video_player.dart';
@@ -108,7 +108,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
try { try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id); final file = await ref.read(storageRepositoryProvider).getAssetFile(id);
if (!mounted) { if (!mounted) {
return null; return null;
} }
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
@@ -273,7 +274,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
onProgress: _handleForegroundBackupProgress, onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess, onSuccess: _handleForegroundBackupSuccess,
onError: _handleForegroundBackupError, onError: _handleForegroundBackupError,
onICloudProgress: _handleICloudProgress, onICloudProgress: CurrentPlatform.isIOS ? _handleICloudProgress : null,
), ),
); );
} }
@@ -282,7 +283,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
_cancelToken?.complete(); _cancelToken?.complete();
_cancelToken = null; _cancelToken = null;
_uploadSpeedManager.clear(); _uploadSpeedManager.clear();
state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {}); state = state.copyWith(uploadItems: const {}, iCloudDownloadProgress: const {});
} }
void _handleICloudProgress(String localAssetId, double progress) { void _handleICloudProgress(String localAssetId, double progress) {
@@ -1,4 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
final storageRepositoryProvider = Provider<StorageRepository>((ref) => StorageRepository()); final storageRepositoryProvider = Provider<StorageRepository>((ref) => const StorageRepository());
+44 -92
View File
@@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final uploadRepositoryProvider = Provider((ref) => UploadRepository()); final uploadRepositoryProvider = Provider((ref) => UploadRepository());
@@ -20,21 +19,14 @@ class UploadRepository {
void Function(TaskProgressUpdate)? onTaskProgress; void Function(TaskProgressUpdate)? onTaskProgress;
UploadRepository() { UploadRepository() {
FileDownloader().registerCallbacks( final downloader = FileDownloader();
group: kBackupGroup, for (final group in const [kBackupGroup, kBackupLivePhotoGroup, kManualUploadGroup]) {
taskStatusCallback: (update) => onUploadStatus?.call(update), downloader.registerCallbacks(
taskProgressCallback: (update) => onTaskProgress?.call(update), group: group,
); taskStatusCallback: onUploadStatus,
FileDownloader().registerCallbacks( taskProgressCallback: onTaskProgress,
group: kBackupLivePhotoGroup, );
taskStatusCallback: (update) => onUploadStatus?.call(update), }
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
} }
Future<void> enqueueBackground(UploadTask task) { Future<void> enqueueBackground(UploadTask task) {
@@ -66,28 +58,6 @@ class UploadRepository {
return FileDownloader().start(); return FileDownloader().start();
} }
Future<void> getUploadInfo() async {
final [enqueuedTasks, runningTasks, canceledTasks, waitingTasks, pausedTasks] = await Future.wait([
FileDownloader().database.allRecordsWithStatus(TaskStatus.enqueued, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.running, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.canceled, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.waitingToRetry, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup),
]);
dPrint(
() =>
"""
Upload Info:
Enqueued: ${enqueuedTasks.length}
Running: ${runningTasks.length}
Canceled: ${canceledTasks.length}
Waiting: ${waitingTasks.length}
Paused: ${pausedTasks.length}
""",
);
}
Future<UploadResult> uploadFile({ Future<UploadResult> uploadFile({
required File file, required File file,
required String originalFileName, required String originalFileName,
@@ -111,41 +81,30 @@ class UploadRepository {
baseRequest.fields.addAll(fields); baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData); baseRequest.files.add(assetRawUploadData);
final response = await NetworkRepository.client.send(baseRequest); final StreamedResponse(:statusCode, :stream) = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await response.stream.bytesToString(); final responseBodyString = await stream.bytesToString();
if (![200, 201].contains(response.statusCode)) { return switch ((statusCode, _tryJsonDecode(responseBodyString))) {
String? errorMessage; (200 || 201, {'id': String id}) => UploadSuccess(remoteAssetId: id),
(413, _) => const UploadError(statusCode: 413, message: 'File is too large to upload'),
if (response.statusCode == 413) { (_, {'message': String message}) => UploadError(statusCode: statusCode, message: message),
errorMessage = 'Error(413) File is too large to upload'; _ => UploadError(statusCode: statusCode, message: 'Upload failed with status $statusCode'),
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage); };
}
try {
final error = jsonDecode(responseBodyString);
errorMessage = error['message'] ?? error['error'];
} catch (_) {
errorMessage = responseBodyString.isNotEmpty
? responseBodyString
: 'Upload failed with status ${response.statusCode}';
}
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final responseBody = jsonDecode(responseBodyString);
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
} catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response');
}
} on RequestAbortedException { } on RequestAbortedException {
logger.warning("Upload $logContext was cancelled"); logger.warning("Upload $logContext was cancelled");
return UploadResult.cancelled(); return const UploadCancelled();
} catch (error, stackTrace) { } catch (error, stackTrace) {
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace"); logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
return UploadResult.error(errorMessage: error.toString()); return UploadError(message: error.toString());
}
}
@pragma('vm:prefer-inline')
Map? _tryJsonDecode(String s) {
try {
return (jsonDecode(s) as Map);
} catch (_) {
return null;
} }
} }
} }
@@ -180,30 +139,23 @@ class ProgressMultipartRequest extends MultipartRequest with Abortable {
} }
} }
class UploadResult { sealed class UploadResult {
final bool isSuccess; const UploadResult();
final bool isCancelled; }
final String? remoteAssetId;
final String? errorMessage; final class UploadSuccess extends UploadResult {
final String remoteAssetId;
const UploadSuccess({required this.remoteAssetId});
}
final class UploadError extends UploadResult {
final String message;
final int? statusCode; final int? statusCode;
const UploadResult({ const UploadError({required this.message, this.statusCode});
required this.isSuccess, }
required this.isCancelled,
this.remoteAssetId, final class UploadCancelled extends UploadResult {
this.errorMessage, const UploadCancelled();
this.statusCode,
});
factory UploadResult.success({required String remoteAssetId}) {
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
}
factory UploadResult.error({String? errorMessage, int? statusCode}) {
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
}
factory UploadResult.cancelled() {
return const UploadResult(isSuccess: false, isCancelled: true);
}
} }
+2 -2
View File
@@ -13,7 +13,7 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class ApiService { class ApiService {
final ApiClient _apiClient = ApiClient(basePath: ''); late ApiClient _apiClient;
late UsersApi usersApi; late UsersApi usersApi;
late AuthenticationApi authenticationApi; late AuthenticationApi authenticationApi;
@@ -54,7 +54,7 @@ class ApiService {
} }
setEndpoint(String endpoint) { setEndpoint(String endpoint) {
_apiClient.basePath = endpoint; _apiClient = ApiClient(basePath: endpoint);
_apiClient.client = NetworkRepository.client; _apiClient.client = NetworkRepository.client;
usersApi = UsersApi(_apiClient); usersApi = UsersApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient);
+1 -7
View File
@@ -110,7 +110,7 @@ class AuthService {
/// - Authentication repository data /// - Authentication repository data
/// - Current user information /// - Current user information
/// - Access token /// - Access token
/// - Server-specific endpoint configuration /// - Asset ETag
/// ///
/// All deletions are executed in parallel using [Future.wait]. /// All deletions are executed in parallel using [Future.wait].
Future<void> clearLocalData() async { Future<void> clearLocalData() async {
@@ -120,12 +120,6 @@ class AuthService {
_authRepository.clearLocalData(), _authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser), Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken), Store.delete(StoreKey.accessToken),
MetadataRepository.instance.clear(const [
.networkAutoEndpointSwitching,
.networkPreferredWifiName,
.networkLocalEndpoint,
.networkExternalEndpointList,
]),
]); ]);
} }
@@ -266,8 +266,6 @@ class BackgroundUploadService {
return null; return null;
} }
File? file;
/// iOS LivePhoto has two files: a photo and a video. /// iOS LivePhoto has two files: a photo and a video.
/// They are uploaded separately, with video file being upload first, then returned with the assetId /// They are uploaded separately, with video file being upload first, then returned with the assetId
/// The assetId is then used as a metadata for the photo file upload task. /// The assetId is then used as a metadata for the photo file upload task.
@@ -278,11 +276,9 @@ class BackgroundUploadService {
/// The cancel operation will only cancel the video group (normal group), the photo group will not /// The cancel operation will only cancel the video group (normal group), the photo group will not
/// be touched, as the video file is already uploaded. /// be touched, as the video file is already uploaded.
if (entity.isLivePhoto) { final file = await (entity.isLivePhoto
file = await _storageRepository.getMotionFileForAsset(asset); ? _storageRepository.getMotionFile(asset.id)
} else { : _storageRepository.getAssetFile(asset.id));
file = await _storageRepository.getFileForAsset(asset.id);
}
if (file == null) { if (file == null) {
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}"); _logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
@@ -330,7 +326,7 @@ class BackgroundUploadService {
return null; return null;
} }
final file = await _storageRepository.getFileForAsset(asset.id); final file = await _storageRepository.getAssetFile(asset.id);
if (file == null) { if (file == null) {
return null; return null;
} }
@@ -20,7 +20,6 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
/// Callbacks for upload progress and status updates /// Callbacks for upload progress and status updates
class UploadCallbacks { class UploadCallbacks {
@@ -99,7 +98,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset); final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi; return requireWifi && !hasWifi;
}, },
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
); );
} }
} }
@@ -125,14 +124,14 @@ class ForegroundUploadService {
continue; continue;
} }
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks); await _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks);
} }
} }
/// Manually upload picked local assets /// Manually upload picked local assets
Future<void> uploadManual( Future<void> uploadManual(
List<LocalAsset> localAssets, { List<LocalAsset> localAssets, {
Completer<void>? cancelToken, required Completer<void>? cancelToken,
UploadCallbacks callbacks = const UploadCallbacks(), UploadCallbacks callbacks = const UploadCallbacks(),
}) async { }) async {
if (localAssets.isEmpty) { if (localAssets.isEmpty) {
@@ -142,7 +141,7 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>( await _executeWithWorkerPool<LocalAsset>(
items: localAssets, items: localAssets,
cancelToken: cancelToken, cancelToken: cancelToken,
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
); );
} }
@@ -170,11 +169,11 @@ class ForegroundUploadService {
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
); );
if (result.isSuccess) { return switch (result) {
onSuccess?.call(fileId); UploadSuccess() => onSuccess?.call(fileId),
} else if (!result.isCancelled && result.errorMessage != null) { UploadError(:final message) => onError?.call(fileId, message),
onError?.call(fileId, result.errorMessage!); UploadCancelled() => null,
} };
}, },
); );
} }
@@ -216,7 +215,7 @@ class ForegroundUploadService {
final item = items[index]; final item = items[index];
if (shouldSkip?.call(item) ?? false) { if (shouldSkip != null && shouldSkip(item)) {
continue; continue;
} }
@@ -233,78 +232,48 @@ class ForegroundUploadService {
} }
Future<void> _uploadSingleAsset( Future<void> _uploadSingleAsset(
LocalAsset asset, LocalAsset asset, {
Completer<void>? cancelToken, { required Completer<void>? cancelToken,
required UploadCallbacks callbacks, required UploadCallbacks callbacks,
}) async { }) async {
File? file; final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = callbacks;
File? assetFile;
File? livePhotoFile; File? livePhotoFile;
try { try {
final entity = await _storageRepository.getAssetEntityForAsset(asset); final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) { if (entity == null) {
callbacks.onError?.call( onError?.call(
asset.localId!, asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(), CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
); );
return; return;
} }
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id); File? file;
if (entity.isLivePhoto) {
if (!isAvailableLocally && CurrentPlatform.isIOS) { file = await _storageRepository.getMotionFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
// Create progress handler for iCloud download
PMProgressHandler? progressHandler;
StreamSubscription? progressSubscription;
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) {
callbacks.onICloudProgress?.call(asset.localId!, event.progress);
});
try {
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
asset.id,
progressHandler: progressHandler,
);
}
} finally {
await progressSubscription.cancel();
}
} else {
// Get files locally
file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) { if (file == null) {
_logger.warning("Failed to get file ${asset.id} - ${asset.name}"); _logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
callbacks.onError?.call( onError?.call(
asset.localId!, asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(), CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
); );
return; return;
} }
livePhotoFile = file;
// For live photos, get the motion video file
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
if (livePhotoFile == null) {
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
}
}
} }
file = await _storageRepository.getAssetFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
if (file == null) { if (file == null) {
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}"); _logger.warning("Failed to get file ${asset.id} - ${asset.name}");
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t()); onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return; return;
} }
assetFile = file;
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
@@ -330,11 +299,9 @@ class ForegroundUploadService {
}; };
// Upload live photo video first if available // Upload live photo video first if available
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) { if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path)); final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final onProgress = callbacks.onProgress;
final livePhotoResult = await _uploadRepository.uploadFile( final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile, file: livePhotoFile,
originalFileName: livePhotoTitle, originalFileName: livePhotoTitle,
@@ -346,15 +313,16 @@ class ForegroundUploadService {
logContext: 'livePhotoVideo[${asset.localId}]', logContext: 'livePhotoVideo[${asset.localId}]',
); );
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) { switch (livePhotoResult) {
livePhotoVideoId = livePhotoResult.remoteAssetId; case UploadSuccess(:final remoteAssetId):
fields['livePhotoVideoId'] = remoteAssetId;
case UploadError(:final message):
onError?.call(asset.localId!, "Failed to upload live photo video: $message");
return;
case UploadCancelled():
} }
} }
if (livePhotoVideoId != null) {
fields['livePhotoVideoId'] = livePhotoVideoId;
}
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image. // Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if (CurrentPlatform.isIOS && asset.cloudId != null) { if (CurrentPlatform.isIOS && asset.cloudId != null) {
fields['metadata'] = jsonEncode([ fields['metadata'] = jsonEncode([
@@ -371,7 +339,6 @@ class ForegroundUploadService {
]); ]);
} }
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile( final result = await _uploadRepository.uploadFile(
file: file, file: file,
originalFileName: originalFileName, originalFileName: originalFileName,
@@ -383,34 +350,33 @@ class ForegroundUploadService {
logContext: 'asset[${asset.localId}]', logContext: 'asset[${asset.localId}]',
); );
if (result.isSuccess && result.remoteAssetId != null) { switch (result) {
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!); case UploadSuccess(:final remoteAssetId):
} else if (result.isCancelled) { onSuccess?.call(asset.localId!, remoteAssetId);
_logger.warning(() => "Backup was cancelled by the user"); case UploadCancelled():
shouldAbortUpload = true;
} else if (result.errorMessage != null) {
_logger.severe(
() =>
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
);
callbacks.onError?.call(asset.localId!, result.errorMessage!);
if (result.errorMessage == "Quota has been exceeded!") {
shouldAbortUpload = true; shouldAbortUpload = true;
} _logger.warning("Upload was cancelled by the user for asset ${asset.localId}");
case UploadError(:final message, :final statusCode):
shouldAbortUpload |= message == "Quota has been exceeded!";
_logger.severe(
"Error(${statusCode ?? 'unknown'}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | $message",
);
onError?.call(asset.localId!, message);
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace); _logger.severe("Asset backup failed", error, stackTrace);
callbacks.onError?.call(asset.localId!, error.toString()); onError?.call(asset.localId!, error.toString());
} finally { } finally {
if (Platform.isIOS) { if (Platform.isIOS) {
try { unawaited(
await file?.delete(); Future.wait([
await livePhotoFile?.delete(); if (assetFile != null) assetFile.delete(),
} catch (error, stackTrace) { if (livePhotoFile != null) livePhotoFile.delete(),
_logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace); ]).onError((error, stackTrace) {
} _logger.severe("Post-upload file cleanup failed", error, stackTrace);
return const [];
}),
);
} }
} }
} }
@@ -446,7 +412,7 @@ class ForegroundUploadService {
logContext: 'shareIntent[$deviceAssetId]', logContext: 'shareIntent[$deviceAssetId]',
); );
} catch (e) { } catch (e) {
return UploadResult.error(errorMessage: e.toString()); return UploadError(message: e.toString());
} }
} }
+3 -21
View File
@@ -508,18 +508,12 @@ class AlbumsApi {
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters) /// Filter albums containing this asset ID (ignores other parameters)
/// ///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned: /// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter /// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
/// ///
/// * [bool] isShared: /// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter /// Filter by shared status: true = only shared, false = not shared, undefined = no filter
/// Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? isOwned, bool? isShared, }) async {
/// * [String] name:
/// Album name (exact match)
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/albums'; final apiPath = r'/albums';
@@ -533,18 +527,12 @@ class AlbumsApi {
if (assetId != null) { if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId)); queryParams.addAll(_queryParams('', 'assetId', assetId));
} }
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (isOwned != null) { if (isOwned != null) {
queryParams.addAll(_queryParams('', 'isOwned', isOwned)); queryParams.addAll(_queryParams('', 'isOwned', isOwned));
} }
if (isShared != null) { if (isShared != null) {
queryParams.addAll(_queryParams('', 'isShared', isShared)); queryParams.addAll(_queryParams('', 'isShared', isShared));
} }
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
const contentTypes = <String>[]; const contentTypes = <String>[];
@@ -569,19 +557,13 @@ class AlbumsApi {
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters) /// Filter albums containing this asset ID (ignores other parameters)
/// ///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned: /// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter /// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
/// ///
/// * [bool] isShared: /// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter /// Filter by shared status: true = only shared, false = not shared, undefined = no filter
/// Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? isOwned, bool? isShared, }) async {
/// * [String] name: final response = await getAllAlbumsWithHttpInfo( assetId: assetId, isOwned: isOwned, isShared: isShared, );
/// Album name (exact match)
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, id: id, isOwned: isOwned, isShared: isShared, name: name, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+1 -1
View File
@@ -13,7 +13,7 @@ part of openapi.api;
class ApiClient { class ApiClient {
ApiClient({this.basePath = '/api', this.authentication,}); ApiClient({this.basePath = '/api', this.authentication,});
String basePath; final String basePath;
final Authentication? authentication; final Authentication? authentication;
var _client = Client(); var _client = Client();
+3 -3
View File
@@ -77,7 +77,7 @@ class JobName {
static const versionCheck = JobName._(r'VersionCheck'); static const versionCheck = JobName._(r'VersionCheck');
static const ocrQueueAll = JobName._(r'OcrQueueAll'); static const ocrQueueAll = JobName._(r'OcrQueueAll');
static const ocr = JobName._(r'Ocr'); static const ocr = JobName._(r'Ocr');
static const workflowAssetTrigger = JobName._(r'WorkflowAssetTrigger'); static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate');
/// List of all possible values in this [enum][JobName]. /// List of all possible values in this [enum][JobName].
static const values = <JobName>[ static const values = <JobName>[
@@ -135,7 +135,7 @@ class JobName {
versionCheck, versionCheck,
ocrQueueAll, ocrQueueAll,
ocr, ocr,
workflowAssetTrigger, workflowAssetCreate,
]; ];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@@ -228,7 +228,7 @@ class JobNameTypeTransformer {
case r'VersionCheck': return JobName.versionCheck; case r'VersionCheck': return JobName.versionCheck;
case r'OcrQueueAll': return JobName.ocrQueueAll; case r'OcrQueueAll': return JobName.ocrQueueAll;
case r'Ocr': return JobName.ocr; case r'Ocr': return JobName.ocr;
case r'WorkflowAssetTrigger': return JobName.workflowAssetTrigger; case r'WorkflowAssetCreate': return JobName.workflowAssetCreate;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
+3 -14
View File
@@ -18,7 +18,6 @@ class PluginTemplateResponseDto {
this.steps = const [], this.steps = const [],
required this.title, required this.title,
required this.trigger, required this.trigger,
this.uiHints = const [],
}); });
/// Template description /// Template description
@@ -35,17 +34,13 @@ class PluginTemplateResponseDto {
WorkflowTrigger trigger; WorkflowTrigger trigger;
/// Ui hints, for example \"smart-album\"
List<String> uiHints;
@override @override
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto && bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
other.description == description && other.description == description &&
other.key == key && other.key == key &&
_deepEquality.equals(other.steps, steps) && _deepEquality.equals(other.steps, steps) &&
other.title == title && other.title == title &&
other.trigger == trigger && other.trigger == trigger;
_deepEquality.equals(other.uiHints, uiHints);
@override @override
int get hashCode => int get hashCode =>
@@ -54,11 +49,10 @@ class PluginTemplateResponseDto {
(key.hashCode) + (key.hashCode) +
(steps.hashCode) + (steps.hashCode) +
(title.hashCode) + (title.hashCode) +
(trigger.hashCode) + (trigger.hashCode);
(uiHints.hashCode);
@override @override
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger, uiHints=$uiHints]'; String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -67,7 +61,6 @@ class PluginTemplateResponseDto {
json[r'steps'] = this.steps; json[r'steps'] = this.steps;
json[r'title'] = this.title; json[r'title'] = this.title;
json[r'trigger'] = this.trigger; json[r'trigger'] = this.trigger;
json[r'uiHints'] = this.uiHints;
return json; return json;
} }
@@ -85,9 +78,6 @@ class PluginTemplateResponseDto {
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']), steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
title: mapValueOfType<String>(json, r'title')!, title: mapValueOfType<String>(json, r'title')!,
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
uiHints: json[r'uiHints'] is Iterable
? (json[r'uiHints'] as Iterable).cast<String>().toList(growable: false)
: const [],
); );
} }
return null; return null;
@@ -140,7 +130,6 @@ class PluginTemplateResponseDto {
'steps', 'steps',
'title', 'title',
'trigger', 'trigger',
'uiHints',
}; };
} }
@@ -75,7 +75,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg'); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg');
final task = await sut.getUploadTask(asset); final task = await sut.getUploadTask(asset);
@@ -92,7 +92,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getUploadTask(asset); final task = await sut.getUploadTask(asset);
@@ -109,7 +109,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getMotionFile(asset.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id), () => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC'); ).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -130,7 +130,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id), () => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC'); ).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -150,7 +150,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null); when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456'); final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
@@ -194,7 +194,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithCloudId); final task = await sutWithV24.getUploadTask(assetWithCloudId);
@@ -243,7 +243,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg'); when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutAndroid.getUploadTask(assetWithCloudId); final task = await sutAndroid.getUploadTask(assetWithCloudId);
@@ -281,7 +281,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false); when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id), () => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
).thenAnswer((_) async => 'test.jpg'); ).thenAnswer((_) async => 'test.jpg');
@@ -323,7 +323,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true); when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity); when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile); when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when( when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id), () => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
).thenAnswer((_) async => 'livephoto.heic'); ).thenAnswer((_) async => 'livephoto.heic');
+2 -30
View File
@@ -1627,17 +1627,6 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "id",
"required": false,
"in": "query",
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{ {
"name": "isOwned", "name": "isOwned",
"required": false, "required": false,
@@ -1655,15 +1644,6 @@
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
},
{
"name": "name",
"required": false,
"in": "query",
"description": "Album name (exact match)",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -18171,7 +18151,7 @@
"VersionCheck", "VersionCheck",
"OcrQueueAll", "OcrQueueAll",
"Ocr", "Ocr",
"WorkflowAssetTrigger" "WorkflowAssetCreate"
], ],
"type": "string" "type": "string"
}, },
@@ -20219,13 +20199,6 @@
"trigger": { "trigger": {
"$ref": "#/components/schemas/WorkflowTrigger", "$ref": "#/components/schemas/WorkflowTrigger",
"description": "Workflow trigger" "description": "Workflow trigger"
},
"uiHints": {
"description": "Ui hints, for example \"smart-album\"",
"items": {
"type": "string"
},
"type": "array"
} }
}, },
"required": [ "required": [
@@ -20233,8 +20206,7 @@
"key", "key",
"steps", "steps",
"title", "title",
"trigger", "trigger"
"uiHints"
], ],
"type": "object" "type": "object"
}, },
-9
View File
@@ -1,12 +1,3 @@
@@ -13,7 +13,7 @@
class ApiClient {
ApiClient({this.basePath = '/api', this.authentication,});
- final String basePath;
+ String basePath;
final Authentication? authentication;
var _client = Client();
@@ -143,19 +143,19 @@ @@ -143,19 +143,19 @@
); );
} }
+14 -15
View File
@@ -20,20 +20,19 @@
"caseSensitive": false "caseSensitive": false
} }
}, },
{
"method": "immich-plugin-core#assetArchive",
"config": {
"inverse": false
}
},
{ {
"method": "immich-plugin-core#assetAddToAlbums", "method": "immich-plugin-core#assetAddToAlbums",
"config": { "config": {
"albumIds": [] "albumIds": []
} }
},
{
"method": "immich-plugin-core#assetArchive",
"config": {
"inverse": false
}
} }
], ]
"uiHints": ["SmartAlbum"]
} }
], ],
"methods": [ "methods": [
@@ -66,7 +65,7 @@
}, },
"required": ["pattern"] "required": ["pattern"]
}, },
"uiHints": ["Filter"] "uiHints": ["filter"]
}, },
{ {
"name": "filterFileType", "name": "filterFileType",
@@ -86,7 +85,7 @@
}, },
"required": ["fileTypes"] "required": ["fileTypes"]
}, },
"uiHints": ["Filter"] "uiHints": ["filter"]
}, },
{ {
"name": "filterPerson", "name": "filterPerson",
@@ -100,7 +99,7 @@
"array": true, "array": true,
"title": "Person IDs", "title": "Person IDs",
"description": "List of person to match", "description": "List of person to match",
"uiHint": "personId" "uiHint": "personI"
}, },
"matchAny": { "matchAny": {
"type": "boolean", "type": "boolean",
@@ -111,7 +110,7 @@
}, },
"required": ["personIds"] "required": ["personIds"]
}, },
"uiHints": ["Filter"] "uiHints": ["filter"]
}, },
{ {
"name": "assetArchive", "name": "assetArchive",
@@ -188,7 +187,7 @@
"title": "Album IDs", "title": "Album IDs",
"array": true, "array": true,
"description": "Target album IDs", "description": "Target album IDs",
"uiHint": "AlbumId" "uiHint": "albumId"
} }
}, },
"required": ["albumIds"] "required": ["albumIds"]
@@ -273,14 +272,14 @@
"type": "string", "type": "string",
"title": "Album ID", "title": "Album ID",
"description": "Target album ID", "description": "Target album ID",
"uiHint": "AlbumId" "uiHint": "albumId"
}, },
"albumIds": { "albumIds": {
"type": "string", "type": "string",
"title": "Album IDs", "title": "Album IDs",
"description": "Target album IDs", "description": "Target album IDs",
"array": true, "array": true,
"uiHint": "AlbumId" "uiHint": "albumId"
} }
} }
} }
+1 -1
View File
@@ -93,7 +93,7 @@ export const assetTrash = () => {
changes: { changes: {
asset: config.inverse asset: config.inverse
? { deletedAt: null, status: AssetStatus.Active } ? { deletedAt: null, status: AssetStatus.Active }
: { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed }, : { deletedAt: new Date(), status: AssetStatus.Trashed },
}, },
})); }));
}; };
-1
View File
@@ -27,7 +27,6 @@
"packageManager": "pnpm@10.33.4", "packageManager": "pnpm@10.33.4",
"devDependencies": { "devDependencies": {
"@extism/js-pdk": "^1.1.1", "@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*",
"@types/node": "^24.12.4", "@types/node": "^24.12.4",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"tsc-alias": "^1.8.16", "tsc-alias": "^1.8.16",
+1 -8
View File
@@ -1,6 +1,3 @@
import { type BulkIdResponseDto, type BulkIdsDto } from '@immich/sdk';
// keep in sync with plugin-core/src/index.d.ts';
declare module 'extism:host' { declare module 'extism:host' {
interface user { interface user {
albumAddAssets(ptr: PTR): I64; albumAddAssets(ptr: PTR): I64;
@@ -48,11 +45,7 @@ type AlbumsToAssets = {
export const hostFunctions = (authToken: string) => ({ export const hostFunctions = (authToken: string) => ({
albumAddAssets: (albumId: string, assetIds: string[]) => albumAddAssets: (albumId: string, assetIds: string[]) =>
call<[string, BulkIdsDto], BulkIdResponseDto[]>( call('albumAddAssets', authToken, [albumId, { ids: assetIds }]),
'albumAddAssets',
authToken,
[albumId, { ids: assetIds }],
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) => addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]), call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
}); });
+9 -9
View File
@@ -68,19 +68,19 @@ export type AssetV1 = {
ownerId: string; ownerId: string;
type: AssetType; type: AssetType;
originalPath: string; originalPath: string;
fileCreatedAt: string; fileCreatedAt: Date;
fileModifiedAt: string; fileModifiedAt: Date;
isFavorite: boolean; isFavorite: boolean;
checksum: Buffer; // sha1 checksum checksum: Buffer; // sha1 checksum
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
updatedAt: string; updatedAt: Date;
createdAt: string; createdAt: Date;
originalFileName: string; originalFileName: string;
isOffline: boolean; isOffline: boolean;
libraryId: string | null; libraryId: string | null;
isExternal: boolean; isExternal: boolean;
deletedAt: string | null; deletedAt: Date | null;
localDateTime: string; localDateTime: Date;
stackId: string | null; stackId: string | null;
duplicateId: string | null; duplicateId: string | null;
status: AssetStatus; status: AssetStatus;
@@ -93,8 +93,8 @@ export type AssetV1 = {
exifImageHeight: number | null; exifImageHeight: number | null;
fileSizeInByte: number | null; fileSizeInByte: number | null;
orientation: string | null; orientation: string | null;
dateTimeOriginal: string | null; dateTimeOriginal: Date | null;
modifyDate: string | null; modifyDate: Date | null;
lensModel: string | null; lensModel: string | null;
fNumber: number | null; fNumber: number | null;
focalLength: number | null; focalLength: number | null;
@@ -116,7 +116,7 @@ export type AssetV1 = {
autoStackId: string | null; autoStackId: string | null;
rating: number | null; rating: number | null;
tags: string[] | null; tags: string[] | null;
updatedAt: string | null; updatedAt: Date | null;
} | null; } | null;
}; };
}; };
+3 -9
View File
@@ -1535,8 +1535,6 @@ export type PluginTemplateResponseDto = {
title: string; title: string;
/** Workflow trigger */ /** Workflow trigger */
trigger: WorkflowTrigger; trigger: WorkflowTrigger;
/** Ui hints, for example "smart-album" */
uiHints: string[];
}; };
export type QueueResponseDto = { export type QueueResponseDto = {
/** Whether the queue is paused */ /** Whether the queue is paused */
@@ -3599,22 +3597,18 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/** /**
* List all albums * List all albums
*/ */
export function getAllAlbums({ assetId, id, isOwned, isShared, name }: { export function getAllAlbums({ assetId, isOwned, isShared }: {
assetId?: string; assetId?: string;
id?: string;
isOwned?: boolean; isOwned?: boolean;
isShared?: boolean; isShared?: boolean;
name?: string;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AlbumResponseDto[]; data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({ }>(`/albums${QS.query(QS.explode({
assetId, assetId,
id,
isOwned, isOwned,
isShared, isShared
name
}))}`, { }))}`, {
...opts ...opts
})); }));
@@ -7146,7 +7140,7 @@ export enum JobName {
VersionCheck = "VersionCheck", VersionCheck = "VersionCheck",
OcrQueueAll = "OcrQueueAll", OcrQueueAll = "OcrQueueAll",
Ocr = "Ocr", Ocr = "Ocr",
WorkflowAssetTrigger = "WorkflowAssetTrigger" WorkflowAssetCreate = "WorkflowAssetCreate"
} }
export enum SearchSuggestionType { export enum SearchSuggestionType {
Country = "country", Country = "country",
+6 -11
View File
@@ -10,7 +10,7 @@ overrides:
sharp: ^0.34.5 sharp: ^0.34.5
webpackbar: ^7.0.0 webpackbar: ^7.0.0
packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k= packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA= pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
@@ -332,9 +332,6 @@ importers:
'@extism/js-pdk': '@extism/js-pdk':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
'@immich/sdk':
specifier: workspace:*
version: link:../sdk
'@types/node': '@types/node':
specifier: ^24.12.4 specifier: ^24.12.4
version: 24.12.4 version: 24.12.4
@@ -392,7 +389,7 @@ importers:
version: 6.1.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) version: 6.1.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
'@nestjs/swagger': '@nestjs/swagger':
specifier: ^11.4.2 specifier: ^11.4.2
version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3) version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
'@nestjs/websockets': '@nestjs/websockets':
specifier: ^11.0.4 specifier: ^11.0.4
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-socket.io@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-socket.io@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -539,7 +536,7 @@ importers:
version: 8.0.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) version: 8.0.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
nestjs-zod: nestjs-zod:
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6) version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6)
nodemailer: nodemailer:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.7 version: 8.0.7
@@ -3798,7 +3795,6 @@ packages:
class-transformer: '*' class-transformer: '*'
class-validator: '*' class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0 reflect-metadata: ^0.1.12 || ^0.2.0
typescript: '*'
peerDependenciesMeta: peerDependenciesMeta:
'@fastify/static': '@fastify/static':
optional: true optional: true
@@ -16574,7 +16570,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - chokidar
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)': '@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)':
dependencies: dependencies:
'@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -16585,7 +16581,6 @@ snapshots:
path-to-regexp: 8.4.2 path-to-regexp: 8.4.2
reflect-metadata: 0.2.2 reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.6 swagger-ui-dist: 5.32.6
typescript: 6.0.3
'@nestjs/testing@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)': '@nestjs/testing@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)':
dependencies: dependencies:
@@ -23234,14 +23229,14 @@ snapshots:
'@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1) '@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1)
tslib: 2.8.1 tslib: 2.8.1
nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6): nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6):
dependencies: dependencies:
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
deepmerge: 4.3.1 deepmerge: 4.3.1
rxjs: 7.8.2 rxjs: 7.8.2
zod: 4.3.6 zod: 4.3.6
optionalDependencies: optionalDependencies:
'@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3) '@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
next-tick@1.1.0: {} next-tick@1.1.0: {}
-3
View File
@@ -60,9 +60,6 @@ packageExtensions:
dependencies: dependencies:
node-addon-api: '*' node-addon-api: '*'
node-gyp: '*' node-gyp: '*'
'@nestjs/swagger':
peerDependencies:
typescript: '*'
dedupePeerDependents: false dedupePeerDependents: false
preferWorkspacePackages: true preferWorkspacePackages: true
injectWorkspacePackages: true injectWorkspacePackages: true
+2 -4
View File
@@ -13,15 +13,14 @@ FROM builder AS server
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY ./server ./server/ COPY ./server ./server/
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/ COPY ./packages/plugin-sdk ./packages/plugin-sdk/
RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich --frozen-lockfile build && \ SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --filter @immich/plugin-sdk --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
FROM builder AS web FROM builder AS web
@@ -67,7 +66,6 @@ ENV MISE_DISABLE_TOOLS=flutter
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install mise install
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-core ./packages/plugin-core/ COPY ./packages/plugin-core ./packages/plugin-core/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/ COPY ./packages/plugin-sdk ./packages/plugin-sdk/
-1
View File
@@ -68,7 +68,6 @@ run = [
[tasks.ci-medium] [tasks.ci-medium]
run = [ run = [
{ task = ":install" }, { task = ":install" },
{ task = "//:plugins" },
{ task = "//packages/plugin-core:build" }, { task = "//packages/plugin-core:build" },
{ task = ":test-medium --run" }, { task = ":test-medium --run" },
] ]
-2
View File
@@ -65,8 +65,6 @@ const UpdateAlbumSchema = z
const GetAlbumsSchema = z const GetAlbumsSchema = z
.object({ .object({
id: z.uuidv4().optional().describe('Album ID'),
name: z.string().optional().describe('Album name (exact match)'),
isOwned: stringToBool isOwned: stringToBool
.optional() .optional()
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'), .describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
-1
View File
@@ -38,7 +38,6 @@ const PluginManifestTemplateSchema = z
description: z.string().min(1).describe('Template description'), description: z.string().min(1).describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'), trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'), steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).optional().default([]).describe('Ui hints, for example "smart-album"'),
}) })
.meta({ id: 'PluginManifestTemplateDto' }); .meta({ id: 'PluginManifestTemplateDto' });
-3
View File
@@ -58,7 +58,6 @@ const PluginTemplateResponseSchema = z
description: z.string().describe('Template description'), description: z.string().describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'), trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'), steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).describe('Ui hints, for example "smart-album"'),
}) })
.meta({ id: 'PluginTemplateResponseDto' }); .meta({ id: 'PluginTemplateResponseDto' });
@@ -92,7 +91,6 @@ export type PluginTemplate = {
config?: Record<string, unknown> | null; config?: Record<string, unknown> | null;
enabled?: boolean; enabled?: boolean;
}>; }>;
uiHints: string[];
}; };
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => { export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
@@ -106,7 +104,6 @@ export const mapTemplate = (plugin: { name: string }, template: PluginTemplate):
config: step.config ?? null, config: step.config ?? null,
enabled: step.enabled, enabled: step.enabled,
})), })),
uiHints: template.uiHints ?? [],
}; };
}; };
+1 -1
View File
@@ -866,7 +866,7 @@ export enum JobName {
Ocr = 'Ocr', Ocr = 'Ocr',
// Workflow // Workflow
WorkflowAssetTrigger = 'WorkflowAssetTrigger', WorkflowAssetCreate = 'WorkflowAssetCreate',
} }
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' }); export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
+1 -6
View File
@@ -209,16 +209,11 @@ export class AlbumRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] }) @GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
getAll( getAll(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<MapAlbumDto[]> {
ownerId: string,
options: { id?: string; isOwned?: boolean; isShared?: boolean; name?: string } = {},
): Promise<MapAlbumDto[]> {
return this.buildAlbumBaseQuery(ownerId, options) return this.buildAlbumBaseQuery(ownerId, options)
.selectAll('album') .selectAll('album')
.select(withAlbumUsers(ownerId)) .select(withAlbumUsers(ownerId))
.select(withSharedLink) .select(withSharedLink)
.$if(!!options.id, (qb) => qb.where('album.id', '=', options.id!))
.$if(!!options.name, (qb) => qb.where('album.albumName', '=', options.name!))
.orderBy('album.createdAt', 'desc') .orderBy('album.createdAt', 'desc')
.execute(); .execute();
} }
@@ -45,10 +45,10 @@ export class WorkflowRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
search(dto: WorkflowSearchDto & { userId?: string }) { search(dto: WorkflowSearchDto & { ownerId?: string }) {
return this.queryBuilder() return this.queryBuilder()
.$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!)) .$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!))
.$if(!!dto.userId, (qb) => qb.where('ownerId', '=', dto.userId!)) .$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!)) .$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!)) .$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
+5 -2
View File
@@ -37,12 +37,15 @@ export class AlbumService extends BaseService {
}; };
} }
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, ...rest }: GetAlbumsDto): Promise<AlbumResponseDto[]> { async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, isOwned, isShared }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
const albums = assetId const albums = assetId
? await this.albumRepository.getByAssetId(ownerId, assetId) ? await this.albumRepository.getByAssetId(ownerId, assetId)
: await this.albumRepository.getAll(ownerId, rest); : await this.albumRepository.getAll(ownerId, { isOwned, isShared });
if (albums.length === 0) { if (albums.length === 0) {
return []; return [];
@@ -1,8 +1,9 @@
import { CurrentPlugin } from '@extism/extism'; import { CurrentPlugin } from '@extism/extism';
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk'; import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common'; import { HttpException, UnauthorizedException } from '@nestjs/common';
import _ from 'lodash';
import { join } from 'node:path'; import { join } from 'node:path';
import { DummyValue, OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto'; import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@@ -20,7 +21,6 @@ import {
} from 'src/enum'; } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service'; import { AlbumService } from 'src/services/album.service';
import { AssetService } from 'src/services/asset.service';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types'; import { JobOf } from 'src/types';
@@ -32,11 +32,9 @@ const dummy = () => {
type ExecuteOptions<T extends WorkflowType> = { type ExecuteOptions<T extends WorkflowType> = {
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>; read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>; write: (changes: WorkflowChanges<T>) => Promise<void>;
}; };
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
export class WorkflowExecutionService extends BaseService { export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string; private jwtSecret!: string;
@@ -64,6 +62,7 @@ export class WorkflowExecutionService extends BaseService {
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) => const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args), albumService.addAssets(authDto, ...args),
); );
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) => const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args), albumService.addAssetsToAlbums(authDto, ...args),
); );
@@ -248,25 +247,20 @@ export class WorkflowExecutionService extends BaseService {
} }
@OnEvent({ name: 'AssetCreate' }) @OnEvent({ name: 'AssetCreate' })
onAssetCreate({ asset: { ownerId: userId, id: assetId } }: ArgOf<'AssetCreate'>) { async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate }); const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate };
} const items = await this.workflowRepository.search(dto);
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
items.map((workflow) => ({ items.map((workflow) => ({
name: JobName.WorkflowAssetTrigger, name: JobName.WorkflowAssetCreate,
data: { workflowId: workflow.id, assetId, trigger }, data: { workflowId: workflow.id, assetId: asset.id },
})), })),
); );
} }
@OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow }) @OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow })
handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) { handleAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) {
return this.execute(workflowId, (type) => { return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
switch (type) { switch (type) {
case WorkflowType.AssetV1: { case WorkflowType.AssetV1: {
return { return {
@@ -277,16 +271,19 @@ export class WorkflowExecutionService extends BaseService {
authUserId: asset.ownerId, authUserId: asset.ownerId,
}; };
}, },
write: async (auth, changes) => { write: async (changes) => {
const asset = changes.asset; if (changes.asset) {
if (!asset) { await this.assetRepository.update({
return; id: assetId,
..._.omitBy(
{
isFavorite: changes.asset?.isFavorite,
visibility: changes.asset?.visibility,
},
_.isUndefined,
),
});
} }
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
});
}, },
} satisfies ExecuteOptions<typeof type>; } satisfies ExecuteOptions<typeof type>;
} }
@@ -304,19 +301,7 @@ export class WorkflowExecutionService extends BaseService {
} }
// TODO infer from steps // TODO infer from steps
let type: T | undefined; const type = 'AssetV1' as T;
for (const targetType of Object.values(WorkflowType)) {
const missing = workflow.steps.some((step) => !step.types.includes(targetType));
if (!missing) {
type = targetType as unknown as T;
break;
}
}
if (!type) {
throw new Error('Unable to infer workflow event type from steps');
}
const handler = getHandler(type); const handler = getHandler(type);
if (!handler) { if (!handler) {
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`); this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
@@ -352,18 +337,7 @@ export class WorkflowExecutionService extends BaseService {
payload, payload,
); );
if (result?.changes) { if (result?.changes) {
await write( await write(result.changes);
{
user: {
id: readResult.authUserId,
},
session: {
id: DummyValue.UUID,
hasElevatedPermission: true,
},
} as AuthDto,
result.changes,
);
({ data } = await read(type)); ({ data } = await read(type));
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export class WorkflowService extends BaseService {
} }
async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> { async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.search({ ...dto, userId: auth.user.id }); const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id });
return workflows.map((workflow) => mapWorkflow(workflow)); return workflows.map((workflow) => mapWorkflow(workflow));
} }
+1 -1
View File
@@ -404,7 +404,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob } | { name: JobName.Ocr; data: IEntityJob }
// Workflow // Workflow
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } } | { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } }
// Editor // Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }; | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
@@ -137,7 +137,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive' }], steps: [{ method: 'immich-plugin-core#assetArchive' }],
}); });
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Archive, visibility: AssetVisibility.Archive,
@@ -154,7 +154,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }], steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }],
}); });
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
@@ -173,7 +173,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock' }], steps: [{ method: 'immich-plugin-core#assetLock' }],
}); });
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Locked, visibility: AssetVisibility.Locked,
@@ -190,7 +190,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }], steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }],
}); });
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
@@ -209,7 +209,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite' }], steps: [{ method: 'immich-plugin-core#assetFavorite' }],
}); });
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
}); });
@@ -224,7 +224,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }], steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }],
}); });
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false }); await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
}); });
@@ -242,7 +242,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }], steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
}); });
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
}); });
@@ -261,7 +261,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }], steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }],
}); });
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id);
await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id);
@@ -279,7 +279,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }], steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
}); });
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy(); await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
}); });
@@ -1,65 +0,0 @@
import type { Attachment } from 'svelte/attachments';
const EDGE_ZONE = 72;
const MAX_SCROLL_SPEED = 22;
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
let node = element.parentElement;
while (node) {
const overflowY = getComputedStyle(node).overflowY;
if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return null;
};
export function dragAutoScroll(isActive: () => boolean): Attachment {
return (node) => {
const element = node as HTMLElement;
let scrollContainer: HTMLElement | null = null;
let pointerY = -1;
let frame: number | null = null;
const trackPointer = (event: DragEvent) => {
pointerY = event.clientY;
};
const tick = () => {
if (scrollContainer && pointerY >= 0) {
const { top, bottom } = scrollContainer.getBoundingClientRect();
let delta = 0;
if (pointerY < top + EDGE_ZONE) {
delta = -MAX_SCROLL_SPEED * Math.min(1, (top + EDGE_ZONE - pointerY) / EDGE_ZONE);
} else if (pointerY > bottom - EDGE_ZONE) {
delta = MAX_SCROLL_SPEED * Math.min(1, (pointerY - (bottom - EDGE_ZONE)) / EDGE_ZONE);
}
if (delta !== 0) {
scrollContainer.scrollBy(0, delta);
}
}
frame = requestAnimationFrame(tick);
};
$effect(() => {
if (!isActive()) {
return;
}
scrollContainer = findScrollContainer(element);
pointerY = -1;
globalThis.addEventListener('dragover', trackPointer);
frame = requestAnimationFrame(tick);
return () => {
globalThis.removeEventListener('dragover', trackPointer);
if (frame !== null) {
cancelAnimationFrame(frame);
frame = null;
}
scrollContainer = null;
};
});
};
}
@@ -64,7 +64,7 @@
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} /> <Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
{/each} {/each}
</div> </div>
{:else if schema.uiHint === 'AlbumId'} {:else if schema.uiHint === 'albumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} /> <SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array} {:else if schema.enum && schema.array}
<Field {label} {description}> <Field {label} {description}>
@@ -205,7 +205,11 @@
</script> </script>
<div <div
class={['group flex overflow-hidden focus-visible:outline-none', backgroundColorClass, { 'rounded-xl': selected }]} class={[
'group flex overflow-hidden transition-[background-color,border-radius] focus-visible:outline-none',
backgroundColorClass,
{ 'rounded-xl': selected },
]}
style:width="{width}px" style:width="{width}px"
style:height="{height}px" style:height="{height}px"
onmouseenter={onMouseEnter} onmouseenter={onMouseEnter}
@@ -245,8 +249,16 @@
]} ]}
> >
<ImageThumbnail <ImageThumbnail
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]} class={[
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]} 'absolute transition-[border-radius] group-focus-visible:rounded-lg',
{ 'rounded-xl': selected },
imageClass,
]}
brokenAssetClass={[
'z-1 absolute group-focus-visible:rounded-lg transition-[border-radius]',
{ 'rounded-xl': selected },
brokenAssetClass,
]}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })} url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)} altText={$getAltText(asset)}
widthStyle="{width}px" widthStyle="{width}px"
@@ -30,11 +30,14 @@
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150); const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const firstInOrNearViewport = $derived(viewerAssets.findIndex((a) => a.isInOrNearViewport));
const lastInOrNearViewport = $derived(viewerAssets.findLastIndex((a) => a.isInOrNearViewport));
</script> </script>
<!-- Image grid --> <!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}> <div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each viewerAssets as viewerAsset (viewerAsset.id)} {#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!} {@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!} {@const asset = viewerAsset.asset!}
+1 -1
View File
@@ -100,7 +100,7 @@
<AssetLayout <AssetLayout
{manager} {manager}
viewerAssets={timelineDay.activeViewerAssets} viewerAssets={timelineDay.viewerAssets}
height={timelineDay.height} height={timelineDay.height}
width={timelineDay.width} width={timelineDay.width}
{customThumbnailLayout} {customThumbnailLayout}
@@ -53,3 +53,17 @@ export function updateTimelineMonthViewportProximity(timelineManager: TimelineMa
timelineManager.clearDeferredLayout(month); timelineManager.clearDeferredLayout(month);
} }
} }
export function calculateViewerAssetViewportProximity(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
) {
const headerHeight = timelineManager.headerHeight;
return calculateViewportProximity(
positionTop,
positionTop + positionHeight,
timelineManager.visibleWindow.top - headerHeight,
timelineManager.visibleWindow.bottom + headerHeight,
);
}
@@ -1,31 +1,12 @@
import { AssetOrder, AssetOrderBy } from '@immich/sdk'; import { AssetOrder, AssetOrderBy } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions, CommonPosition } from '$lib/utils/layout-utils'; import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util'; import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { TimelineMonth } from './timeline-month.svelte'; import type { TimelineMonth } from './timeline-month.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types'; import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
function lowerBound(assets: ViewerAsset[], target: number, key: (pos: CommonPosition) => number): number {
let lo = 0;
let hi = assets.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (key(assets[mid].position!) < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
export class TimelineDay { export class TimelineDay {
readonly timelineMonth: TimelineMonth; readonly timelineMonth: TimelineMonth;
readonly index: number; readonly index: number;
@@ -37,15 +18,12 @@ export class TimelineDay {
height = $state(0); height = $state(0);
width = $state(0); width = $state(0);
// Assets in or near the viewport; active assets should be added to the DOM.
activeViewerAssets: ViewerAsset[] = $state([]);
isInOrNearViewport = $state(false);
#top: number = $state(0); #top: number = $state(0);
#start: number = $state(0); #start: number = $state(0);
#row = $state(0); #row = $state(0);
#col = $state(0); #col = $state(0);
#deferredLayout = false; #deferredLayout = false;
#lastInOrNearViewport = -1;
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) { constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
this.index = index; this.index = index;
@@ -171,32 +149,18 @@ export class TimelineDay {
for (let i = 0; i < this.viewerAssets.length; i++) { for (let i = 0; i < this.viewerAssets.length; i++) {
this.viewerAssets[i].position = geometry.getPosition(i); this.viewerAssets[i].position = geometry.getPosition(i);
} }
this.updateAssetBoundaries();
}
updateAssetBoundaries() {
const manager = this.timelineMonth.timelineManager;
const visibleWindow = manager.visibleWindow;
if (this.viewerAssets.length === 0 || !this.viewerAssets[0].position) {
this.activeViewerAssets = [];
this.isInOrNearViewport = false;
return;
}
const dayOffset = this.absoluteTimelineDayTop;
const headerHeight = manager.headerHeight;
const expandedTop = visibleWindow.top - headerHeight - INTERSECTION_EXPAND_TOP - dayOffset;
const expandedBottom = visibleWindow.bottom + headerHeight + INTERSECTION_EXPAND_BOTTOM - dayOffset;
const first = lowerBound(this.viewerAssets, expandedTop, (p) => p.top + p.height);
const last = lowerBound(this.viewerAssets, expandedBottom, (p) => p.top) - 1;
const hasActive = last >= first && first < this.viewerAssets.length;
this.activeViewerAssets = hasActive ? this.viewerAssets.slice(first, last + 1) : [];
this.isInOrNearViewport = hasActive;
} }
get absoluteTimelineDayTop() { get absoluteTimelineDayTop() {
return this.timelineMonth.top + this.#top; return this.timelineMonth.top + this.#top;
} }
get isInOrNearViewport() {
if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) {
return true;
}
this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport);
return this.#lastInOrNearViewport !== -1;
}
} }
@@ -214,11 +214,6 @@ export class TimelineManager extends VirtualScrollManager {
for (const month of this.months) { for (const month of this.months) {
updateTimelineMonthViewportProximity(this, month); updateTimelineMonthViewportProximity(this, month);
if (month.isInOrNearViewport && month.isLoaded) {
for (const day of month.timelineDays) {
day.updateAssetBoundaries();
}
}
} }
const month = this.months.find((month) => month.isInViewport); const month = this.months.find((month) => month.isInViewport);
@@ -254,7 +254,7 @@ export class TimelineMonth {
addContext.newTimelineDays.add(timelineDay); addContext.newTimelineDays.add(timelineDay);
} }
const viewerAsset = new ViewerAsset(timelineAsset); const viewerAsset = new ViewerAsset(timelineDay, timelineAsset);
timelineDay.viewerAssets.push(viewerAsset); timelineDay.viewerAssets.push(viewerAsset);
addContext.changedTimelineDays.add(timelineDay); addContext.changedTimelineDays.add(timelineDay);
} }
@@ -1,12 +1,36 @@
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import {
ViewportProximity,
calculateViewerAssetViewportProximity,
isInOrNearViewport,
} from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
export class ViewerAsset { export class ViewerAsset {
readonly #group: TimelineDay;
#viewportProximity = $derived.by(() => {
if (!this.position) {
return ViewportProximity.FarFromViewport;
}
const store = this.#group.timelineMonth.timelineManager;
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
return calculateViewerAssetViewportProximity(store, positionTop, this.position.height);
});
get isInOrNearViewport() {
return isInOrNearViewport(this.#viewportProximity);
}
position: CommonPosition | undefined = $state.raw(); position: CommonPosition | undefined = $state.raw();
asset: TimelineAsset = $state() as TimelineAsset; asset: TimelineAsset = $state() as TimelineAsset;
id: string = $derived(this.asset.id); id: string = $derived(this.asset.id);
constructor(asset: TimelineAsset) { constructor(group: TimelineDay, asset: TimelineAsset) {
this.#group = group;
this.asset = asset; this.asset = asset;
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@
<div class="grow text-start"> <div class="grow text-start">
<Text fontWeight="medium" class="flex items-center gap-1" <Text fontWeight="medium" class="flex items-center gap-1"
>{method.title} >{method.title}
{#if method.uiHints.includes('Filter')} {#if method.uiHints.includes('filter')}
<Badge size="tiny" color="info" title={$t('plugin_method_filter_type_description')} <Badge size="tiny" color="info" title={$t('plugin_method_filter_type_description')}
>{$t('plugin_method_filter_type')}</Badge >{$t('plugin_method_filter_type')}</Badge
> >
@@ -0,0 +1,37 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import { type WorkflowResponseDto } from '@immich/sdk';
import { FormModal, ListButton, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
workflow: WorkflowResponseDto;
onClose: () => void;
};
const { workflow, onClose }: Props = $props();
let selected = $state(pluginManager.getTrigger(workflow.trigger));
const onSubmit = async () => {
const success = await handleUpdateWorkflow(workflow.id, { trigger: selected.trigger });
if (success) {
onClose();
}
};
</script>
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
<div class="flex flex-col gap-2">
{#each pluginManager.triggers as item (item.trigger)}
<ListButton selected={item.trigger === selected.trigger} onclick={() => (selected = item)}>
<div class="grow text-start">
<Text fontWeight="medium">{getTriggerName($t, item.trigger)}</Text>
<Text size="tiny" color="muted">{getTriggerDescription($t, item.trigger)}</Text>
</div>
</ListButton>
{/each}
</div>
</FormModal>
@@ -2,7 +2,7 @@
import { pluginManager } from '$lib/managers/plugin-manager.svelte'; import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleCreateWorkflow } from '$lib/services/workflow.service'; import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { type PluginTemplateResponseDto } from '@immich/sdk'; import { type PluginTemplateResponseDto } from '@immich/sdk';
import { Badge, FormModal, Icon, ListButton, Text } from '@immich/ui'; import { FormModal, Icon, ListButton, Text } from '@immich/ui';
import { mdiFlashOutline } from '@mdi/js'; import { mdiFlashOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -59,11 +59,6 @@
<Text fontWeight="medium">{template.title}</Text> <Text fontWeight="medium">{template.title}</Text>
<Text size="tiny" color="muted">{template.description}</Text> <Text size="tiny" color="muted">{template.description}</Text>
</div> </div>
{#if template.uiHints.includes('SmartAlbum')}
<div class="shrink-0">
<Badge size="small">{$t('smart_album')}</Badge>
</div>
{/if}
</div> </div>
</ListButton> </ListButton>
{/each} {/each}
+2 -2
View File
@@ -26,7 +26,7 @@ import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte'; import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
import WorkflowTemplatePickerModal from '$lib/modals/WorkflowTemplatePickerModal.svelte'; import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { copyToClipboard, downloadJson } from '$lib/utils'; import { copyToClipboard, downloadJson } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@@ -50,7 +50,7 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
const UseTemplate: ActionItem = { const UseTemplate: ActionItem = {
title: $t('browse_templates'), title: $t('browse_templates'),
icon: mdiFileDocumentMultipleOutline, icon: mdiFileDocumentMultipleOutline,
onAction: () => modalManager.show(WorkflowTemplatePickerModal, {}), onAction: () => modalManager.show(WorkflowTemplatePicker, {}),
}; };
return { Create, UseTemplate }; return { Create, UseTemplate };
+1 -1
View File
@@ -104,7 +104,7 @@ export type JSONSchemaProperty = {
array?: boolean; array?: boolean;
properties?: Record<string, JSONSchemaProperty>; properties?: Record<string, JSONSchemaProperty>;
required?: string[]; required?: string[];
uiHint?: 'AlbumId' | 'AssetId' | 'PersonId'; uiHint?: 'albumId' | 'assetId' | 'personId';
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate, goto, invalidate } from '$app/navigation'; import { beforeNavigate, goto, invalidate } from '$app/navigation';
import { dragAutoScroll } from '$lib/attachments/drag-auto-scroll.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte'; import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte'; import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
@@ -8,7 +7,7 @@
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service'; import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow'; import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk'; import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk';
import { import {
ActionBar, ActionBar,
AppShell, AppShell,
@@ -44,10 +43,7 @@
mdiPlus, mdiPlus,
} from '@mdi/js'; } from '@mdi/js';
import { cloneDeep, isEqual } from 'lodash-es'; import { cloneDeep, isEqual } from 'lodash-es';
import { flip } from 'svelte/animate';
import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { createListReorder, GHOST_KEY, type ReorderEntry } from './list-reorder.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte'; import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte'; import WorkflowStepCard from './WorkflowStepCard.svelte';
@@ -73,11 +69,6 @@
let isSaving = $state(false); let isSaving = $state(false);
let editMode = $state<EditMode>('visual'); let editMode = $state<EditMode>('visual');
const reorder = createListReorder(
() => steps,
(next) => (steps = next),
);
const workflowSummary = $derived({ name, description, trigger, steps }); const workflowSummary = $derived({ name, description, trigger, steps });
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps }); const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
@@ -115,6 +106,19 @@
} }
}; };
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
const from = Number(event.dataTransfer.getData('text/plain'));
const next = [...steps];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
steps = next;
};
const handleDeleteStep = async (index: number) => { const handleDeleteStep = async (index: number) => {
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') }); const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
if (confirmed) { if (confirmed) {
@@ -340,51 +344,17 @@
</CardHeader> </CardHeader>
</Card> </Card>
<div class="hidden" aria-hidden="true" {@attach dragAutoScroll(() => reorder.isDragging)}></div> {#each steps as step, index (step.method + index)}
{#snippet stepCard(entry: ReorderEntry<WorkflowStepDto>)}
<WorkflowStepCard <WorkflowStepCard
step={entry.item} {step}
index={entry.index} {index}
position={entry.index + 1}
isGhost={entry.isGhost}
isSource={entry.isSource}
isDragging={reorder.isDragging}
onEdit={handleEditStep} onEdit={handleEditStep}
onDelete={handleDeleteStep} onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep} onInsertBefore={handleInsertStep}
onDragStart={reorder.start} onDrop={handleDrop}
onDragOver={reorder.over}
onDragEnd={reorder.end}
onDrop={reorder.drop}
/> />
{/snippet}
{#each reorder.entries as entry (entry.isGhost ? GHOST_KEY : entry.item)}
<div class="w-full" animate:flip={{ duration: 200 }}>
{#if entry.isGhost}
<div transition:fade={{ duration: 120 }}>{@render stepCard(entry)}</div>
{:else}
{@render stepCard(entry)}
{/if}
</div>
{/each} {/each}
{#if reorder.isDragging}
<div
class="-mt-4 min-h-12 w-full"
role="listitem"
ondragover={(event) => {
event.preventDefault();
reorder.toEnd();
}}
ondrop={(event) => {
event.preventDefault();
reorder.drop();
}}
></div>
{/if}
<Button <Button
size="small" size="small"
fullWidth fullWidth
@@ -17,59 +17,23 @@
type Props = { type Props = {
step: WorkflowStepDto; step: WorkflowStepDto;
index: number; index: number;
position: number;
isGhost: boolean;
isSource: boolean;
isDragging: boolean;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onInsertBefore: (index: number) => void; onInsertBefore: (index: number) => void;
onDragStart: (index: number) => void; onDrop: (index: number, event: DragEvent) => void;
onDragOver: (index: number, after: boolean) => void;
onDragEnd: () => void;
onDrop: () => void;
}; };
let { let { step, index, onEdit, onDelete, onInsertBefore, onDrop }: Props = $props();
step,
index,
position,
isGhost,
isSource,
isDragging,
onEdit,
onDelete,
onInsertBefore,
onDragStart,
onDragOver,
onDragEnd,
onDrop,
}: Props = $props();
const method = $derived(pluginManager.getMethod(step.method)); const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false); const isFilter = $derived(method?.uiHints?.includes('filter') ?? false);
const configEntries = $derived( const configEntries = $derived(
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''), Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
); );
let dragImage = $state<Element>(); let dragImage = $state<Element>();
let isDropTarget = $state(false);
let hoverDrag = $state(false); let hoverDrag = $state(false);
const cardStateClass = $derived.by(() => {
if (isGhost) {
return 'pointer-events-none border-2 border-dashed border-primary bg-primary-50/40 shadow-lg';
}
if (isSource) {
return 'border-dashed border-primary-300 bg-primary-50/20';
}
if (hoverDrag) {
return 'border-dashed border-primary';
}
return '';
});
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input); const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
const formatConfigValue = (value: unknown): string => { const formatConfigValue = (value: unknown): string => {
@@ -111,7 +75,7 @@
target: document.body, target: document.body,
props: { props: {
description: method?.description, description: method?.description,
isFilter: method?.uiHints?.includes('Filter') ?? false, isFilter: method?.uiHints?.includes('filter') ?? false,
label: step ? pluginManager.getMethodLabel(step.method) : '', label: step ? pluginManager.getMethodLabel(step.method) : '',
stepNumber: index + 1, stepNumber: index + 1,
}, },
@@ -119,31 +83,31 @@
dragImage = document.body.querySelector('#workflow-step-drag-image')!; dragImage = document.body.querySelector('#workflow-step-drag-image')!;
event.dataTransfer.setDragImage(dragImage, 16, 22); event.dataTransfer.setDragImage(dragImage, 16, 22);
onDragStart(index);
}; };
const handleDrop = (event: DragEvent) => { const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
event.preventDefault(); event.preventDefault();
onDrop();
};
const handleDragOver = (event: DragEvent & { currentTarget: HTMLElement }) => { const from = Number(event.dataTransfer.getData('text/plain'));
event.preventDefault(); if (from === index) {
if (isGhost) {
return; return;
} }
const rect = event.currentTarget.getBoundingClientRect(); onDrop(index, event);
const after = event.clientY > rect.top + rect.height / 2; };
onDragOver(index, after);
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
isDropTarget = true;
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
dragImage?.remove(); dragImage?.remove();
dragImage = undefined; dragImage = undefined;
hoverDrag = false; isDropTarget = false;
onDragEnd();
}; };
</script> </script>
@@ -154,7 +118,6 @@
<button <button
type="button" type="button"
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50" class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
class:hidden={isDragging}
aria-label={$t('add_step')} aria-label={$t('add_step')}
title={$t('add_step')} title={$t('add_step')}
onclick={() => onInsertBefore(index)} onclick={() => onInsertBefore(index)}
@@ -166,12 +129,20 @@
<div <div
class="w-full transition-all" class="w-full transition-all"
class:opacity-50={isSource} class:opacity-40={!!dragImage}
class:scale-[0.99]={!!dragImage}
ondragover={handleDragOver} ondragover={handleDragOver}
ondrop={handleDrop} ondragleave={() => (isDropTarget = false)}
ondrop={(event) => handleDrop(index, event)}
role="listitem" role="listitem"
> >
<Card class="shadow-none transition-colors {cardStateClass}"> <Card
class="shadow-none transition-colors {isDropTarget
? 'border-primary ring-2 ring-primary-200'
: hoverDrag
? 'border-dashed border-primary'
: ''}"
>
<CardHeader> <CardHeader>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -200,9 +171,7 @@
</div> </div>
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<CardTitle class="truncate"> <CardTitle class="truncate">
{#if !isGhost} <span class="mr-1 font-bold text-light-500">{index + 1}</span>
<span class="mr-1 font-bold text-light-500">{position}</span>
{/if}
{pluginManager.getMethodLabel(step.method)} {pluginManager.getMethodLabel(step.method)}
</CardTitle> </CardTitle>
{#if method?.description} {#if method?.description}
@@ -67,7 +67,7 @@
for (const [i, step] of workflow.steps.entries()) { for (const [i, step] of workflow.steps.entries()) {
const method = pluginManager.getMethod(step.method); const method = pluginManager.getMethod(step.method);
const isFilter = method?.uiHints?.includes('Filter') ?? false; const isFilter = method?.uiHints?.includes('filter') ?? false;
const type = isFilter ? $t('filter') : $t('action'); const type = isFilter ? $t('filter') : $t('action');
const label = pluginManager.getMethodLabel(step.method); const label = pluginManager.getMethodLabel(step.method);
lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`); lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`);
@@ -1,78 +0,0 @@
export const GHOST_KEY = 'reorder-ghost';
export type ReorderEntry<T> = {
item: T;
index: number;
isGhost: boolean;
isSource: boolean;
};
export function createListReorder<T>(getItems: () => T[], setItems: (items: T[]) => void) {
let draggingIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
const entries = $derived.by<ReorderEntry<T>[]>(() => {
const items = getItems();
const list: ReorderEntry<T>[] = items.map((item, index) => ({
item,
index,
isGhost: false,
isSource: index === draggingIndex,
}));
if (
draggingIndex !== null &&
dropIndex !== null &&
dropIndex !== draggingIndex &&
dropIndex !== draggingIndex + 1
) {
list.splice(dropIndex, 0, { item: items[draggingIndex], index: draggingIndex, isGhost: true, isSource: false });
}
return list;
});
return {
get isDragging() {
return draggingIndex !== null;
},
get entries() {
return entries;
},
start(index: number) {
draggingIndex = index;
dropIndex = index;
},
over(index: number, after: boolean) {
if (draggingIndex === null) {
return;
}
dropIndex = Math.max(0, Math.min(index + (after ? 1 : 0), getItems().length));
},
toEnd() {
if (draggingIndex !== null) {
dropIndex = getItems().length;
}
},
end() {
draggingIndex = null;
dropIndex = null;
},
drop() {
if (draggingIndex === null || dropIndex === null) {
return;
}
const target = dropIndex > draggingIndex ? dropIndex - 1 : dropIndex;
if (target !== draggingIndex) {
const next = [...getItems()];
const [moved] = next.splice(draggingIndex, 1);
next.splice(target, 0, moved);
setItems(next);
}
draggingIndex = null;
dropIndex = null;
},
};
}