Compare commits

...

4 Commits

Author SHA1 Message Date
Alex
a644cc0a3c feat: album exclusion filter in free up space 2026-01-22 14:56:28 -06:00
Min Idzelis
dd72c32c60 feat: rename parallel tests to ui, split test step into: [e2e, ui] (#25439) 2026-01-22 13:44:00 -05:00
Brandon Wees
4bd01b70ff fix: asset edit sequence (#25457) 2026-01-22 12:41:01 -06:00
renovate[bot]
945f7fb9ea chore(deps): update dependency lodash-es to v4.17.23 [security] (#25453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 13:38:13 -05:00
15 changed files with 423 additions and 21 deletions

View File

@@ -502,7 +502,12 @@ jobs:
- name: Run e2e tests (web)
env:
CI: true
run: npx playwright test
run: npx playwright test --project=chromium
if: ${{ !cancelled() }}
- name: Run ui tests (web)
env:
CI: true
run: npx playwright test --project=ui
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: '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: '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: 'firefox',

View File

@@ -1007,6 +1007,7 @@
"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",
@@ -1138,6 +1139,9 @@
"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...",
@@ -1553,6 +1557,7 @@
"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

@@ -135,6 +135,7 @@ 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])
@@ -159,6 +160,13 @@ 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

@@ -11,6 +11,7 @@ class CleanupState {
final bool isDeleting;
final AssetFilterType filterType;
final bool keepFavorites;
final Set<String> excludedAlbumIds;
const CleanupState({
this.selectedDate,
@@ -19,6 +20,7 @@ class CleanupState {
this.isDeleting = false,
this.filterType = AssetFilterType.all,
this.keepFavorites = true,
this.excludedAlbumIds = const {},
});
CleanupState copyWith({
@@ -28,6 +30,7 @@ class CleanupState {
bool? isDeleting,
AssetFilterType? filterType,
bool? keepFavorites,
Set<String>? excludedAlbumIds,
}) {
return CleanupState(
selectedDate: selectedDate ?? this.selectedDate,
@@ -36,6 +39,7 @@ class CleanupState {
isDeleting: isDeleting ?? this.isDeleting,
filterType: filterType ?? this.filterType,
keepFavorites: keepFavorites ?? this.keepFavorites,
excludedAlbumIds: excludedAlbumIds ?? this.excludedAlbumIds,
);
}
}
@@ -62,6 +66,20 @@ 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;
@@ -74,6 +92,7 @@ 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

@@ -20,12 +20,14 @@ 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,12 +3,14 @@ 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 {
@@ -195,6 +197,11 @@ 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('');
}
@@ -397,6 +404,14 @@ 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),
@@ -701,3 +716,112 @@ 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,5 +434,222 @@ 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.22
version: 4.17.23
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.22
version: 4.17.23
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.22:
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
lodash-es@4.17.23:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
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.22
lodash-es: 4.17.23
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.22
lodash-es: 4.17.23
data-urls@3.0.2:
dependencies:
@@ -22376,7 +22376,7 @@ snapshots:
lodash-es@4.17.21: {}
lodash-es@4.17.22: {}
lodash-es@4.17.23: {}
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.22
lodash-es: 4.17.23
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.22
lodash-es: 4.17.23
memoize-one: 6.0.0
natural-compare-lite: 1.4.0
sass: 1.97.1

View File

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

View File

@@ -12,14 +12,14 @@ export class AssetEditRepository {
@GenerateSql({
params: [DummyValue.UUID],
})
async replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
return await this.db.transaction().execute(async (trx) => {
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
return 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) => ({ assetId, ...edit })))
.values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit })))
.returning(['action', 'parameters'])
.execute() as Promise<AssetEditActionItem[]>;
}
@@ -31,11 +31,12 @@ export class AssetEditRepository {
@GenerateSql({
params: [DummyValue.UUID],
})
async getAll(assetId: string): Promise<AssetEditActionItem[]> {
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

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