fix: address 4 P2 review issues from PR #8 round 6
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
This commit is contained in:
@@ -151,6 +151,15 @@ pub struct TagSearchParams {
|
||||
pub favorites_only: Option<bool>,
|
||||
pub rating_min: Option<i64>,
|
||||
pub limit: Option<usize>,
|
||||
pub offset: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TagSearchPage {
|
||||
pub images: Vec<ImageRecord>,
|
||||
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<Vec<ImageRecord>, String> {
|
||||
) -> Result<TagSearchPage, String> {
|
||||
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]
|
||||
|
||||
+27
-7
@@ -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<Vec<ImageRecord>> {
|
||||
offset: usize,
|
||||
) -> Result<(Vec<ImageRecord>, 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::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
get_images_by_ids(conn, &image_ids)
|
||||
Ok((get_images_by_ids(conn, &image_ids)?, total))
|
||||
}
|
||||
|
||||
pub fn search_tags_autocomplete(
|
||||
|
||||
+18
-4
@@ -725,7 +725,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
}
|
||||
|
||||
if (parsedSearch.mode === "tag" && parsedSearch.query) {
|
||||
const images = await invoke<ImageRecord[]>("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,14 +734,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
favorites_only: favoritesOnly,
|
||||
rating_min: minimumRating > 0 ? minimumRating : null,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
},
|
||||
});
|
||||
|
||||
if (requestToken !== galleryRequestToken) return;
|
||||
if (reset) {
|
||||
set({
|
||||
images,
|
||||
totalImages: images.length,
|
||||
loadedCount: images.length,
|
||||
images: result.images,
|
||||
totalImages: result.total,
|
||||
loadedCount: result.images.length,
|
||||
loadingImages: false,
|
||||
collectionTitle: `Tag search: ${parsedSearch.query}`,
|
||||
selectedFolderId,
|
||||
@@ -749,6 +752,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
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<GalleryState>((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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user