fix: address all 6 P2 review issues from PR #8 round 4
- BackgroundTasks: per-task Retry now dispatches to the correct backend — retryFailedEmbeddings for embedding failures, queueTaggingJobs for tagging failures, or both; previously always called embedding retry only - commands: tag-cloud cache key now hashes both image IDs and embedding bytes (xxh3) so re-embedding a file after reindex correctly invalidates the cache; removed now-dead fnv_hash_ids helper - db: update_folder_path uses SUBSTR instead of replace() to rewrite only the leading path prefix, preventing corruption when the old folder name also appears in a subdirectory name - db: add_user_tag promotes an existing AI-owned tag to user-owned on conflict instead of DO NOTHING, preventing the tagger from deleting a manually added tag that coincides with an AI tag - db/commands: add clear_duplicate_scan_cache / invalidate_duplicate_scan_cache so deletion removes the stale persisted cache entry; without this a restart would reload groups containing deleted images - store: setSimilarScope re-runs loadSimilarByRegion (with saved crop) when the active collection is Region Search Results, instead of silently switching to whole-image similarity and discarding the crop
This commit is contained in:
+29
-14
@@ -753,15 +753,6 @@ pub struct TagCloudEntry {
|
|||||||
pub image_ids: Vec<i64>,
|
pub image_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
/// 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.
|
/// 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![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute a hash of the current embedded image IDs (sorted for stability)
|
// Sort by ID for stable ordering; hash both IDs and embedding bytes so that
|
||||||
let mut sorted_ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect();
|
// replacing a file and regenerating its embedding invalidates the cache even
|
||||||
sorted_ids.sort_unstable();
|
// when the set of image IDs hasn't changed.
|
||||||
let current_hash = fnv_hash_ids(&sorted_ids);
|
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 {
|
let folder_scope = match folder_id {
|
||||||
Some(id) => format!("folder_{}", id),
|
Some(id) => format!("folder_{}", id),
|
||||||
None => "all".to_string(),
|
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<i64>,
|
||||||
|
) -> 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]
|
#[tauri::command]
|
||||||
pub async fn delete_images_from_disk(
|
pub async fn delete_images_from_disk(
|
||||||
db: State<'_, DbState>,
|
db: State<'_, DbState>,
|
||||||
|
|||||||
+17
-3
@@ -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
|
// Rewrite image paths so the indexer can match them by path and skip
|
||||||
// re-generating thumbnails and embeddings for unchanged files.
|
// 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(
|
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],
|
params![old_path, new_path, folder_id],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1887,7 +1888,11 @@ pub fn add_user_tag(conn: &Connection, image_id: i64, tag: &str) -> Result<Image
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
|
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
|
||||||
VALUES (?1, ?2, 'user', NULL, NULL, datetime('now'))
|
VALUES (?1, ?2, 'user', NULL, NULL, datetime('now'))
|
||||||
ON CONFLICT(image_id, tag) DO NOTHING",
|
ON CONFLICT(image_id, tag) DO UPDATE SET
|
||||||
|
source = 'user',
|
||||||
|
ai_model = NULL,
|
||||||
|
confidence = NULL
|
||||||
|
WHERE source = 'ai'",
|
||||||
params![image_id, tag],
|
params![image_id, tag],
|
||||||
)?;
|
)?;
|
||||||
let row = conn.query_row(
|
let row = conn.query_row(
|
||||||
@@ -2130,6 +2135,15 @@ pub fn get_duplicate_scan_cache(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes the duplicate scan cache for the given scope.
|
||||||
|
pub fn clear_duplicate_scan_cache(conn: &Connection, folder_scope: &str) -> 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.
|
/// Upserts the duplicate scan cache for the given scope.
|
||||||
pub fn set_duplicate_scan_cache(
|
pub fn set_duplicate_scan_cache(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ pub fn run() {
|
|||||||
commands::search_tags_autocomplete,
|
commands::search_tags_autocomplete,
|
||||||
commands::find_duplicates,
|
commands::find_duplicates,
|
||||||
commands::load_duplicate_scan_cache,
|
commands::load_duplicate_scan_cache,
|
||||||
|
commands::invalidate_duplicate_scan_cache,
|
||||||
commands::delete_images_from_disk,
|
commands::delete_images_from_disk,
|
||||||
commands::rename_folder,
|
commands::rename_folder,
|
||||||
commands::update_folder_path,
|
commands::update_folder_path,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function BackgroundTasks() {
|
|||||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||||
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
||||||
|
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
|
||||||
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
|
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
|
||||||
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -475,7 +476,10 @@ export function BackgroundTasks() {
|
|||||||
{taskHasFailed && (
|
{taskHasFailed && (
|
||||||
<button
|
<button
|
||||||
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
|
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
|
||||||
onClick={() => void retryFailedEmbeddings(task.id)}
|
onClick={() => {
|
||||||
|
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
|
||||||
|
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+8
-2
@@ -1126,10 +1126,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
|
|
||||||
setSimilarScope: (similarScope) => {
|
setSimilarScope: (similarScope) => {
|
||||||
set({ similarScope });
|
set({ similarScope });
|
||||||
const { similarSourceImageId, similarSourceFolderId, selectedFolderId } = get();
|
const { similarSourceImageId, similarSourceFolderId, selectedFolderId, collectionTitle, similarCrop } = get();
|
||||||
if (similarSourceImageId === null) return;
|
if (similarSourceImageId === null) return;
|
||||||
const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null;
|
const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null;
|
||||||
|
if (collectionTitle === "Region Search Results" && similarCrop !== null) {
|
||||||
|
void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId);
|
||||||
|
} else {
|
||||||
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId);
|
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
suggestImageTags: async (imageId) => {
|
suggestImageTags: async (imageId) => {
|
||||||
@@ -1498,7 +1502,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }),
|
clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }),
|
||||||
|
|
||||||
deleteSelectedDuplicates: async () => {
|
deleteSelectedDuplicates: async () => {
|
||||||
const { duplicateSelectedIds } = get();
|
const { duplicateSelectedIds, duplicateScanFolderId } = get();
|
||||||
const ids = Array.from(duplicateSelectedIds);
|
const ids = Array.from(duplicateSelectedIds);
|
||||||
if (ids.length === 0) return 0;
|
if (ids.length === 0) return 0;
|
||||||
const deleted = await invoke<number>("delete_images_from_disk", { params: { image_ids: ids } });
|
const deleted = await invoke<number>("delete_images_from_disk", { params: { image_ids: ids } });
|
||||||
@@ -1509,6 +1513,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
.map((g) => ({ ...g, images: g.images.filter((img) => !duplicateSelectedIds.has(img.id)) }))
|
.map((g) => ({ ...g, images: g.images.filter((img) => !duplicateSelectedIds.has(img.id)) }))
|
||||||
.filter((g) => g.images.length > 1),
|
.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;
|
return deleted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user