diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs index 3d69d1f503cd5f..1e57910b0198ed 100644 --- a/e2e/eslint.config.mjs +++ b/e2e/eslint.config.mjs @@ -45,6 +45,7 @@ export default typescriptEslint.config([ 'unicorn/prefer-promise-with-resolvers': 'off', 'unicorn/no-declarations-before-early-exit': 'off', 'unicorn/prefer-simple-condition-first': 'off', + 'unicorn/single-line-block-comment-style': ['error', 'single-line'], curly: 2, 'prettier/prettier': 0, 'unicorn/name-replacements': 'off', diff --git a/e2e/src/specs/server/api/user.e2e-spec.ts b/e2e/src/specs/server/api/user.e2e-spec.ts index c92246dc4b2917..0e62672f36b269 100644 --- a/e2e/src/specs/server/api/user.e2e-spec.ts +++ b/e2e/src/specs/server/api/user.e2e-spec.ts @@ -29,9 +29,7 @@ describe('/users', () => { }); describe('PUT /users/me', () => { - /** - @deprecated - */ + /** @deprecated */ it('should allow a user to change their password (deprecated)', async () => { const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) }); diff --git a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift index a7bbc31ceb1eba..0eee970e71dd6c 100644 --- a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift +++ b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift @@ -22,8 +22,9 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi { print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers") } - private static let refreshTaskID = "app.alextran.immich.background.refreshUpload" - private static let processingTaskID = "app.alextran.immich.background.processingUpload" + private static let taskIDs = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as! [String] + private static let refreshTaskID = taskIDs.first { $0.hasSuffix(".refreshUpload") }! + private static let processingTaskID = taskIDs.first { $0.hasSuffix(".processingUpload") }! private static let taskSemaphore = DispatchSemaphore(value: 1) public static func registerBackgroundWorkers() { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 2f1cfdaf6a0418..d94306475fae3c 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -272,7 +272,11 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale), theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale), builder: (context, child) => ImmichTranslationProvider( - translations: ImmichTranslations(submit: context.t.submit, password: context.t.password), + translations: ImmichTranslations( + submit: context.t.submit, + password: context.t.password, + undo: context.t.undo, + ), child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!), ), routerConfig: router.config( diff --git a/mobile/lib/presentation/actions/archive.action.dart b/mobile/lib/presentation/actions/archive.action.dart index f8e4a1e0381af6..1e41411a3c1e04 100644 --- a/mobile/lib/presentation/actions/archive.action.dart +++ b/mobile/lib/presentation/actions/archive.action.dart @@ -57,7 +57,8 @@ class ArchiveAction extends AssetActionBuilder { try { await assetService.update(assetIds, visibility: .some(shouldArchive ? .archive : .timeline)); - toastService.success(message); + Future undo() => assetService.update(assetIds, visibility: .some(shouldArchive ? .timeline : .archive)); + toastService.success(message, toast: .new(onUndo: undo)); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to update the archive status for assets"); diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index 23a2ef2a2f08ff..7edac9aa4216f4 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; +import 'package:immich_mobile/services/toast.service.dart'; import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; @@ -67,15 +68,19 @@ class DeleteAction extends AssetActionBuilder { } final (:localIds, :remoteIds, :trash) = state; + final assetService = ref.read(assetServiceProvider); final toastService = ref.read(toastServiceProvider); final clearSelection = ref.read(clearSelectionProvider(source)); try { final String? message; + // Only trashing is reversible; a permanent delete and a device cleanup are not. + ToastOption? undo; if (remoteIds.isEmpty) { message = await _removeLocalAssets(context, ref, localIds); } else if (trash) { message = await _moveToTrash(context, ref, remoteIds, localIds); + undo = .new(onUndo: () => assetService.restoreTrash(remoteIds)); } else { message = await _deletePermanently(context, ref, remoteIds, localIds); } @@ -84,7 +89,7 @@ class DeleteAction extends AssetActionBuilder { return; } - toastService.success(message); + toastService.success(message, toast: undo); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to delete assets"); diff --git a/mobile/lib/presentation/actions/lock.action.dart b/mobile/lib/presentation/actions/lock.action.dart index 3d090b712d07fb..dad0d020a9a79a 100644 --- a/mobile/lib/presentation/actions/lock.action.dart +++ b/mobile/lib/presentation/actions/lock.action.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; +import 'package:immich_mobile/services/toast.service.dart'; import 'package:immich_mobile/utils/error_handler.dart'; typedef _State = ({bool shouldLock, List assetIds, List localIds}); @@ -62,7 +63,11 @@ class LockAction extends AssetActionBuilder { // A locked asset still sits in the device gallery, so offer to remove the local copy. await assetService.deleteLocal(localIds); } - toastService.success(message); + // Unlocking is a sensitive action and requires an elevated session + final toast = shouldLock + ? null + : ToastOption(onUndo: () => assetService.update(assetIds, visibility: const .some(.locked))); + toastService.success(message, toast: toast); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to update the locked folder for assets"); diff --git a/mobile/lib/presentation/actions/restore.action.dart b/mobile/lib/presentation/actions/restore.action.dart index 0a1b707b3949ca..1ce444e9061845 100644 --- a/mobile/lib/presentation/actions/restore.action.dart +++ b/mobile/lib/presentation/actions/restore.action.dart @@ -38,7 +38,7 @@ class RestoreAction extends AssetActionBuilder { try { await assetService.restoreTrash(assetIds); - toastService.success(message); + toastService.success(message, toast: .new(onUndo: () => assetService.trash(assetIds))); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to restore assets"); diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 1ebc9825f4f0c0..136684093ce4eb 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -95,23 +95,34 @@ class UploadRepository { required Completer? cancelToken, void Function(int bytes, int totalBytes)? onProgress, required String logContext, + Client? httpClient, }) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); - final baseRequest = ProgressMultipartRequest( - 'POST', - Uri.parse('$savedEndpoint/assets'), - abortTrigger: cancelToken?.future, - onProgress: onProgress, - ); - try { - final fileStream = file.openRead(); - final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); + ProgressMultipartRequest buildRequest() { + final request = ProgressMultipartRequest( + 'POST', + Uri.parse('$savedEndpoint/assets'), + abortTrigger: cancelToken?.future, + onProgress: onProgress, + ); + request.fields.addAll(fields); + request.files.add(MultipartFile("assetData", file.openRead(), file.lengthSync(), filename: originalFileName)); + return request; + } - baseRequest.fields.addAll(fields); - baseRequest.files.add(assetRawUploadData); + try { + final client = httpClient ?? NetworkRepository.client; + StreamedResponse response; + try { + response = await client.send(buildRequest()); + } on RequestAbortedException { + rethrow; + } on ClientException catch (error) { + logger.warning("Upload $logContext failed before a response, resending once: $error"); + response = await client.send(buildRequest()); + } - final response = await NetworkRepository.client.send(baseRequest); final responseBodyString = await response.stream.bytesToString(); if (![200, 201].contains(response.statusCode)) { diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index 617a5b61324417..738cb787211b7e 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -51,7 +51,7 @@ class DeepLinkService { final queryParams = link.uri.queryParameters; return switch (intent) { - "memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''), + "memory" => await _buildMemoryDeepLink(queryParams['id']), "asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref), "album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''), "people" => await _buildPeopleDeepLink(queryParams['id'] ?? ''), diff --git a/mobile/lib/services/toast.service.dart b/mobile/lib/services/toast.service.dart index 2b61a945ff7467..1cbb52635111dc 100644 --- a/mobile/lib/services/toast.service.dart +++ b/mobile/lib/services/toast.service.dart @@ -13,14 +13,25 @@ class ToastService { const ToastService(); FutureOr success(String message, {ToastOption? toast}) { - snackbar.success(message, duration: toast?.timeout); + snackbar.success(message, duration: toast?.timeout, action: toast?.action); } FutureOr info(String message, {ToastOption? toast}) { - snackbar.info(message, duration: toast?.timeout); + snackbar.info(message, duration: toast?.timeout, action: toast?.action); } FutureOr error(String message, {ToastOption? toast}) { - snackbar.error(message, duration: toast?.timeout); + snackbar.error(message, duration: toast?.timeout, action: toast?.action); + } +} + +extension on ToastOption { + SnackbarAction? get action { + final onUndo = this.onUndo; + if (onUndo == null) { + return null; + } + + return SnackbarAction(onPressed: onUndo); } } diff --git a/mobile/packages/ui/lib/src/snackbar.dart b/mobile/packages/ui/lib/src/snackbar.dart index 1ede1124a81d7e..4e3c818df36ed4 100644 --- a/mobile/packages/ui/lib/src/snackbar.dart +++ b/mobile/packages/ui/lib/src/snackbar.dart @@ -1,8 +1,19 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:immich_ui/immich_ui.dart'; +import 'package:immich_ui/src/internal.dart'; final scaffoldMessengerKey = GlobalKey(); +class SnackbarAction { + // Optional label for the action button. Falls back to the localized "Undo" label when null + final String? label; + final FutureOr Function() onPressed; + + const SnackbarAction({this.label, required this.onPressed}); +} + class SnackbarManager { const SnackbarManager(); @@ -10,6 +21,7 @@ class SnackbarManager { String message, SnackbarType type, { Duration? duration, + SnackbarAction? action, }) { final messenger = scaffoldMessengerKey.currentState; final context = scaffoldMessengerKey.currentContext; @@ -19,10 +31,10 @@ class SnackbarManager { duration ??= const .new(seconds: 4); messenger.hideCurrentSnackBar(); - return messenger.showSnackBar(_build(context, message, type, duration)); + return messenger.showSnackBar(_build(context, message, type, duration, action)); } - SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) { + SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration, SnackbarAction? action) { final theme = Theme.of(context); final colors = theme.extension() ?? ImmichColors.harmonized(theme.colorScheme); final (IconData icon, Color background, Color foreground) = switch (type) { @@ -31,11 +43,22 @@ class SnackbarManager { .error => (Icons.warning_rounded, colors.error, colors.onError), }; + SnackBarAction? snackAction; + if (action != null) { + snackAction = .new( + label: action.label ?? context.translations.undo, + onPressed: action.onPressed, + textColor: foreground, + ); + } + return SnackBar( behavior: .floating, backgroundColor: background, duration: duration, shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))), + // A snackbar carrying an action stays up until it is tapped by default; our actions are optional + persist: false, content: Row( children: [ Icon(icon, color: foreground, size: ImmichIconSize.sm), @@ -50,17 +73,27 @@ class SnackbarManager { ), ], ), + action: snackAction, ); } - ScaffoldFeatureController? info(String message, {Duration? duration}) => - show(message, .info, duration: duration); + ScaffoldFeatureController? info( + String message, { + Duration? duration, + SnackbarAction? action, + }) => show(message, .info, duration: duration, action: action); - ScaffoldFeatureController? success(String message, {Duration? duration}) => - show(message, .success, duration: duration); + ScaffoldFeatureController? success( + String message, { + Duration? duration, + SnackbarAction? action, + }) => show(message, .success, duration: duration, action: action); - ScaffoldFeatureController? error(String message, {Duration? duration}) => - show(message, .error, duration: duration); + ScaffoldFeatureController? error( + String message, { + Duration? duration, + SnackbarAction? action, + }) => show(message, .error, duration: duration, action: action); } const snackbar = SnackbarManager(); diff --git a/mobile/packages/ui/lib/src/translation.dart b/mobile/packages/ui/lib/src/translation.dart index cd51f74422aa49..1bca33f4f1655f 100644 --- a/mobile/packages/ui/lib/src/translation.dart +++ b/mobile/packages/ui/lib/src/translation.dart @@ -1,27 +1,24 @@ import 'package:flutter/material.dart'; class ImmichTranslations { - late String submit; - late String password; + final String submit; + final String password; + final String undo; - ImmichTranslations({String? submit, String? password}) { - this.submit = submit ?? 'Submit'; - this.password = password ?? 'Password'; - } + const ImmichTranslations({String? submit, String? password, String? undo}) + : submit = submit ?? 'Submit', + password = password ?? 'Password', + undo = undo ?? 'Undo'; } class ImmichTranslationProvider extends InheritedWidget { final ImmichTranslations? translations; - const ImmichTranslationProvider({ - super.key, - this.translations, - required super.child, - }); + const ImmichTranslationProvider({super.key, this.translations, required super.child}); static ImmichTranslations of(BuildContext context) { final provider = context.dependOnInheritedWidgetOfExactType(); - return provider?.translations ?? ImmichTranslations(); + return provider?.translations ?? const .new(); } @override diff --git a/mobile/test/repositories/upload_repository_test.dart b/mobile/test/repositories/upload_repository_test.dart new file mode 100644 index 00000000000000..516559de504e38 --- /dev/null +++ b/mobile/test/repositories/upload_repository_test.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/repositories/upload.repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockHttpClient extends Mock implements http.Client {} + +class _FakeBaseRequest extends Fake implements http.BaseRequest {} + +// keeps the FileDownloader singleton off the disk and off the platform channels +class _NoStorage extends Fake implements PersistentStorage { + @override + Future initialize() async {} +} + +void main() { + late _MockHttpClient client; + late UploadRepository sut; + late File file; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + FileDownloader(persistentStorage: _NoStorage()); + final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + await StoreService.init(storeRepository: StoreRepository(db)); + await Store.put(StoreKey.serverEndpoint, 'http://demo.immich.app/api'); + registerFallbackValue(_FakeBaseRequest()); + file = File('${Directory.systemTemp.createTempSync().path}/photo.jpg')..writeAsStringSync('bytes'); + }); + + setUp(() { + client = _MockHttpClient(); + sut = UploadRepository(); + }); + + // consumes the body like a real client would, so a reused request would blow up on the second send + void stubSend(FutureOr Function(int attempt) answer) { + var attempt = 0; + when(() => client.send(any())).thenAnswer((invocation) async { + final request = invocation.positionalArguments.single as http.BaseRequest; + await request.finalize().drain(); + return answer(++attempt); + }); + } + + http.StreamedResponse response(int status, String body) => + http.StreamedResponse(Stream.value(utf8.encode(body)), status); + + Future upload() => sut.uploadFile( + file: file, + originalFileName: 'photo.jpg', + fields: const {'deviceAssetId': 'a1'}, + cancelToken: null, + logContext: 'a1', + httpClient: client, + ); + + test('resends once when the first send dies before a response', () async { + stubSend((attempt) { + if (attempt == 1) { + throw http.ClientException('Broken pipe'); + } + return response(201, '{"id":"remote-1"}'); + }); + + final result = await upload(); + + expect(result.isSuccess, isTrue); + expect(result.remoteAssetId, 'remote-1'); + verify(() => client.send(any())).called(2); + }); + + test('a second transport failure is an error, no third send', () async { + stubSend((_) => throw http.ClientException('Connection reset')); + + final result = await upload(); + + expect(result.isSuccess, isFalse); + expect(result.isCancelled, isFalse); + verify(() => client.send(any())).called(2); + }); + + test('a cancelled upload is not resent', () async { + stubSend((_) => throw http.RequestAbortedException()); + + final result = await upload(); + + expect(result.isCancelled, isTrue); + verify(() => client.send(any())).called(1); + }); + + test('a cancel during the resend still counts as cancelled', () async { + stubSend((attempt) { + if (attempt == 1) { + throw http.ClientException('Broken pipe'); + } + throw http.RequestAbortedException(); + }); + + final result = await upload(); + + expect(result.isCancelled, isTrue); + verify(() => client.send(any())).called(2); + }); + + test('a server error response is not resent', () async { + stubSend((_) => response(500, '{"message":"boom"}')); + + final result = await upload(); + + expect(result.statusCode, 500); + expect(result.errorMessage, 'boom'); + verify(() => client.send(any())).called(1); + }); +} diff --git a/mobile/test/services/deep_link_service_test.dart b/mobile/test/services/deep_link_service_test.dart index 16fa392f272dc2..c588cf26b43287 100644 --- a/mobile/test/services/deep_link_service_test.dart +++ b/mobile/test/services/deep_link_service_test.dart @@ -3,7 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/domain/services/people.service.dart'; @@ -32,11 +34,13 @@ class MockAssetViewerStateNotifier extends Mock implements AssetViewerStateNotif const _assetId = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'; const _albumId = 'cccccccc-4444-5555-6666-dddddddddddd'; +const _memoryId = 'mmmmmmmm-7777-8888-9999-yyyyyyyyyyyy'; +const _userId = 'user-1'; final _asset = RemoteAsset( id: _assetId, name: 'photo.jpg', - ownerId: 'user-1', + ownerId: _userId, checksum: 'checksum-1', type: AssetType.image, createdAt: DateTime(2026, 6, 12), @@ -47,7 +51,7 @@ final _asset = RemoteAsset( final _album = RemoteAlbum( id: _albumId, name: 'Shared Album', - ownerId: 'user-1', + ownerId: _userId, description: '', createdAt: DateTime(2026, 6, 12), updatedAt: DateTime(2026, 6, 12), @@ -58,10 +62,30 @@ final _album = RemoteAlbum( ownerName: 'Owner', ); +final _memory = DriftMemory( + id: _memoryId, + createdAt: DateTime(2026, 6, 12), + updatedAt: DateTime(2026, 6, 12), + ownerId: _userId, + type: MemoryTypeEnum.onThisDay, + data: const MemoryData(year: 2025), + isSaved: false, + memoryAt: DateTime(2025, 6, 12), + assets: [_asset], +); + +final _user = UserDto( + id: _userId, + email: 'test@immich.app', + name: 'Test User', + profileChangedAt: DateTime(2026, 6, 12), +); + void main() { late MockTimelineFactory timelineFactory; late MockAssetService assetService; late MockRemoteAlbumService remoteAlbumService; + late MockDriftMemoryService memoryService; late MockWidgetRef ref; late List createdTimelineServices; late DeepLinkService sut; @@ -70,6 +94,7 @@ void main() { timelineFactory = MockTimelineFactory(); assetService = MockAssetService(); remoteAlbumService = MockRemoteAlbumService(); + memoryService = MockDriftMemoryService(); ref = MockWidgetRef(); createdTimelineServices = []; @@ -90,9 +115,9 @@ void main() { timelineFactory, assetService, remoteAlbumService, - MockDriftMemoryService(), + memoryService, MockDriftPeopleService(), - null, + _user, ); addTearDown(() async { @@ -137,4 +162,30 @@ void main() { expect((route!.args! as AssetViewerRouteArgs).currentAlbum, isNull); verifyNever(() => remoteAlbumService.get(any())); }); + + test('memory scheme link without an id opens the memory lane', () async { + when(() => memoryService.getMemoryLane(_userId)).thenAnswer((_) async => [_memory]); + final deepLink = MockPlatformDeepLink(); + when(() => deepLink.uri).thenReturn(Uri.parse('immich://memory')); + + final route = await sut.handleScheme(deepLink, ref); + + expect(route, isA()); + expect((route!.args! as DriftMemoryRouteArgs).memories, [_memory]); + verify(() => memoryService.getMemoryLane(_userId)).called(1); + verifyNever(() => memoryService.get(any())); + }); + + test('memory scheme link with an id opens only the specified memory', () async { + when(() => memoryService.get(_memoryId)).thenAnswer((_) async => _memory); + final deepLink = MockPlatformDeepLink(); + when(() => deepLink.uri).thenReturn(Uri.parse('immich://memory?id=$_memoryId')); + + final route = await sut.handleScheme(deepLink, ref); + + expect(route, isA()); + expect((route!.args! as DriftMemoryRouteArgs).memories, [_memory]); + verifyNever(() => memoryService.getMemoryLane(any())); + verify(() => memoryService.get(_memoryId)).called(1); + }); } diff --git a/mobile/test/unit/presentation/actions/archive_action_test.dart b/mobile/test/unit/presentation/actions/archive_action_test.dart index 555d6af2ffa185..80f1556e395714 100644 --- a/mobile/test/unit/presentation/actions/archive_action_test.dart +++ b/mobile/test/unit/presentation/actions/archive_action_test.dart @@ -92,6 +92,28 @@ void main() { expect(find.byType(ImmichIconButton), findsNothing); }); + testWidgets('offers an undo that puts the archived assets back on the timeline', (tester) async { + final asset = owned(); + + await pumpArchive(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.update([asset.id], visibility: const .some(.timeline))).called(1); + }); + + testWidgets('offers an undo that re-archives the unarchived assets', (tester) async { + final asset = owned(visibility: .archive); + + await pumpArchive(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.update([asset.id], visibility: const .some(.archive))).called(1); + }); + testWidgets('is hidden inside the locked folder view', (tester) async { await tester.pumpTestWidget( context, diff --git a/mobile/test/unit/presentation/actions/delete_action_test.dart b/mobile/test/unit/presentation/actions/delete_action_test.dart index 4c1bf9306c52e5..b68e2096da8454 100644 --- a/mobile/test/unit/presentation/actions/delete_action_test.dart +++ b/mobile/test/unit/presentation/actions/delete_action_test.dart @@ -105,6 +105,17 @@ void main() { verify(() => cleanupService.deleteLocalAssets(['local'])).called(1); verify(() => assetService.trash([asset.id])).called(1); }); + + testWidgets('offers an undo that restores the trashed assets', (tester) async { + final asset = owned(); + + await pumpDelete(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.restoreTrash([asset.id])).called(1); + }); }); group('permanent', () { @@ -118,6 +129,15 @@ void main() { verifyNever(() => assetService.trash(any())); }); + testWidgets('offers no undo for a permanent delete', (tester) async { + await pumpDelete(tester, {owned()}, trashEnabled: false); + await respondToDialog(tester, confirm: true); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Undo'), findsNothing); + }); + testWidgets('permanently deletes a merged asset and removes its device copy', (tester) async { final asset = owned(localId: 'local'); diff --git a/mobile/test/unit/presentation/actions/lock_action_test.dart b/mobile/test/unit/presentation/actions/lock_action_test.dart index 9bad0f5a43bc9f..9bbbcaed7ac08e 100644 --- a/mobile/test/unit/presentation/actions/lock_action_test.dart +++ b/mobile/test/unit/presentation/actions/lock_action_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; @@ -94,6 +95,25 @@ void main() { verifyNever(() => assetService.deleteLocal(any())); }); + testWidgets('offers an undo that locks the unlocked assets again', (tester) async { + final asset = owned(visibility: .locked); + + await pumpLock(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.update([asset.id], visibility: const .some(.locked))).called(1); + }); + + testWidgets('offers no undo for locking', (tester) async { + await pumpLock(tester, {owned()}); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Undo'), findsNothing); + }); + testWidgets('clears the selection once the update succeeds', (tester) async { await pumpLock(tester, {owned()}); await tester.pumpAndSettle(); diff --git a/mobile/test/unit/presentation/actions/restore_action_test.dart b/mobile/test/unit/presentation/actions/restore_action_test.dart index 530e1b76e951c7..5c11d204037c04 100644 --- a/mobile/test/unit/presentation/actions/restore_action_test.dart +++ b/mobile/test/unit/presentation/actions/restore_action_test.dart @@ -62,6 +62,17 @@ void main() { expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action'); }); + testWidgets('offers an undo that puts the assets back in the trash', (tester) async { + final asset = owned(); + + await pumpRestore(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.trash([asset.id])).called(1); + }); + testWidgets('is hidden when no owned asset is trashed', (tester) async { await tester.pumpTestWidget( context, diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index cd32bf6023ce65..d6f32a9abcb7a1 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -42,6 +42,7 @@ export default typescriptEslint.config([ 'unicorn/import-style': 'off', 'unicorn/consistent-class-member-order': 'off', 'unicorn/prefer-simple-condition-first': 'off', + 'unicorn/single-line-block-comment-style': ['error', 'single-line'], curly: 2, // prefer the typescript-eslint type-aware version 'unicorn/require-array-sort-compare': 'off', diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 949acaf1396342..241d8fd5328c8f 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -51,6 +51,7 @@ export default typescriptEslint.config([ 'unicorn/max-nested-calls': 'off', 'unicorn/no-declarations-before-early-exit': 'off', 'unicorn/no-unreadable-object-destructuring': 'off', + 'unicorn/single-line-block-comment-style': ['error', 'single-line'], // maybe we do want to enable this later. TBD 'unicorn/prefer-await': 'off', 'unicorn/consistent-class-member-order': 'off', diff --git a/server/src/controllers/system-config.controller.spec.ts b/server/src/controllers/system-config.controller.spec.ts index 862a448791be3a..7d40f125836af7 100644 --- a/server/src/controllers/system-config.controller.spec.ts +++ b/server/src/controllers/system-config.controller.spec.ts @@ -7,9 +7,7 @@ import request from 'supertest'; import { errorDto } from 'test/medium/responses'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; -/** -Returns a full config that passes Zod validation (required URLs and min lengths). -*/ +/** Returns a full config that passes Zod validation (required URLs and min lengths). */ function validConfig() { const config = _.cloneDeep(defaults) as typeof defaults & { oauth: { mobileRedirectUri: string }; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 322c6c2d38c5c6..b8556be41c0fbe 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -136,24 +136,16 @@ export interface GenerateSqlQueries { export const Telemetry = (options: { enabled?: boolean }) => SetMetadata(MetadataKey.TelemetryEnabled, options?.enabled ?? true); -/** -Decorator to enable versioning/tracking of generated Sql -*/ +/** Decorator to enable versioning/tracking of generated Sql */ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options); export type EventConfig = { name: EmitEvent; - /** - handle socket.io server events as well - */ + /** handle socket.io server events as well */ server?: boolean; - /** - lower value has higher priority, defaults to 0 - */ + /** lower value has higher priority, defaults to 0 */ priority?: number; - /** - register events for these workers, defaults to all workers - */ + /** register events for these workers, defaults to all workers */ workers?: ImmichWorker[]; }; export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EventConfig, config); @@ -191,9 +183,7 @@ type HistoryEntry = { }; type DeprecatedOptions = { - /** - replacement operationId - */ + /** replacement operationId */ replacementId?: string; }; diff --git a/server/src/dtos/asset-ids.response.dto.ts b/server/src/dtos/asset-ids.response.dto.ts index 92c73a68cf740d..e86eee0973eb69 100644 --- a/server/src/dtos/asset-ids.response.dto.ts +++ b/server/src/dtos/asset-ids.response.dto.ts @@ -1,9 +1,7 @@ import { createZodDto } from 'nestjs-zod'; import z from 'zod'; -/** -@deprecated Use `BulkIdResponseDto` instead -*/ +/** @deprecated Use `BulkIdResponseDto` instead */ export enum AssetIdErrorReason { DUPLICATE = 'duplicate', NO_PERMISSION = 'no_permission', @@ -15,9 +13,7 @@ const AssetIdErrorReasonSchema = z .describe('Error reason if failed') .meta({ id: 'AssetIdErrorReason' }); -/** -@deprecated Use `BulkIdResponseDto` instead -*/ +/** @deprecated Use `BulkIdResponseDto` instead */ const AssetIdsResponseSchema = z .object({ assetId: z.uuidv4().describe('Asset ID'), @@ -54,9 +50,7 @@ const BulkIdResponseSchema = z }) .meta({ id: 'BulkIdResponseDto' }); -/** -@deprecated Use `BulkIdResponseDto` instead -*/ +/** @deprecated Use `BulkIdResponseDto` instead */ export class AssetIdsResponseDto extends createZodDto(AssetIdsResponseSchema) {} export class BulkIdsDto extends createZodDto(BulkIdsSchema) {} export class BulkIdResponseDto extends createZodDto(BulkIdResponseSchema) {} diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index 8393d0bce22d3b..c378a7c74d2b25 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -40,9 +40,7 @@ const AssetMediaBaseSchema = z.object({ fileModifiedAt: isoDatetimeToDate.describe('File modification date'), duration: z.coerce.number().int().min(0).optional().describe('Duration in milliseconds (for videos)'), filename: z.string().optional().describe('Filename'), - /** - The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled in the controller. - */ + /** The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled in the controller. */ [UploadFieldName.ASSET_DATA]: z.any().describe('Asset file data').meta({ type: 'string', format: 'binary' }), }); diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index b92445aecb7a20..a50b7abe87b459 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -23,9 +23,7 @@ import { } from 'src/enum'; import z from 'zod'; -/** -Coerces 'true'/'false' strings to boolean, but also allows booleans. -*/ +/** Coerces 'true'/'false' strings to boolean, but also allows booleans. */ const configBool = z .preprocess((val) => { if (val === 'true') { diff --git a/server/src/enum.ts b/server/src/enum.ts index 1c41da9eb41b86..e88a9a667c79e0 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -45,13 +45,9 @@ export enum AssetType { export const AssetTypeSchema = z.enum(AssetType).describe('Asset type').meta({ id: 'AssetTypeEnum' }); export enum ChecksumAlgorithm { - /** - sha1 checksum of the whole file contents - */ + /** sha1 checksum of the whole file contents */ sha1File = 'sha1', - /** - sha1 checksum of "path:" plus the file path, currently used in external libraries, deprecated - */ + /** sha1 checksum of "path:" plus the file path, currently used in external libraries, deprecated */ sha1Path = 'sha1-path', } @@ -89,9 +85,7 @@ export enum AssetOrderBy { export const AssetOrderBySchema = z.enum(AssetOrderBy).describe('Asset sorting property').meta({ id: 'AssetOrderBy' }); export enum MemoryType { - /** - pictures taken on this day X years ago - */ + /** pictures taken on this day X years ago */ OnThisDay = 'on_this_day', } @@ -101,9 +95,7 @@ export enum AssetOrderWithRandom { // Include existing values Asc = AssetOrder.Asc, Desc = AssetOrder.Desc, - /** - Randomly Ordered - */ + /** Randomly Ordered */ Random = 'random', } @@ -650,9 +642,7 @@ export enum ExifOrientation { Rotate270CW = 8, } -/** -ITU-T H.273 colour primaries codes. -*/ +/** ITU-T H.273 colour primaries codes. */ export enum ColorPrimaries { Reserved = 0, Bt709 = 1, @@ -669,9 +659,7 @@ export enum ColorPrimaries { Ebu3213 = 22, } -/** -ITU-T H.273 transfer characteristics codes. -*/ +/** ITU-T H.273 transfer characteristics codes. */ export enum ColorTransfer { Reserved = 0, Bt709 = 1, @@ -693,9 +681,7 @@ export enum ColorTransfer { AribStdB67 = 18, } -/** -ITU-T H.273 matrix coefficients codes. -*/ +/** ITU-T H.273 matrix coefficients codes. */ export enum ColorMatrix { Gbr = 0, Bt709 = 1, @@ -714,9 +700,7 @@ export enum ColorMatrix { Ictcp = 14, } -/** -H.264 `profile_idc` values. -*/ +/** H.264 `profile_idc` values. */ // H.264 has a few profiles that have the same value but different names, included so lookup by name works export enum H264Profile { ConstrainedBaseline = 66, @@ -734,9 +718,7 @@ export enum H264Profile { High444Predictive = 244, } -/** -HEVC `profile_idc` values. -*/ +/** HEVC `profile_idc` values. */ export enum HevcProfile { Main = 1, Main10 = 2, @@ -744,18 +726,14 @@ export enum HevcProfile { Rext = 4, } -/** -AV1 `seq_profile` values. -*/ +/** AV1 `seq_profile` values. */ export enum Av1Profile { Main = 0, High = 1, Professional = 2, } -/** -MPEG-4 Audio Object Type values for AAC. -*/ +/** MPEG-4 Audio Object Type values for AAC. */ export enum AacProfile { Main = 1, Lc = 2, @@ -768,9 +746,7 @@ export enum AacProfile { XheAac = 42, } -/** -Dolby Vision bitstream profile numbers from the DOVI configuration record. -*/ +/** Dolby Vision bitstream profile numbers from the DOVI configuration record. */ export enum DvProfile { Dvhe03 = 3, Dvhe04 = 4, @@ -946,21 +922,13 @@ export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'Jo export enum QueueCommand { Start = 'start', - /** - @deprecated Use `updateQueue` instead - */ + /** @deprecated Use `updateQueue` instead */ Pause = 'pause', - /** - @deprecated Use `updateQueue` instead - */ + /** @deprecated Use `updateQueue` instead */ Resume = 'resume', - /** - @deprecated Use `emptyQueue` instead - */ + /** @deprecated Use `emptyQueue` instead */ Empty = 'empty', - /** - @deprecated Use `emptyQueue` instead - */ + /** @deprecated Use `emptyQueue` instead */ ClearFailed = 'clear-failed', } @@ -1025,15 +993,11 @@ export enum SyncRequestType { AlbumsV2 = 'AlbumsV2', AlbumUsersV1 = 'AlbumUsersV1', AlbumToAssetsV1 = 'AlbumToAssetsV1', - /** - @deprecated - */ + /** @deprecated */ AlbumAssetsV1 = 'AlbumAssetsV1', AlbumAssetsV2 = 'AlbumAssetsV2', AlbumAssetExifsV1 = 'AlbumAssetExifsV1', - /** - @deprecated - */ + /** @deprecated */ AssetsV1 = 'AssetsV1', AssetsV2 = 'AssetsV2', AssetExifsV1 = 'AssetExifsV1', @@ -1044,9 +1008,7 @@ export enum SyncRequestType { MemoriesV1 = 'MemoriesV1', MemoryToAssetsV1 = 'MemoryToAssetsV1', PartnersV1 = 'PartnersV1', - /** - @deprecated - */ + /** @deprecated */ PartnerAssetsV1 = 'PartnerAssetsV1', PartnerAssetsV2 = 'PartnerAssetsV2', PartnerAssetExifsV1 = 'PartnerAssetExifsV1', @@ -1054,9 +1016,7 @@ export enum SyncRequestType { StacksV1 = 'StacksV1', UsersV1 = 'UsersV1', PeopleV1 = 'PeopleV1', - /** - @deprecated - */ + /** @deprecated */ AssetFacesV1 = 'AssetFacesV1', AssetFacesV2 = 'AssetFacesV2', UserMetadataV1 = 'UserMetadataV1', @@ -1073,9 +1033,7 @@ export enum SyncEntityType { UserV1 = 'UserV1', UserDeleteV1 = 'UserDeleteV1', - /** - @deprecated - */ + /** @deprecated */ AssetV1 = 'AssetV1', AssetV2 = 'AssetV2', AssetDeleteV1 = 'AssetDeleteV1', @@ -1090,14 +1048,10 @@ export enum SyncEntityType { PartnerV1 = 'PartnerV1', PartnerDeleteV1 = 'PartnerDeleteV1', - /** - @deprecated - */ + /** @deprecated */ PartnerAssetV1 = 'PartnerAssetV1', PartnerAssetV2 = 'PartnerAssetV2', - /** - @deprecated - */ + /** @deprecated */ PartnerAssetBackfillV1 = 'PartnerAssetBackfillV1', PartnerAssetBackfillV2 = 'PartnerAssetBackfillV2', PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', @@ -1115,19 +1069,13 @@ export enum SyncEntityType { AlbumUserBackfillV1 = 'AlbumUserBackfillV1', AlbumUserDeleteV1 = 'AlbumUserDeleteV1', - /** - @deprecated - */ + /** @deprecated */ AlbumAssetCreateV1 = 'AlbumAssetCreateV1', AlbumAssetCreateV2 = 'AlbumAssetCreateV2', - /** - @deprecated - */ + /** @deprecated */ AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1', AlbumAssetUpdateV2 = 'AlbumAssetUpdateV2', - /** - @deprecated - */ + /** @deprecated */ AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1', AlbumAssetBackfillV2 = 'AlbumAssetBackfillV2', AlbumAssetExifCreateV1 = 'AlbumAssetExifCreateV1', diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 2a1f30220db167..93bcfe26e76b75 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -25,9 +25,7 @@ export type AuthenticatedOptions = AuthorizedRoute | PublicRoute; type ReflectorTarget = Parameters[1]; -/** -Resolves the `@Authenticated()` options of a route handler, with the defaults applied. -*/ +/** Resolves the `@Authenticated()` options of a route handler, with the defaults applied. */ export const getAuthenticatedOptions = (reflector: Reflector, target: ReflectorTarget) => { const options = reflector.getAllAndOverride(MetadataKey.AuthRoute, [target]); return options && { sharedLink: false, admin: false, public: false, setup: false, ...options }; diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index ab83cdaf0429a4..7fedc4eb3aed0b 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -56,25 +56,15 @@ type EventMap = { AssetDeleteAll: [{ assetIds: string[]; userId: string }]; AssetRestoreAll: [{ assetIds: string[]; userId: string }]; - /** - a worker receives a job and emits this event to run it - */ + /** a worker receives a job and emits this event to run it */ JobRun: [QueueName, JobItem]; - /** - job pre-hook - */ + /** job pre-hook */ JobStart: [QueueName, JobItem]; - /** - job post-hook - */ + /** job post-hook */ JobComplete: [QueueName, JobItem]; - /** - job finishes without error - */ + /** job finishes without error */ JobSuccess: [JobSuccessEvent]; - /** - job finishes with error - */ + /** job finishes with error */ JobError: [JobErrorEvent]; // queue events @@ -94,13 +84,9 @@ type EventMap = { // user events UserSignup: [{ notify: boolean; id: string; password?: string }]; UserCreate: [UserEvent]; - /** - user is soft deleted - */ + /** user is soft deleted */ UserTrash: [UserEvent]; - /** - user is permanently deleted - */ + /** user is permanently deleted */ UserDelete: [UserEvent]; UserRestore: [UserEvent]; diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 8902556eac6c43..e925bd503077a9 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -287,9 +287,7 @@ export class JobRepository { return this.moduleRef.get(getQueueToken(queue), { strict: false }); } - /** - @deprecated - */ + /** @deprecated */ // todo: remove this when asset notifications no longer need it. public async removeJob(name: JobName, jobID: string): Promise { const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobID); diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 6b9fa757ce5a8c..c1e8cfd2347660 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -464,9 +464,7 @@ export class MediaRepository { return value ? ((enumObj[pascalCase(value)] as Extract | undefined) ?? null) : null; } - /** - Parse a rational like "60000/1001" or "1/600" into `{ num, den }`. - */ + /** Parse a rational like "60000/1001" or "1/600" into `{ num, den }`. */ private parseRational(value: string | undefined): { num: number; den: number } | null { if (value) { const [num, den = 1] = value.split('/').map(Number); diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 8db06f755726e0..4bde10f1651740 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -135,9 +135,7 @@ export type AssetSearchBuilderOptions = Omit): Promise { + await sql`ALTER TABLE "asset_ocr" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_ocr_updatedAt" + BEFORE UPDATE ON "asset_ocr" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_ocr_updatedAt', '{"type":"trigger","name":"asset_ocr_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"asset_ocr_updatedAt\\"\\n BEFORE UPDATE ON \\"asset_ocr\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "asset_ocr_updatedAt" ON "asset_ocr";`.execute(db); + await sql`ALTER TABLE "asset_ocr" DROP COLUMN "updatedAt";`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_ocr_updatedAt';`.execute(db); +} diff --git a/server/src/schema/migrations/1786972746372-AssetOcrSyncReset.ts b/server/src/schema/migrations/1786972746372-AssetOcrSyncReset.ts new file mode 100644 index 00000000000000..767778e6a15850 --- /dev/null +++ b/server/src/schema/migrations/1786972746372-AssetOcrSyncReset.ts @@ -0,0 +1,12 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // OCR visibility updates did not bump updateId before the asset_ocr_updatedAt trigger was added, + // so clients never received those changes. There is no way to know which rows were missed, so + // reset the checkpoint to backfill all OCR rows on the next sync. + await sql`DELETE FROM session_sync_checkpoint WHERE type = 'AssetOcrV1'`.execute(db); +} + +export async function down(): Promise { + // Not implemented +} diff --git a/server/src/schema/tables/asset-ocr.table.ts b/server/src/schema/tables/asset-ocr.table.ts index f2c4e400325b4e..80f6c52dae3acf 100644 --- a/server/src/schema/tables/asset-ocr.table.ts +++ b/server/src/schema/tables/asset-ocr.table.ts @@ -5,12 +5,15 @@ import { Generated, PrimaryGeneratedColumn, Table, + Timestamp, + UpdateDateColumn, } from '@immich/sql-tools'; -import { UpdateIdColumn } from 'src/decorators'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { asset_ocr_delete_audit } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; @Table('asset_ocr') +@UpdatedAtTrigger('asset_ocr_updatedAt') @AfterDeleteTrigger({ scope: 'statement', function: asset_ocr_delete_audit, @@ -61,6 +64,9 @@ export class AssetOcrTable { @Column({ type: 'boolean', default: true }) isVisible!: Generated; + @UpdateDateColumn() + updatedAt!: Generated; + @UpdateIdColumn({ index: true }) updateId!: Generated; } diff --git a/server/src/schema/tables/memory.table.ts b/server/src/schema/tables/memory.table.ts index 24d1ae3f66c693..c132d42feeaf0c 100644 --- a/server/src/schema/tables/memory.table.ts +++ b/server/src/schema/tables/memory.table.ts @@ -45,21 +45,15 @@ export class MemoryTable { @Column({ type: 'jsonb' }) data!: Record; - /** - unless set to true, will be automatically deleted in the future - */ + /** unless set to true, will be automatically deleted in the future */ @Column({ type: 'boolean', default: false }) isSaved!: Generated; - /** - memories are sorted in ascending order by this value - */ + /** memories are sorted in ascending order by this value */ @Column({ type: 'timestamp with time zone' }) memoryAt!: Timestamp; - /** - when the user last viewed the memory - */ + /** when the user last viewed the memory */ @Column({ type: 'timestamp with time zone', nullable: true }) seenAt!: Timestamp | null; diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 379f0d64fb0590..56038192120d98 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -48,9 +48,7 @@ export type ValidateRequest = { metadata: { sharedLinkRoute: boolean; adminRoute: boolean; - /** - `false` explicitly means no permission is required, which otherwise defaults to `all` - */ + /** `false` explicitly means no permission is required, which otherwise defaults to `all` */ permission?: Permission | false; uri: string; }; diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 983ad62be5bc4c..a95d1f1497e9df 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -42,9 +42,7 @@ import { Tasks } from 'src/utils/tasks'; const POSTGRES_INT_MAX = 2_147_483_647; const POSTGRES_INT_MIN = -2_147_483_648; -/** -look for a date from these tags (in order) -*/ +/** look for a date from these tags (in order) */ const EXIF_DATE_TAGS: Array = [ 'SubSecDateTimeOriginal', 'SubSecCreateDate', diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts new file mode 100644 index 00000000000000..ff505c1e6766cc --- /dev/null +++ b/server/src/services/sync.service.spec.ts @@ -0,0 +1,78 @@ +import { Writable } from 'node:stream'; +import { SyncEntityType } from 'src/enum'; +import { send } from 'src/services/sync.service'; +import { serialize } from 'src/utils/sync'; + +type TestStream = { + stream: Writable; + chunks: string[]; + flushNext: () => void; + pendingCount: () => number; +}; + +const createTestStream = (highWaterMark: number): TestStream => { + const chunks: string[] = []; + const pendingCallbacks: Array<() => void> = []; + + const stream = new Writable({ + highWaterMark, + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + pendingCallbacks.push(callback); + }, + }); + + return { + stream, + chunks, + flushNext: () => pendingCallbacks.shift()?.(), + pendingCount: () => pendingCallbacks.length, + }; +}; + +describe('send', () => { + const item = { + type: SyncEntityType.SyncCompleteV1 as const, + data: {}, + ids: ['now-id'] as [string], + }; + + it('resolves immediately when the stream has capacity', async () => { + // A large highWaterMark means write() never signals backpressure for a + // single small item. + const { stream, chunks, flushNext } = createTestStream(1024 * 1024); + + const sendPromise = send(stream, item); + flushNext(); + await sendPromise; + + expect(chunks).toEqual([serialize(item)]); + }); + + it('waits for the drain event before resolving when the stream signals backpressure', async () => { + // A tiny highWaterMark means the very first write already exceeds + // capacity, so write() returns false and send() must wait for 'drain'. + const { stream, chunks, flushNext, pendingCount } = createTestStream(1); + + let resolved = false; + const sendPromise = send(stream, item).then(() => { + resolved = true; + }); + + // Let any pending microtasks run; send() should still be waiting on the + // underlying write to complete and 'drain' to fire — it must not resolve + // just because write() was called. + await Promise.resolve(); + await Promise.resolve(); + expect(resolved).toBe(false); + expect(pendingCount()).toBe(1); + + // Completing the write lets the stream's internal buffer drop back below + // highWaterMark, which is what triggers the 'drain' event. + flushNext(); + await sendPromise; + + expect(resolved).toBe(true); + expect(chunks).toEqual([serialize(item)]); + }); +}); diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index e3842b1503c816..39ccd9ae6f68b7 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { Insertable } from 'kysely'; import { DateTime, Duration } from 'luxon'; +import { once } from 'node:events'; import { Writable } from 'node:stream'; import { OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -42,12 +43,17 @@ const isEntityBackfillComplete = (createId: string, checkpoint: SyncAck | undefi const getStartId = (createId: string, checkpoint: SyncAck | undefined): string | undefined => createId === checkpoint?.updateId ? checkpoint?.extraId : undefined; -const send = (response: Writable, item: SerializeOptions) => { - response.write(serialize(item)); +export const send = async ( + response: Writable, + item: SerializeOptions, +) => { + if (!response.write(serialize(item))) { + await once(response, 'drain'); + } }; -const sendEntityBackfillCompleteAck = (response: Writable, ackType: SyncEntityType, id: string) => { - send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType, ids: [id, COMPLETE_ID] }); +const sendEntityBackfillCompleteAck = async (response: Writable, ackType: SyncEntityType, id: string) => { + await send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType, ids: [id, COMPLETE_ID] }); }; export const SYNC_TYPES_ORDER = [ @@ -141,7 +147,7 @@ export class SyncService extends BaseService { const isPendingSyncReset = await this.sessionRepository.isPendingSyncReset(session.id); if (isPendingSyncReset) { - send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} }); + await send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} }); response.end(); return; } @@ -150,7 +156,7 @@ export class SyncService extends BaseService { const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)])); if (this.needsFullSync(checkpointMap)) { - send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} }); + await send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} }); response.end(); return; } @@ -201,7 +207,7 @@ export class SyncService extends BaseService { await handler(); } - send(response, { type: SyncEntityType.SyncCompleteV1, ids: [nowId], data: {} }); + await send(response, { type: SyncEntityType.SyncCompleteV1, ids: [nowId], data: {} }); response.end(); } @@ -242,7 +248,11 @@ export class SyncService extends BaseService { const upsertType = SyncEntityType.AuthUserV1; const upserts = this.syncRepository.authUser.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, profileImagePath, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: { ...data, hasProfileImage: !!profileImagePath } }); + await send(response, { + type: upsertType, + ids: [updateId], + data: { ...data, hasProfileImage: !!profileImagePath }, + }); } } @@ -250,13 +260,17 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.UserDeleteV1; const deletes = this.syncRepository.user.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.UserV1; const upserts = this.syncRepository.user.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, profileImagePath, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: { ...data, hasProfileImage: !!profileImagePath } }); + await send(response, { + type: upsertType, + ids: [updateId], + data: { ...data, hasProfileImage: !!profileImagePath }, + }); } } @@ -264,13 +278,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.PartnerDeleteV1; const deletes = this.syncRepository.partner.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.PartnerV1; const upserts = this.syncRepository.partner.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -282,13 +296,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AssetDeleteV1; const deletes = this.syncRepository.asset.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AssetV2; const upserts = this.syncRepository.asset.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) }); + await send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -307,7 +321,7 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.PartnerAssetDeleteV1; const deletes = this.syncRepository.partnerAsset.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const backfillType = SyncEntityType.PartnerAssetBackfillV2; @@ -334,14 +348,14 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { + await send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV2(data), }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (partners.length > 0) { await this.upsertBackfillCheckpoint({ @@ -353,7 +367,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.partnerAsset.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) }); + await send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -361,7 +375,7 @@ export class SyncService extends BaseService { const upsertType = SyncEntityType.AssetExifV1; const upserts = this.syncRepository.assetExif.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -370,13 +384,13 @@ export class SyncService extends BaseService { const deletes = this.syncRepository.assetEdit.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AssetEditV1; const upserts = this.syncRepository.assetEdit.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -411,10 +425,10 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [partner.createId, updateId], data }); + await send(response, { type: backfillType, ids: [partner.createId, updateId], data }); } - sendEntityBackfillCompleteAck(response, backfillType, partner.createId); + await sendEntityBackfillCompleteAck(response, backfillType, partner.createId); } } else if (partners.length > 0) { await this.upsertBackfillCheckpoint({ @@ -426,7 +440,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.partnerAssetExif.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -434,14 +448,14 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AlbumDeleteV1; const deletes = this.syncRepository.album.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AlbumV1; const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { const albumUsers = await this.syncRepository.album.getAlbumUsers(data.id); - send(response, { + await send(response, { type: upsertType, ids: [updateId], // TODO: return null instead of '' in v4 @@ -454,14 +468,18 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AlbumDeleteV1; const deletes = this.syncRepository.album.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AlbumV2; const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { // TODO: return null instead of '' in v4 - send(response, { type: upsertType, ids: [updateId], data: { ...data, description: data.description ?? '' } }); + await send(response, { + type: upsertType, + ids: [updateId], + data: { ...data, description: data.description ?? '' }, + }); } } @@ -474,7 +492,7 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AlbumUserDeleteV1; const deletes = this.syncRepository.albumUser.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const backfillType = SyncEntityType.AlbumUserBackfillV1; @@ -501,10 +519,10 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [createId, updateId], data }); + await send(response, { type: backfillType, ids: [createId, updateId], data }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (albums.length > 0) { await this.upsertBackfillCheckpoint({ @@ -516,7 +534,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.albumUser.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -559,10 +577,10 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV2(data) }); + await send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV2(data) }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (albums.length > 0) { await this.upsertBackfillCheckpoint({ @@ -578,7 +596,7 @@ export class SyncService extends BaseService { createCheckpoint, ); for await (const { updateId, ...data } of updates) { - send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV2(data) }); + await send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -586,7 +604,7 @@ export class SyncService extends BaseService { let isFirst = true; for await (const { updateId, ...data } of creates) { if (isFirst) { - send(response, { + await send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType: SyncEntityType.AlbumAssetUpdateV2, @@ -594,7 +612,7 @@ export class SyncService extends BaseService { }); isFirst = false; } - send(response, { type: createType, ids: [updateId], data: mapSyncAssetV2(data) }); + await send(response, { type: createType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -630,10 +648,10 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [createId, updateId], data }); + await send(response, { type: backfillType, ids: [createId, updateId], data }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (albums.length > 0) { await this.upsertBackfillCheckpoint({ @@ -649,7 +667,7 @@ export class SyncService extends BaseService { createCheckpoint, ); for await (const { updateId, ...data } of updates) { - send(response, { type: updateType, ids: [updateId], data }); + await send(response, { type: updateType, ids: [updateId], data }); } } @@ -657,7 +675,7 @@ export class SyncService extends BaseService { let isFirst = true; for await (const { updateId, ...data } of creates) { if (isFirst) { - send(response, { + await send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType: SyncEntityType.AlbumAssetExifUpdateV1, @@ -665,7 +683,7 @@ export class SyncService extends BaseService { }); isFirst = false; } - send(response, { type: createType, ids: [updateId], data }); + await send(response, { type: createType, ids: [updateId], data }); } } @@ -678,7 +696,7 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AlbumToAssetDeleteV1; const deletes = this.syncRepository.albumToAsset.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const backfillType = SyncEntityType.AlbumToAssetBackfillV1; @@ -705,10 +723,10 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [createId, updateId], data }); + await send(response, { type: backfillType, ids: [createId, updateId], data }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (albums.length > 0) { await this.upsertBackfillCheckpoint({ @@ -720,7 +738,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.albumToAsset.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -728,13 +746,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.MemoryDeleteV1; const deletes = this.syncRepository.memory.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.MemoryV1; const upserts = this.syncRepository.memory.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -742,13 +760,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.MemoryToAssetDeleteV1; const deletes = this.syncRepository.memoryToAsset.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.MemoryToAssetV1; const upserts = this.syncRepository.memoryToAsset.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -756,13 +774,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.StackDeleteV1; const deletes = this.syncRepository.stack.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.StackV1; const upserts = this.syncRepository.stack.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -775,7 +793,7 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.PartnerStackDeleteV1; const deletes = this.syncRepository.partnerStack.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const backfillType = SyncEntityType.PartnerStackBackfillV1; @@ -802,14 +820,14 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { + await send(response, { type: backfillType, ids: [createId, updateId], data, }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (partners.length > 0) { await this.upsertBackfillCheckpoint({ @@ -821,7 +839,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.partnerStack.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -829,13 +847,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.PersonDeleteV1; const deletes = this.syncRepository.person.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.PersonV1; const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -849,13 +867,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AssetFaceDeleteV1; const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AssetFaceV2; const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -864,14 +882,14 @@ export class SyncService extends BaseService { const deletes = this.syncRepository.userMetadata.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.UserMetadataV1; const upserts = this.syncRepository.userMetadata.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -888,7 +906,7 @@ export class SyncService extends BaseService { ); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AssetMetadataV1; @@ -898,7 +916,7 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -915,7 +933,7 @@ export class SyncService extends BaseService { ); for await (const row of deletes) { - send(response, { type: deleteType, ids: [row.id], data: row }); + await send(response, { type: deleteType, ids: [row.id], data: row }); } const upsertType = SyncEntityType.AssetOcrV1; @@ -925,7 +943,7 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } diff --git a/server/src/types.ts b/server/src/types.ts index 7cb1cd34a25aa9..d31841ffd320a4 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -105,33 +105,19 @@ export interface AudioStreamInfo { bitrate: number; } -/** -Packet-derived video data needed for accurate HLS playlists. -*/ +/** Packet-derived video data needed for accurate HLS playlists. */ export interface VideoPacketInfo { - /** - Sum of source packet duration across all packets (includes discard). - */ + /** Sum of source packet duration across all packets (includes discard). */ totalDuration: number; - /** - Post-discard packet count. - */ + /** Post-discard packet count. */ packetCount: number; - /** - Output CFR frame count at `packetCount / format.duration`. - */ + /** Output CFR frame count at `packetCount / format.duration`. */ outputFrames: number; - /** - All keyframe PTS in source ticks, including pre-roll discard keyframes. - */ + /** All keyframe PTS in source ticks, including pre-roll discard keyframes. */ keyframePts: number[]; - /** - Cumulative packet duration through each keyframe, inclusive. - */ + /** Cumulative packet duration through each keyframe, inclusive. */ keyframeAccDuration: number[]; - /** - Each keyframe's own packet duration (needed for VFR). - */ + /** Each keyframe's own packet duration (needed for VFR). */ keyframeOwnDuration: number[]; } @@ -224,9 +210,7 @@ export interface IBaseJob { } export interface IDelayedJob extends IBaseJob { - /** - The minimum time to wait to execute this job, in milliseconds. - */ + /** The minimum time to wait to execute this job, in milliseconds. */ delay?: number; } @@ -468,9 +452,7 @@ export interface ExtensionVersion { export interface ImmichFile extends Express.Multer.File { uuid: string; - /** - sha1 hash of file - */ + /** sha1 hash of file */ checksum: Buffer; } @@ -540,9 +522,7 @@ export type SystemFlags = { mountChecks: Record }; export type MaintenanceModeState = { isMaintenanceMode: true; secret: string; action?: SetMaintenanceModeDto } | { isMaintenanceMode: false }; export type MemoriesState = { - /** - memories have already been created through this date - */ + /** memories have already been created through this date */ lastOnThisDayDate: string; }; export type MediaLocation = { location: string }; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 04270781af7b56..5420e6036161d6 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -113,9 +113,7 @@ export const removeAssets = async ( export type PartnerIdOptions = { userId: string; repository: PartnerRepository; - /** - only include partners with `inTimeline: true` - */ + /** only include partners with `inTimeline: true` */ timelineEnabled?: boolean; }; export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: PartnerIdOptions) => { diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 74e38fad89f36d..5122b0a9d4b333 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -391,9 +391,7 @@ export function withEdits(eb: ExpressionBuilder): AliasedEditAction } const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); -/** -TODO: This should only be used for search-related queries, not as a general purpose query builder -*/ +/** TODO: This should only be used for search-related queries, not as a general purpose query builder */ export function searchAssetBuilderLegacy(kysely: Kysely, options: AssetSearchBuilderOptions) { options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index f5a39b4733be51..bb5e7be95b8527 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -164,9 +164,7 @@ export const mimeTypes = { transparentCapableExtensions.has(getFilenameExtension(filename).toLowerCase()), isRaw: (filename: string) => isType(filename, raw), lookup, - /** - return an extension (including a leading `.`) for a mime-type - */ + /** return an extension (including a leading `.`) for a mime-type */ toExtension, assetType: (filename: string) => { const contentType = lookup(filename); diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts index a2517381c4a5c9..a3a9d56c42a1ae 100644 --- a/server/src/utils/workflow.ts +++ b/server/src/utils/workflow.ts @@ -12,9 +12,7 @@ export const triggerMap: Record = { export const getWorkflowTriggers = () => Object.entries(triggerMap).map(([trigger, types]) => ({ trigger: trigger as WorkflowTrigger, types })); -/** -some types extend other types and have implied compatibility -*/ +/** some types extend other types and have implied compatibility */ const inferredMap: Record = { [WorkflowType.AssetV1]: [], // [WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1], diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 6f20b0a427d846..4421a098010a68 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -48,9 +48,7 @@ const probeStubDefault: VideoInfo = { audioStreams: probeStubDefaultAudioStream, }; -/** -Fixtures in the shape `mediaRepository.probe()` returns (arrays of streams, raw ffprobe format). -*/ +/** Fixtures in the shape `mediaRepository.probe()` returns (arrays of streams, raw ffprobe format). */ export const videoInfoStub = { noVideoStreams: Object.freeze({ ...probeStubDefault, videoStreams: [] }), noAudioStreams: Object.freeze({ ...probeStubDefault, audioStreams: [] }), diff --git a/server/test/medium/specs/services/ocr.service.spec.ts b/server/test/medium/specs/services/ocr.service.spec.ts index 5f69aea8505bd5..efcc7aa136da51 100644 --- a/server/test/medium/specs/services/ocr.service.spec.ts +++ b/server/test/medium/specs/services/ocr.service.spec.ts @@ -56,6 +56,7 @@ describe(OcrService.name, () => { boxScore: 0.99, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Test OCR', textScore: 0.95, isVisible: true, @@ -107,6 +108,7 @@ describe(OcrService.name, () => { boxScore: 0.7, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'One', textScore: 0.9, isVisible: true, @@ -124,6 +126,7 @@ describe(OcrService.name, () => { boxScore: 0.67, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Two', textScore: 0.89, isVisible: true, @@ -141,6 +144,7 @@ describe(OcrService.name, () => { boxScore: 0.65, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Three', textScore: 0.88, isVisible: true, @@ -158,6 +162,7 @@ describe(OcrService.name, () => { boxScore: 0.62, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Four', textScore: 0.87, isVisible: true, @@ -175,6 +180,7 @@ describe(OcrService.name, () => { boxScore: 0.6, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Five', textScore: 0.86, isVisible: true, diff --git a/server/test/medium/specs/sync/sync-asset-ocr.spec.ts b/server/test/medium/specs/sync/sync-asset-ocr.spec.ts index 02d6808ecf88f1..7d43bc6eb1d621 100644 --- a/server/test/medium/specs/sync/sync-asset-ocr.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-ocr.spec.ts @@ -3,6 +3,7 @@ import { SyncEntityType, SyncRequestType } from 'src/enum'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { DB } from 'src/schema'; import { SyncTestContext } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -303,6 +304,61 @@ describe(SyncEntityType.AssetOcrV1, () => { await ctx.syncAckAll(auth, updatedResponse); await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]); }); + + it('should sync in-place OCR visibility toggles (e.g. after an asset edit)', async () => { + const { auth, user, ctx } = await setup(); + + const ocrRepo = ctx.get(OcrRepository); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ocrRepo.upsert( + asset.id, + [ + { + assetId: asset.id, + x1: 0.1, + y1: 0.2, + x2: 0.9, + y2: 0.2, + x3: 0.9, + y3: 0.8, + x4: 0.1, + y4: 0.8, + boxScore: 0.95, + textScore: 0.92, + text: 'Hello World', + isVisible: true, + }, + ], + 'Hello World', + ); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]); + const ocrId = (response[0] as { data: { id: string } }).data.id; + await ctx.syncAckAll(auth, response); + + await ocrRepo.updateOcrVisibilities( + asset.id, + [], + [factory.assetOcr({ id: ocrId, assetId: asset.id, text: 'Hello World' })], + ); + + const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]); + expect(updatedResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: ocrId, + assetId: asset.id, + isVisible: false, + }), + type: 'AssetOcrV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, updatedResponse); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]); + }); }); describe(SyncEntityType.AssetOcrDeleteV1, () => { diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 545c05e881badd..14f74d1a25a655 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -185,10 +185,12 @@ const assetOcrFactory = ( textScore?: number; text?: string; isVisible?: boolean; + updatedAt?: Date; } = {}, ) => ({ id: newUuid(), updateId: newUuidV7(), + updatedAt: newDate(), assetId: newUuid(), x1: 0.1, y1: 0.2, diff --git a/web/eslint.config.js b/web/eslint.config.js index f7fd82d75172c7..c7075db443bf04 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -146,6 +146,7 @@ export default typescriptEslint.config( 'unicorn/prefer-minimal-ternary': 'off', 'unicorn/no-empty-file': 'off', 'unicorn/prefer-simple-condition-first': 'off', + 'unicorn/single-line-block-comment-style': ['error', 'single-line'], // prefer the typescript-eslint type-aware version 'unicorn/require-array-sort-compare': 'off', '@typescript-eslint/require-array-sort-compare': 'error', diff --git a/web/src/lib/actions/focus.ts b/web/src/lib/actions/focus.ts index 3b2d70dbbf62b0..3b6049f24732fb 100644 --- a/web/src/lib/actions/focus.ts +++ b/web/src/lib/actions/focus.ts @@ -1,6 +1,4 @@ -/** -Focus the given element when it is mounted. -*/ +/** Focus the given element when it is mounted. */ export const initInput = (element: HTMLInputElement) => { element.focus(); }; diff --git a/web/src/lib/components/album-page/AlbumsList.svelte b/web/src/lib/components/album-page/AlbumsList.svelte index 318e501b9d719a..69ba212c19c2f1 100644 --- a/web/src/lib/components/album-page/AlbumsList.svelte +++ b/web/src/lib/components/album-page/AlbumsList.svelte @@ -55,9 +55,7 @@ } const groupOptions: AlbumGroupOption = { - /** - No grouping - */ + /** No grouping */ [AlbumGroupBy.None]: (order, albums): AlbumGroup[] => { return [ { @@ -68,9 +66,7 @@ ]; }, - /** - Group by year - */ + /** Group by year */ [AlbumGroupBy.Year]: (order, albums): AlbumGroup[] => { const unknownYear = $t('unknown_year'); const useStartDate = userSettings.sortBy === AlbumSortBy.OldestPhoto; @@ -96,9 +92,7 @@ })); }, - /** - Group by owner - */ + /** Group by owner */ [AlbumGroupBy.Owner]: (order, albums): AlbumGroup[] => { const currentUserId = authManager.user.id; const groupedByOwnerIds = groupBy(albums, (album) => album.albumUsers[0].user.id); diff --git a/web/src/lib/components/asset-viewer/PhotoSphereViewerAdapter.svelte b/web/src/lib/components/asset-viewer/PhotoSphereViewerAdapter.svelte index 76dedff6c5af35..a98418d65aa971 100644 --- a/web/src/lib/components/asset-viewer/PhotoSphereViewerAdapter.svelte +++ b/web/src/lib/components/asset-viewer/PhotoSphereViewerAdapter.svelte @@ -108,9 +108,7 @@ updateOcrBoxes(ocrManager.showOverlay, ocrManager.data); }); - /** - Use updateOnly=true on zoom, pan, or resize. - */ + /** Use updateOnly=true on zoom, pan, or resize. */ const updateOcrBoxes = (showOverlay: boolean, ocrData: OcrBoundingBox[], updateOnly = false) => { if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) { return; diff --git a/web/src/lib/components/timeline/Scrubber.svelte b/web/src/lib/components/timeline/Scrubber.svelte index bdac1823da5139..67ec87ff7da291 100644 --- a/web/src/lib/components/timeline/Scrubber.svelte +++ b/web/src/lib/components/timeline/Scrubber.svelte @@ -11,54 +11,30 @@ import { fade, fly } from 'svelte/transition'; interface Props { - /** - Offset from the top of the timeline (e.g., for headers) - */ + /** Offset from the top of the timeline (e.g., for headers) */ timelineTopOffset?: number; - /** - Offset from the bottom of the timeline (e.g., for footers) - */ + /** Offset from the bottom of the timeline (e.g., for footers) */ timelineBottomOffset?: number; - /** - Total height of the scrubber component - */ + /** Total height of the scrubber component */ height?: number; - /** - Timeline manager instance that controls the timeline state - */ + /** Timeline manager instance that controls the timeline state */ timelineManager: TimelineManager; - /** - Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted - */ + /** Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted */ timelineScrollPercent?: number; - /** - The percentage of scroll through the month that is currently intersecting the top boundary of the viewport - */ + /** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */ viewportTopMonthScrollPercent?: number; - /** - The year/month of the timeline month at the top of the viewport - */ + /** The year/month of the timeline month at the top of the viewport */ viewportTopMonth?: ViewportTopMonth; - /** - Width of the scrubber component in pixels (bindable for parent component margin adjustments) - */ + /** Width of the scrubber component in pixels (bindable for parent component margin adjustments) */ scrubberWidth?: number; - /** - Callback fired when user interacts with the scrubber to navigate - */ + /** Callback fired when user interacts with the scrubber to navigate */ onScrub?: ScrubberListener; - /** - Callback fired when keyboard events occur on the scrubber - */ + /** Callback fired when keyboard events occur on the scrubber */ onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void; - /** - Callback fired when scrubbing starts - */ + /** Callback fired when scrubbing starts */ startScrub?: ScrubberListener; - /** - Callback fired when scrubbing stops - */ + /** Callback fired when scrubbing stops */ stopScrub?: ScrubberListener; } diff --git a/web/src/lib/utils/album-utils.ts b/web/src/lib/utils/album-utils.ts index cfd090818c9f6a..97546f0b0021f5 100644 --- a/web/src/lib/utils/album-utils.ts +++ b/web/src/lib/utils/album-utils.ts @@ -223,46 +223,34 @@ export const stringToSortOrder = (order: string) => { }; const sortOptions: AlbumSortOption = { - /** - Sort by album title - */ + /** Sort by album title */ [AlbumSortBy.Title]: (order, albums) => { const sortSign = order === SortOrder.Desc ? -1 : 1; return albums.slice().sort((a, b) => a.albumName.localeCompare(b.albumName, get(locale)) * sortSign); }, - /** - Sort by asset count - */ + /** Sort by asset count */ [AlbumSortBy.ItemCount]: (order, albums) => { return orderBy(albums, 'assetCount', [order]); }, - /** - Sort by last modified - */ + /** Sort by last modified */ [AlbumSortBy.DateModified]: (order, albums) => { return orderBy(albums, [({ updatedAt }) => new Date(updatedAt)], [order]); }, - /** - Sort by creation date - */ + /** Sort by creation date */ [AlbumSortBy.DateCreated]: (order, albums) => { return orderBy(albums, [({ createdAt }) => new Date(createdAt)], [order]); }, - /** - Sort by the most recent photo date - */ + /** Sort by the most recent photo date */ [AlbumSortBy.MostRecentPhoto]: (order, albums) => { albums = orderBy(albums, [({ endDate }) => (endDate ? new Date(endDate) : '')], [order]); return albums.sort(sortUnknownYearAlbums); }, - /** - Sort by the oldest photo date - */ + /** Sort by the oldest photo date */ [AlbumSortBy.OldestPhoto]: (order, albums) => { albums = orderBy(albums, [({ startDate }) => (startDate ? new Date(startDate) : '')], [order]); return albums.sort(sortUnknownYearAlbums); diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts index 9f2501be83e218..ada3b12d0ea4cd 100644 --- a/web/src/lib/utils/invocationTracker.ts +++ b/web/src/lib/utils/invocationTracker.ts @@ -6,13 +6,9 @@ import { handleError } from '$lib/utils/handle-error'; * and allowing operations to check if they're still valid. */ export class InvocationTracker { - /** - Counter for the number of invocations that have been started - */ + /** Counter for the number of invocations that have been started */ invocationsStarted = 0; - /** - Counter for the number of invocations that have been completed - */ + /** Counter for the number of invocations that have been completed */ invocationsEnded = 0; constructor() {} diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index 22678fd5fa7161..9bba61d915f7cd 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -92,9 +92,7 @@ interface AssetGridRoute extends Route { type ImmichRoute = AssetRoute | AssetGridRoute; type NavOptions = { - /* - navigate even if url is the same - */ + /* navigate even if url is the same */ forceNavigate?: boolean | undefined; replaceState?: boolean | undefined; noScroll?: boolean | undefined; diff --git a/web/src/params/id.ts b/web/src/params/id.ts index 73e8eec866bd06..440d4532a9c3a9 100644 --- a/web/src/params/id.ts +++ b/web/src/params/id.ts @@ -1,9 +1,7 @@ import type { ParamMatcher } from '@sveltejs/kit'; import { UUID_REGEX } from '$lib/constants'; -/* -Returns true if the given param matches UUID format -*/ +/* Returns true if the given param matches UUID format */ export const match: ParamMatcher = (param: string) => { return UUID_REGEX.test(param); }; diff --git a/web/src/routes/(user)/places/PlacesList.svelte b/web/src/routes/(user)/places/PlacesList.svelte index 7ac9b287bad796..2f2dfed9ca71a2 100644 --- a/web/src/routes/(user)/places/PlacesList.svelte +++ b/web/src/routes/(user)/places/PlacesList.svelte @@ -33,9 +33,7 @@ } const groupOptions: PlacesGroupOption = { - /** - No grouping - */ + /** No grouping */ [PlacesGroupBy.None]: (places): PlacesGroup[] => { return [ { @@ -46,9 +44,7 @@ ]; }, - /** - Group by year - */ + /** Group by year */ [PlacesGroupBy.Country]: (places): PlacesGroup[] => { const unknownCountry = $t('unknown_country');