From 7efb1879710fe7340a5d8bb8354ac865b5e7afde Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 7 Jun 2026 22:15:48 +0100 Subject: [PATCH] fix: address 4 P2 review issues from PR #8 round 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db/commands: paginate tag search — add offset + COUNT query to search_images_by_tag returning (images, total); TagSearchParams gains offset field and command returns TagSearchPage; store handles both the initial load and load-more path so results beyond PAGE_SIZE are reachable commands: semantic search over-fetches by 4× when any post-query filter (folder, media kind, favorites, rating) is active, then truncates to the requested limit; previously well-rated matches ranked just beyond the bare vector limit were silently omitted db: wrap update_folder_path in a transaction so a path-rewrite failure (e.g. uniqueness collision) rolls back the already-updated folder row instead of leaving the DB in an inconsistent half-migrated state store: invalidate exploreTagsFolderId when a tagging run completes so Explore reflects the updated tag distribution without requiring a manual folder switch; previously the cache was never busted by background tagging --- src-tauri/src/commands.rs | 34 ++++++++++++++++++++++++++++----- src-tauri/src/db.rs | 34 ++++++++++++++++++++++++++------- src/store.ts | 40 ++++++++++++++++++++++++++------------- 3 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0d92a10..647d5d1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -151,6 +151,15 @@ pub struct TagSearchParams { pub favorites_only: Option, pub rating_min: Option, pub limit: Option, + pub offset: Option, +} + +#[derive(Serialize)] +pub struct TagSearchPage { + pub images: Vec, + pub total: usize, + pub offset: usize, + pub limit: usize, } #[derive(Deserialize)] @@ -522,7 +531,17 @@ pub async fn semantic_search_images( let conn = db.get().map_err(|e| e.to_string())?; let limit = params.limit.unwrap_or(64); - let ids = vector::search_image_ids_by_embedding(&conn, &embedding, limit) + + // When post-query filters are active the vector search may discard many + // candidates ranked just beyond the bare limit. Over-fetch by 4× so that + // well-rated or folder-scoped matches are not silently omitted. + let has_filters = params.folder_id.is_some() + || params.media_kind.is_some() + || params.favorites_only.unwrap_or(false) + || params.rating_min.map_or(false, |r| r > 0); + let fetch_limit = if has_filters { limit * 4 } else { limit }; + + let ids = vector::search_image_ids_by_embedding(&conn, &embedding, fetch_limit) .map_err(|e| e.to_string())?; let mut images = db::get_images_by_ids(&conn, &ids).map_err(|e| e.to_string())?; @@ -538,6 +557,7 @@ pub async fn semantic_search_images( if let Some(rating_min) = params.rating_min { images.retain(|image| image.rating >= rating_min); } + images.truncate(limit); Ok(images) } @@ -546,18 +566,22 @@ pub async fn semantic_search_images( pub async fn search_images_by_tag( db: State<'_, DbState>, params: TagSearchParams, -) -> Result, String> { +) -> Result { let conn = db.get().map_err(|e| e.to_string())?; - db::search_images_by_tag( + let limit = params.limit.unwrap_or(64); + let offset = params.offset.unwrap_or(0); + let (images, total) = db::search_images_by_tag( &conn, ¶ms.query, params.folder_id, params.media_kind.as_deref(), params.favorites_only.unwrap_or(false), params.rating_min.unwrap_or(0), - params.limit.unwrap_or(64), + limit, + offset, ) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + Ok(TagSearchPage { images, total, offset, limit }) } #[tauri::command] diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 980f871..83d9ac2 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1385,7 +1385,10 @@ pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Resul } pub fn update_folder_path(conn: &Connection, folder_id: i64, old_path: &str, new_path: &str, new_name: &str) -> Result<()> { - conn.execute( + // Both updates must be atomic: if the image path rewrite fails (e.g. a + // uniqueness collision) the folder row must not remain at the new location. + let tx = conn.unchecked_transaction()?; + tx.execute( "UPDATE folders SET path = ?2, name = ?3, scan_error = NULL WHERE id = ?1", params![folder_id, new_path, new_name], )?; @@ -1393,10 +1396,11 @@ pub fn update_folder_path(conn: &Connection, folder_id: i64, old_path: &str, new // re-generating thumbnails and embeddings for unchanged files. // 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( + tx.execute( "UPDATE images SET path = ?2 || SUBSTR(path, LENGTH(?1) + 1) WHERE folder_id = ?3", params![old_path, new_path, folder_id], )?; + tx.commit()?; Ok(()) } @@ -1522,13 +1526,28 @@ pub fn search_images_by_tag( favorites_only: bool, rating_min: i64, limit: usize, -) -> Result> { + offset: usize, +) -> Result<(Vec, usize)> { let normalized_query = query.trim().to_ascii_lowercase(); if normalized_query.is_empty() { - return Ok(Vec::new()); + return Ok((Vec::new(), 0)); } let favorites_flag = i64::from(favorites_only); + // Total count (for pagination) + let total: usize = conn.query_row( + "SELECT COUNT(DISTINCT i.id) + FROM images i + JOIN image_tags t ON t.image_id = i.id + WHERE (?1 IS NULL OR i.folder_id = ?1) + AND (?2 IS NULL OR i.media_kind = ?2) + AND (?3 = 0 OR i.favorite = 1) + AND i.rating >= ?4 + AND LOWER(TRIM(t.tag)) = ?5", + params![folder_id, media_kind, favorites_flag, rating_min, normalized_query], + |row| row.get::<_, i64>(0), + )? as usize; + let mut stmt = conn.prepare( "SELECT DISTINCT i.id FROM images i @@ -1539,7 +1558,7 @@ pub fn search_images_by_tag( AND i.rating >= ?4 AND LOWER(TRIM(t.tag)) = ?5 ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC - LIMIT ?6", + LIMIT ?6 OFFSET ?7", )?; let image_ids = stmt @@ -1550,13 +1569,14 @@ pub fn search_images_by_tag( favorites_flag, rating_min, normalized_query, - limit as i64 + limit as i64, + offset as i64 ], |row| row.get::<_, i64>(0), )? .collect::>>()?; - get_images_by_ids(conn, &image_ids) + Ok((get_images_by_ids(conn, &image_ids)?, total)) } pub fn search_tags_autocomplete( diff --git a/src/store.ts b/src/store.ts index e5755e3..3d0bd28 100644 --- a/src/store.ts +++ b/src/store.ts @@ -725,7 +725,8 @@ export const useGalleryStore = create((set, get) => ({ } if (parsedSearch.mode === "tag" && parsedSearch.query) { - const images = await invoke("search_images_by_tag", { + const offset = reset ? 0 : loadedCount; + const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("search_images_by_tag", { params: { query: parsedSearch.query, folder_id: selectedFolderId, @@ -733,22 +734,32 @@ export const useGalleryStore = create((set, get) => ({ favorites_only: favoritesOnly, rating_min: minimumRating > 0 ? minimumRating : null, limit: PAGE_SIZE, + offset, }, }); if (requestToken !== galleryRequestToken) return; - set({ - images, - totalImages: images.length, - loadedCount: images.length, - loadingImages: false, - collectionTitle: `Tag search: ${parsedSearch.query}`, - selectedFolderId, - similarSourceImageId: null, - similarSourceFolderId: null, - similarHasMore: false, - similarFolderId: null, - }); + if (reset) { + set({ + images: result.images, + totalImages: result.total, + loadedCount: result.images.length, + loadingImages: false, + collectionTitle: `Tag search: ${parsedSearch.query}`, + selectedFolderId, + similarSourceImageId: null, + similarSourceFolderId: null, + similarHasMore: false, + similarFolderId: null, + }); + } else { + set((state) => ({ + images: [...state.images, ...result.images], + loadedCount: state.loadedCount + result.images.length, + totalImages: result.total, + loadingImages: false, + })); + } return; } @@ -1608,6 +1619,9 @@ export const useGalleryStore = create((set, get) => ({ "AI tagging complete", `${folderName} finished generating tags.${failureDetail}`, ); + // New tags are now in the DB — invalidate the Explore tag cache so + // reopening Explore reflects the updated tag distribution. + set({ exploreTagsFolderId: undefined }); } }