feat: expand media discovery and AI workflows #8

Merged
LyAhn merged 25 commits from codex/5-discovery-features into main 2026-06-07 22:43:16 +00:00
3 changed files with 30 additions and 13 deletions
Showing only changes of commit 1f6650021c - Show all commits
+4 -4
View File
@@ -1153,9 +1153,9 @@ pub async fn invalidate_duplicate_scan_cache(
pub async fn delete_images_from_disk(
db: State<'_, DbState>,
params: DeleteImagesFromDiskParams,
) -> Result<usize, String> {
) -> Result<Vec<i64>, String> {
if params.image_ids.is_empty() {
return Ok(0);
return Ok(Vec::new());
}
let conn = db.get().map_err(|e| e.to_string())?;
let records = db::get_all_image_paths(&conn, None).map_err(|e| e.to_string())?;
@@ -1170,11 +1170,11 @@ pub async fn delete_images_from_disk(
succeeded_ids.push(r.id);
}
}
let deleted = succeeded_ids.len();
if !succeeded_ids.is_empty() {
db::delete_images_by_ids(&conn, &succeeded_ids).map_err(|e| e.to_string())?;
}
Ok(deleted)
// Return the IDs that were actually removed so the caller can update state precisely.
Ok(succeeded_ids)
}
#[tauri::command]
+6 -2
View File
@@ -175,9 +175,13 @@ export function Lightbox() {
useEffect(() => {
if (!selectedImage) return;
// Capture the ID so a stale response for image A cannot overwrite B's tags
// when the user navigates before the request resolves.
let cancelled = false;
void getImageTags(selectedImage.id)
.then(setImageTags)
.catch(() => setImageTags([]));
.then((tags) => { if (!cancelled) setImageTags(tags); })
.catch(() => { if (!cancelled) setImageTags([]); });
return () => { cancelled = true; };
}, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]);
// Reset the queued state once the worker finishes so the button is usable again
+20 -7
View File
@@ -1513,20 +1513,33 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }),
deleteSelectedDuplicates: async () => {
const { duplicateSelectedIds, duplicateScanFolderId } = get();
const { duplicateSelectedIds, duplicateGroups } = get();
const ids = Array.from(duplicateSelectedIds);
if (ids.length === 0) return 0;
const deleted = await invoke<number>("delete_images_from_disk", { params: { image_ids: ids } });
// Remove deleted images from groups and drop now-trivial groups
// Backend returns only the IDs that were actually removed from disk.
const succeededIds = await invoke<number[]>("delete_images_from_disk", { params: { image_ids: ids } });
const succeededSet = new Set(succeededIds);
// Only remove images confirmed deleted — failed files remain visible so the user can retry.
set((state) => ({
duplicateSelectedIds: new Set(),
duplicateGroups: state.duplicateGroups
.map((g) => ({ ...g, images: g.images.filter((img) => !duplicateSelectedIds.has(img.id)) }))
.map((g) => ({ ...g, images: g.images.filter((img) => !succeededSet.has(img.id)) }))
.filter((g) => g.images.length > 1),
}));
// Invalidate the persisted cache so a restart doesn't reload stale entries
await invoke("invalidate_duplicate_scan_cache", { folderId: duplicateScanFolderId ?? null });
return deleted;
// Invalidate the persisted cache for every affected scope:
// - global "all" cache (always, since a folder-scoped deletion still makes the global result stale)
// - each folder that contained a deleted image (so a folder-scoped scan is also evicted)
const affectedFolderIds = new Set<number>(
duplicateGroups
.flatMap((g) => g.images)
.filter((img) => succeededSet.has(img.id))
.map((img) => img.folder_id),
);
await invoke("invalidate_duplicate_scan_cache", { folderId: null }); // global
for (const folderId of affectedFolderIds) {
await invoke("invalidate_duplicate_scan_cache", { folderId });
}
return succeededIds.length;
},
retryFailedEmbeddings: async (folderId) => {