Compare commits

..

1 Commits

Author SHA1 Message Date
shenlong-tanwen
5ccc05feeb fix: foreground cloud sync 2026-01-22 20:37:44 +05:30
25 changed files with 41 additions and 496 deletions

View File

@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
- name: Load parameters
id: parameters

View File

@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
- name: Destroy Docs Subdomain
env:

View File

@@ -502,12 +502,7 @@ jobs:
- name: Run e2e tests (web)
env:
CI: true
run: npx playwright test --project=chromium
if: ${{ !cancelled() }}
- name: Run ui tests (web)
env:
CI: true
run: npx playwright test --project=ui
run: npx playwright test
if: ${{ !cancelled() }}
- name: Archive test results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0

View File

@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
testMatch: /.*\.e2e-spec\.ts/,
workers: 1,
},
{
name: 'ui',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.ui-spec\.ts/,
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
// {
// name: 'parallel tests',
// use: { ...devices['Desktop Chrome'] },
// testMatch: /.*\.parallel-e2e-spec\.ts/,
// fullyParallel: true,
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
// },
// {
// name: 'firefox',

View File

@@ -1007,7 +1007,6 @@
"error_change_sort_album": "Failed to change album sort order",
"error_delete_face": "Error deleting face from asset",
"error_getting_places": "Error getting places",
"error_loading_albums": "Error loading albums",
"error_loading_image": "Error loading image",
"error_loading_partners": "Error loading partners: {error}",
"error_retrieving_asset_information": "Error retrieving asset information",
@@ -1139,9 +1138,6 @@
"unable_to_upload_file": "Unable to upload file"
},
"errors_text": "Errors",
"exclude_albums": "Exclude albums",
"exclude_albums_description": "Assets in selected albums will not be removed from your device",
"excluded_albums_count": "{count} albums excluded",
"exclusion_pattern": "Exclusion pattern",
"exif": "Exif",
"exif_bottom_sheet_description": "Add Description...",
@@ -1557,7 +1553,6 @@
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_found": "No albums found",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
"no_albums_yet": "It looks like you do not have any albums yet.",

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart';
@@ -23,13 +22,8 @@ class BackgroundSyncManager {
final SyncCallback? onHashingComplete;
final SyncErrorCallback? onHashingError;
final SyncCallback? onCloudIdSyncStart;
final SyncCallback? onCloudIdSyncComplete;
final SyncErrorCallback? onCloudIdSyncError;
Cancelable<bool?>? _syncTask;
Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _cloudIdSyncTask;
Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _linkedAlbumSyncTask;
Cancelable<void>? _hashTask;
@@ -44,9 +38,6 @@ class BackgroundSyncManager {
this.onHashingStart,
this.onHashingComplete,
this.onHashingError,
this.onCloudIdSyncStart,
this.onCloudIdSyncComplete,
this.onCloudIdSyncError,
});
Future<void> cancel() async {
@@ -64,12 +55,6 @@ class BackgroundSyncManager {
_syncWebsocketTask?.cancel();
_syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future);
}
@@ -216,25 +201,6 @@ class BackgroundSyncManager {
_linkedAlbumSyncTask = null;
});
}
Future<void> syncCloudIds() {
if (_cloudIdSyncTask != null) {
return _cloudIdSyncTask!.future;
}
onCloudIdSyncStart?.call();
_cloudIdSyncTask = runInIsolateGentle(computation: m.syncCloudIds);
return _cloudIdSyncTask!
.whenComplete(() {
onCloudIdSyncComplete?.call();
_cloudIdSyncTask = null;
})
.catchError((error) {
onCloudIdSyncError?.call(error.toString());
_cloudIdSyncTask = null;
});
}
}
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(

View File

@@ -10,14 +10,13 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart' hide AssetVisibility;
Future<void> syncCloudIds(ProviderContainer ref) async {
Future<void> syncCloudIds(Ref ref) async {
if (!CurrentPlatform.isIOS) {
return;
}
@@ -35,14 +34,6 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
}
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
try {
await ref.read(syncStreamServiceProvider).sync();
} catch (e, s) {
logger.fine('Failed to complete remote sync before cloudId migration.', e, s);
return;
}
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {

View File

@@ -135,7 +135,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
Set<String> excludedAlbumIds = const {},
}) async {
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
@@ -160,13 +159,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
// Exclude assets that are in iOS shared albums
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets);
if (excludedAlbumIds.isNotEmpty) {
final excludedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(_db.localAlbumAssetEntity.albumId.isIn(excludedAlbumIds));
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(excludedAlbumAssets);
}
if (filterType == AssetFilterType.photosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
} else if (filterType == AssetFilterType.videosOnly) {

View File

@@ -75,7 +75,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider),
backgroundManager.syncCloudIds(),
]);
} else {
await backgroundManager.hashAssets();

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
@@ -156,11 +157,11 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
]);
if (syncSuccess) {
await Future.wait([
_safeRun(syncCloudIds(_ref), "syncCloudIds"),
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
_resumeBackup();
}),
_resumeBackup(),
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
]);
} else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets");

View File

@@ -1,19 +1,23 @@
import 'dart:async';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/hash.dart';
@@ -172,6 +176,15 @@ class AuthNotifier extends StateNotifier<AuthState> {
isAdmin: user.isAdmin,
);
// TODO: Temporarily run cloud Id sync here until we have a better place to do it
unawaited(
_ref.read(backgroundSyncProvider).syncRemote().then((success) {
if (success) {
return syncCloudIds(_ref);
}
}),
);
return true;
}

View File

@@ -28,9 +28,6 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
onHashingStart: syncStatusNotifier.startHashJob,
onHashingComplete: syncStatusNotifier.completeHashJob,
onHashingError: syncStatusNotifier.errorHashJob,
onCloudIdSyncStart: syncStatusNotifier.startCloudIdSync,
onCloudIdSyncComplete: syncStatusNotifier.completeCloudIdSync,
onCloudIdSyncError: syncStatusNotifier.errorCloudIdSync,
);
ref.onDispose(manager.cancel);
return manager;

View File

@@ -11,7 +11,6 @@ class CleanupState {
final bool isDeleting;
final AssetFilterType filterType;
final bool keepFavorites;
final Set<String> excludedAlbumIds;
const CleanupState({
this.selectedDate,
@@ -20,7 +19,6 @@ class CleanupState {
this.isDeleting = false,
this.filterType = AssetFilterType.all,
this.keepFavorites = true,
this.excludedAlbumIds = const {},
});
CleanupState copyWith({
@@ -30,7 +28,6 @@ class CleanupState {
bool? isDeleting,
AssetFilterType? filterType,
bool? keepFavorites,
Set<String>? excludedAlbumIds,
}) {
return CleanupState(
selectedDate: selectedDate ?? this.selectedDate,
@@ -39,7 +36,6 @@ class CleanupState {
isDeleting: isDeleting ?? this.isDeleting,
filterType: filterType ?? this.filterType,
keepFavorites: keepFavorites ?? this.keepFavorites,
excludedAlbumIds: excludedAlbumIds ?? this.excludedAlbumIds,
);
}
}
@@ -66,20 +62,6 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
}
void toggleExcludedAlbum(String albumId) {
final newExcludedAlbumIds = Set<String>.from(state.excludedAlbumIds);
if (newExcludedAlbumIds.contains(albumId)) {
newExcludedAlbumIds.remove(albumId);
} else {
newExcludedAlbumIds.add(albumId);
}
state = state.copyWith(excludedAlbumIds: newExcludedAlbumIds, assetsToDelete: []);
}
void setExcludedAlbumIds(Set<String> albumIds) {
state = state.copyWith(excludedAlbumIds: albumIds, assetsToDelete: []);
}
Future<void> scanAssets() async {
if (_userId == null || state.selectedDate == null) {
return;
@@ -92,7 +74,6 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
state.selectedDate!,
filterType: state.filterType,
keepFavorites: state.keepFavorites,
excludedAlbumIds: state.excludedAlbumIds,
);
state = state.copyWith(assetsToDelete: assets, isScanning: false);
} catch (e) {

View File

@@ -21,7 +21,6 @@ class SyncStatusState {
final SyncStatus remoteSyncStatus;
final SyncStatus localSyncStatus;
final SyncStatus hashJobStatus;
final SyncStatus cloudIdSyncStatus;
final String? errorMessage;
@@ -29,7 +28,6 @@ class SyncStatusState {
this.remoteSyncStatus = SyncStatus.idle,
this.localSyncStatus = SyncStatus.idle,
this.hashJobStatus = SyncStatus.idle,
this.cloudIdSyncStatus = SyncStatus.idle,
this.errorMessage,
});
@@ -37,14 +35,12 @@ class SyncStatusState {
SyncStatus? remoteSyncStatus,
SyncStatus? localSyncStatus,
SyncStatus? hashJobStatus,
SyncStatus? cloudIdSyncStatus,
String? errorMessage,
}) {
return SyncStatusState(
remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
localSyncStatus: localSyncStatus ?? this.localSyncStatus,
hashJobStatus: hashJobStatus ?? this.hashJobStatus,
cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@@ -52,7 +48,6 @@ class SyncStatusState {
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
bool get isHashing => hashJobStatus == SyncStatus.syncing;
bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing;
@override
bool operator ==(Object other) {
@@ -61,12 +56,11 @@ class SyncStatusState {
other.remoteSyncStatus == remoteSyncStatus &&
other.localSyncStatus == localSyncStatus &&
other.hashJobStatus == hashJobStatus &&
other.cloudIdSyncStatus == cloudIdSyncStatus &&
other.errorMessage == errorMessage;
}
@override
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage);
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, errorMessage);
}
class SyncStatusNotifier extends Notifier<SyncStatusState> {
@@ -77,7 +71,6 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
remoteSyncStatus: SyncStatus.idle,
localSyncStatus: SyncStatus.idle,
hashJobStatus: SyncStatus.idle,
cloudIdSyncStatus: SyncStatus.idle,
);
}
@@ -116,18 +109,6 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
void startHashJob() => setHashJobStatus(SyncStatus.syncing);
void completeHashJob() => setHashJobStatus(SyncStatus.success);
void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error);
///
/// Cloud ID Sync Job
///
void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) {
state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
}
void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing);
void completeCloudIdSync() => setCloudIdSyncStatus(SyncStatus.success);
void errorCloudIdSync(String error) => setCloudIdSyncStatus(SyncStatus.error, error);
}
final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new);

View File

@@ -20,14 +20,12 @@ class CleanupService {
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
Set<String> excludedAlbumIds = const {},
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
filterType: filterType,
keepFavorites: keepFavorites,
excludedAlbumIds: excludedAlbumIds,
);
}

View File

@@ -3,14 +3,12 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/cleanup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class FreeUpSpaceSettings extends ConsumerStatefulWidget {
@@ -197,11 +195,6 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
if (state.keepFavorites) {
parts.add('keep_favorites'.t(context: context));
}
if (state.excludedAlbumIds.isNotEmpty) {
parts.add(
'excluded_albums_count'.t(context: context, args: {'count': state.excludedAlbumIds.length.toString()}),
);
}
return parts.join('');
}
@@ -404,14 +397,6 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
},
),
const SizedBox(height: 16),
_ExcludedAlbumsSection(
excludedAlbumIds: state.excludedAlbumIds,
onAlbumToggled: (albumId) {
ref.read(cleanupProvider.notifier).toggleExcludedAlbum(albumId);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _goToScanStep,
icon: const Icon(Icons.arrow_forward),
@@ -716,112 +701,3 @@ class _DatePresetCard extends StatelessWidget {
);
}
}
class _ExcludedAlbumsSection extends ConsumerWidget {
final Set<String> excludedAlbumIds;
final ValueChanged<String> onAlbumToggled;
const _ExcludedAlbumsSection({required this.excludedAlbumIds, required this.onAlbumToggled});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumsAsync = ref.watch(localAlbumProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'exclude_albums'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
const SizedBox(height: 4),
Text(
'exclude_albums_description'.t(context: context),
style: context.textTheme.bodyMedium!.copyWith(color: context.textTheme.bodyMedium!.color!.withAlpha(215)),
),
const SizedBox(height: 12),
albumsAsync.when(
loading: () => const Center(
child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (error, stack) => Text(
'error_loading_albums'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
),
data: (albums) {
if (albums.isEmpty) {
return Text(
'no_albums_found'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
);
}
return Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.outlineVariant),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
constraints: const BoxConstraints(maxHeight: 200),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: ListView.builder(
shrinkWrap: true,
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
final isExcluded = excludedAlbumIds.contains(album.id);
return _AlbumExclusionTile(
album: album,
isExcluded: isExcluded,
onToggle: () => onAlbumToggled(album.id),
);
},
),
),
);
},
),
if (excludedAlbumIds.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'excluded_albums_count'.t(context: context, args: {'count': excludedAlbumIds.length.toString()}),
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error, fontWeight: FontWeight.w500),
),
],
],
);
}
}
class _AlbumExclusionTile extends StatelessWidget {
final LocalAlbum album;
final bool isExcluded;
final VoidCallback onToggle;
const _AlbumExclusionTile({required this.album, required this.isExcluded, required this.onToggle});
@override
Widget build(BuildContext context) {
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
leading: Icon(
isExcluded ? Icons.remove_circle : Icons.photo_album_outlined,
color: isExcluded ? context.colorScheme.error : context.colorScheme.onSurfaceVariant,
size: 20,
),
title: Text(
album.name,
style: context.textTheme.bodyMedium?.copyWith(color: isExcluded ? context.colorScheme.error : null),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
album.assetCount.toString(),
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
onTap: onToggle,
);
}
}

View File

@@ -434,222 +434,5 @@ void main() {
expect(candidates, isEmpty);
});
test('excludes assets in user-excluded albums', () async {
// Create two regular albums
await insertLocalAlbum(id: 'album-include', name: 'Include Album', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-exclude', name: 'Exclude Album', isIosSharedAlbum: false);
// Asset in included album - should be included
await insertLocalAsset(
id: 'local-in-included',
checksum: 'checksum-included',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-included', checksum: 'checksum-included', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-include', assetId: 'local-in-included');
// Asset in excluded album - should NOT be included
await insertLocalAsset(
id: 'local-in-excluded',
checksum: 'checksum-excluded',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-excluded', checksum: 'checksum-excluded', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-exclude', assetId: 'local-in-excluded');
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, excludedAlbumIds: {'album-exclude'});
expect(candidates.length, 1);
expect(candidates[0].id, 'local-in-included');
});
test('excludes assets that are in any of multiple excluded albums', () async {
// Create multiple albums
await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-2', name: 'Album 2', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-3', name: 'Album 3', isIosSharedAlbum: false);
// Asset in album-1 (excluded) - should NOT be included
await insertLocalAsset(
id: 'local-1',
checksum: 'checksum-1',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1');
// Asset in album-2 (excluded) - should NOT be included
await insertLocalAsset(
id: 'local-2',
checksum: 'checksum-2',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-2', assetId: 'local-2');
// Asset in album-3 (not excluded) - should be included
await insertLocalAsset(
id: 'local-3',
checksum: 'checksum-3',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3');
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
excludedAlbumIds: {'album-1', 'album-2'},
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-3');
});
test('excludes asset that is in both excluded and non-excluded album', () async {
await insertLocalAlbum(id: 'album-included', name: 'Included Album', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
// Asset in BOTH albums - should be excluded because it's in an excluded album
await insertLocalAsset(
id: 'local-both',
checksum: 'checksum-both',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-both', checksum: 'checksum-both', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-included', assetId: 'local-both');
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-both');
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
excludedAlbumIds: {'album-excluded'},
);
expect(candidates, isEmpty);
});
test('includes all assets when excludedAlbumIds is empty', () async {
await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false);
await insertLocalAsset(
id: 'local-1',
checksum: 'checksum-1',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1');
await insertLocalAsset(
id: 'local-2',
checksum: 'checksum-2',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId);
// Empty excludedAlbumIds should include all eligible assets
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, excludedAlbumIds: {});
expect(candidates.length, 2);
});
test('excludes asset not in any album when album is excluded', () async {
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
// Asset NOT in any album - should be included
await insertLocalAsset(
id: 'local-no-album',
checksum: 'checksum-no-album',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId);
// Asset in excluded album - should NOT be included
await insertLocalAsset(
id: 'local-in-excluded',
checksum: 'checksum-in-excluded',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-in-excluded', checksum: 'checksum-in-excluded', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-in-excluded');
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
excludedAlbumIds: {'album-excluded'},
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-no-album');
});
test('combines excludedAlbumIds with other filters correctly', () async {
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false);
// Photo in excluded album - should NOT be included
await insertLocalAsset(
id: 'local-photo-excluded',
checksum: 'checksum-photo-excluded',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo-excluded', checksum: 'checksum-photo-excluded', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-photo-excluded');
// Video in regular album - should NOT be included (filtering photos only)
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
createdAt: beforeCutoff,
type: AssetType.video,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-video');
// Photo in regular album - should be included
await insertLocalAsset(
id: 'local-photo-regular',
checksum: 'checksum-photo-regular',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo-regular', checksum: 'checksum-photo-regular', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-photo-regular');
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.photosOnly,
excludedAlbumIds: {'album-excluded'},
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-photo-regular');
});
});
}

18
pnpm-lock.yaml generated
View File

@@ -36,7 +36,7 @@ importers:
version: 1.20.1
lodash-es:
specifier: ^4.17.21
version: 4.17.23
version: 4.17.22
micromatch:
specifier: ^4.0.8
version: 4.0.8
@@ -802,7 +802,7 @@ importers:
version: 4.1.0
lodash-es:
specifier: ^4.17.21
version: 4.17.23
version: 4.17.22
luxon:
specifier: ^3.4.4
version: 3.7.2
@@ -8916,8 +8916,8 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash-es@4.17.23:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
lodash-es@4.17.22:
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
@@ -19332,7 +19332,7 @@ snapshots:
chevrotain-allstar@0.3.1(chevrotain@11.0.3):
dependencies:
chevrotain: 11.0.3
lodash-es: 4.17.23
lodash-es: 4.17.22
chevrotain@11.0.3:
dependencies:
@@ -20027,7 +20027,7 @@ snapshots:
dagre-d3-es@7.0.13:
dependencies:
d3: 7.9.0
lodash-es: 4.17.23
lodash-es: 4.17.22
data-urls@3.0.2:
dependencies:
@@ -22376,7 +22376,7 @@ snapshots:
lodash-es@4.17.21: {}
lodash-es@4.17.23: {}
lodash-es@4.17.22: {}
lodash.camelcase@4.3.0: {}
@@ -22810,7 +22810,7 @@ snapshots:
dompurify: 3.3.1
katex: 0.16.27
khroma: 2.1.0
lodash-es: 4.17.23
lodash-es: 4.17.22
marked: 16.4.2
roughjs: 4.6.6
stylis: 4.3.6
@@ -25714,7 +25714,7 @@ snapshots:
json-source-map: 0.6.1
jsonpath-plus: 10.3.0
jsonrepair: 3.13.1
lodash-es: 4.17.23
lodash-es: 4.17.22
memoize-one: 6.0.0
natural-compare-lite: 1.4.0
sass: 1.97.1

View File

@@ -15,5 +15,3 @@ from
"asset_edit"
where
"assetId" = $1
order by
"sequence" asc

View File

@@ -12,14 +12,14 @@ export class AssetEditRepository {
@GenerateSql({
params: [DummyValue.UUID],
})
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
return this.db.transaction().execute(async (trx) => {
async replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
return await this.db.transaction().execute(async (trx) => {
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
if (edits.length > 0) {
return trx
.insertInto('asset_edit')
.values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit })))
.values(edits.map((edit) => ({ assetId, ...edit })))
.returning(['action', 'parameters'])
.execute() as Promise<AssetEditActionItem[]>;
}
@@ -31,12 +31,11 @@ export class AssetEditRepository {
@GenerateSql({
params: [DummyValue.UUID],
})
getAll(assetId: string): Promise<AssetEditActionItem[]> {
async getAll(assetId: string): Promise<AssetEditActionItem[]> {
return this.db
.selectFrom('asset_edit')
.select(['action', 'parameters'])
.where('assetId', '=', assetId)
.orderBy('sequence', 'asc')
.execute() as Promise<AssetEditActionItem[]>;
}
}

View File

@@ -1,14 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`DELETE FROM "asset_edit";`.execute(db);
await sql`ALTER TABLE "asset_edit" ADD "sequence" integer NOT NULL;`.execute(db);
await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_assetId_sequence_uq" UNIQUE ("assetId", "sequence");`.execute(
db,
);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_edit" DROP CONSTRAINT "asset_edit_assetId_sequence_uq";`.execute(db);
await sql`ALTER TABLE "asset_edit" DROP COLUMN "sequence";`.execute(db);
}

View File

@@ -9,7 +9,6 @@ import {
Generated,
PrimaryGeneratedColumn,
Table,
Unique,
} from 'src/sql-tools';
@Table('asset_edit')
@@ -20,7 +19,6 @@ import {
referencingOldTableAs: 'deleted_edit',
when: 'pg_trigger_depth() = 0',
})
@Unique({ columns: ['assetId', 'sequence'] })
export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@@ -33,7 +31,4 @@ export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
@Column({ type: 'jsonb' })
parameters!: AssetEditActionParameter[T];
@Column({ type: 'integer' })
sequence!: number;
}

View File

@@ -431,7 +431,6 @@
const showOcrButton = $derived(
$slideshowState === SlideshowState.None &&
asset.type === AssetTypeEnum.Image &&
!(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') &&
!isShowEditor &&
ocrManager.hasOcrData,
);