diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 53982b3..0d92a10 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -753,15 +753,6 @@ pub struct TagCloudEntry { pub image_ids: Vec, } -fn fnv_hash_ids(ids: &[i64]) -> u64 { - let mut h: u64 = 0xcbf29ce484222325; - for &id in ids { - for b in id.to_le_bytes() { - h = h.wrapping_mul(0x100000001b3) ^ (b as u64); - } - } - h -} /// Clusters the library's image embeddings with k-means and returns one representative /// image per cluster — the member whose embedding is closest to its cluster centroid. @@ -782,11 +773,22 @@ pub async fn get_tag_cloud( return Ok(vec![]); } - // Compute a hash of the current embedded image IDs (sorted for stability) - let mut sorted_ids: Vec = embeddings_with_ids.iter().map(|(id, _)| *id).collect(); - sorted_ids.sort_unstable(); - let current_hash = fnv_hash_ids(&sorted_ids); - + // Sort by ID for stable ordering; hash both IDs and embedding bytes so that + // replacing a file and regenerating its embedding invalidates the cache even + // when the set of image IDs hasn't changed. + let mut sorted_pairs: Vec<_> = embeddings_with_ids.iter().collect(); + sorted_pairs.sort_unstable_by_key(|(id, _)| *id); + let current_hash = { + use xxhash_rust::xxh3::Xxh3; + let mut hasher = Xxh3::new(); + for (id, embedding) in &sorted_pairs { + hasher.update(&id.to_le_bytes()); + for val in embedding.iter() { + hasher.update(&val.to_le_bytes()); + } + } + hasher.digest() + }; let folder_scope = match folder_id { Some(id) => format!("folder_{}", id), None => "all".to_string(), @@ -1097,6 +1099,19 @@ pub async fn load_duplicate_scan_cache( } } +#[tauri::command] +pub async fn invalidate_duplicate_scan_cache( + db: State<'_, DbState>, + folder_id: Option, +) -> Result<(), String> { + let folder_scope = match folder_id { + Some(id) => format!("folder:{}", id), + None => "all".to_string(), + }; + let conn = db.get().map_err(|e| e.to_string())?; + db::clear_duplicate_scan_cache(&conn, &folder_scope).map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn delete_images_from_disk( db: State<'_, DbState>, diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index e102004..e36c3bb 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1371,9 +1371,10 @@ pub fn update_folder_path(conn: &Connection, folder_id: i64, old_path: &str, new )?; // Rewrite image paths so the indexer can match them by path and skip // re-generating thumbnails and embeddings for unchanged files. - // SQLite's replace() does a literal prefix substitution on each path. + // Use SUBSTR to replace only the leading prefix; SQLite's replace() + // would corrupt paths where the old folder name also appears deeper in the tree. conn.execute( - "UPDATE images SET path = replace(path, ?1, ?2) WHERE folder_id = ?3", + "UPDATE images SET path = ?2 || SUBSTR(path, LENGTH(?1) + 1) WHERE folder_id = ?3", params![old_path, new_path, folder_id], )?; Ok(()) @@ -1887,7 +1888,11 @@ pub fn add_user_tag(conn: &Connection, image_id: i64, tag: &str) -> Result Result<()> { + conn.execute( + "DELETE FROM duplicate_scan_cache WHERE folder_scope = ?1", + params![folder_scope], + )?; + Ok(()) +} + /// Upserts the duplicate scan cache for the given scope. pub fn set_duplicate_scan_cache( conn: &Connection, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 165219f..009851f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -132,6 +132,7 @@ pub fn run() { commands::search_tags_autocomplete, commands::find_duplicates, commands::load_duplicate_scan_cache, + commands::invalidate_duplicate_scan_cache, commands::delete_images_from_disk, commands::rename_folder, commands::update_folder_path, diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index 62ead7b..46d1861 100644 --- a/src/components/BackgroundTasks.tsx +++ b/src/components/BackgroundTasks.tsx @@ -57,6 +57,7 @@ export function BackgroundTasks() { const indexingProgress = useGalleryStore((state) => state.indexingProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); + const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const [expanded, setExpanded] = useState(false); @@ -475,7 +476,10 @@ export function BackgroundTasks() { {taskHasFailed && ( diff --git a/src/store.ts b/src/store.ts index d0c404f..e5755e3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1126,10 +1126,14 @@ export const useGalleryStore = create((set, get) => ({ setSimilarScope: (similarScope) => { set({ similarScope }); - const { similarSourceImageId, similarSourceFolderId, selectedFolderId } = get(); + const { similarSourceImageId, similarSourceFolderId, selectedFolderId, collectionTitle, similarCrop } = get(); if (similarSourceImageId === null) return; const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null; - void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId); + if (collectionTitle === "Region Search Results" && similarCrop !== null) { + void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId); + } else { + void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId); + } }, suggestImageTags: async (imageId) => { @@ -1498,7 +1502,7 @@ export const useGalleryStore = create((set, get) => ({ clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }), deleteSelectedDuplicates: async () => { - const { duplicateSelectedIds } = get(); + const { duplicateSelectedIds, duplicateScanFolderId } = get(); const ids = Array.from(duplicateSelectedIds); if (ids.length === 0) return 0; const deleted = await invoke("delete_images_from_disk", { params: { image_ids: ids } }); @@ -1509,6 +1513,8 @@ export const useGalleryStore = create((set, get) => ({ .map((g) => ({ ...g, images: g.images.filter((img) => !duplicateSelectedIds.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; },