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 favorites_only: Option<bool>,
|
||||||
pub rating_min: Option<i64>,
|
pub rating_min: Option<i64>,
|
||||||
pub limit: Option<usize>,
|
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)]
|
#[derive(Deserialize)]
|
||||||
@@ -522,7 +531,17 @@ pub async fn semantic_search_images(
|
|||||||
|
|
||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
let limit = params.limit.unwrap_or(64);
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let mut images = db::get_images_by_ids(&conn, &ids).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 {
|
if let Some(rating_min) = params.rating_min {
|
||||||
images.retain(|image| image.rating >= rating_min);
|
images.retain(|image| image.rating >= rating_min);
|
||||||
}
|
}
|
||||||
|
images.truncate(limit);
|
||||||
|
|
||||||
Ok(images)
|
Ok(images)
|
||||||
}
|
}
|
||||||
@@ -546,18 +566,22 @@ pub async fn semantic_search_images(
|
|||||||
pub async fn search_images_by_tag(
|
pub async fn search_images_by_tag(
|
||||||
db: State<'_, DbState>,
|
db: State<'_, DbState>,
|
||||||
params: TagSearchParams,
|
params: TagSearchParams,
|
||||||
) -> Result<Vec<ImageRecord>, String> {
|
) -> Result<TagSearchPage, String> {
|
||||||
let conn = db.get().map_err(|e| e.to_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,
|
&conn,
|
||||||
¶ms.query,
|
¶ms.query,
|
||||||
params.folder_id,
|
params.folder_id,
|
||||||
params.media_kind.as_deref(),
|
params.media_kind.as_deref(),
|
||||||
params.favorites_only.unwrap_or(false),
|
params.favorites_only.unwrap_or(false),
|
||||||
params.rating_min.unwrap_or(0),
|
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]
|
#[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<()> {
|
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",
|
"UPDATE folders SET path = ?2, name = ?3, scan_error = NULL WHERE id = ?1",
|
||||||
params![folder_id, new_path, new_name],
|
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.
|
// re-generating thumbnails and embeddings for unchanged files.
|
||||||
// Use SUBSTR to replace only the leading prefix; SQLite's replace()
|
// 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.
|
// 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",
|
"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],
|
||||||
)?;
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1522,13 +1526,28 @@ pub fn search_images_by_tag(
|
|||||||
favorites_only: bool,
|
favorites_only: bool,
|
||||||
rating_min: i64,
|
rating_min: i64,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<ImageRecord>> {
|
offset: usize,
|
||||||
|
) -> Result<(Vec<ImageRecord>, usize)> {
|
||||||
let normalized_query = query.trim().to_ascii_lowercase();
|
let normalized_query = query.trim().to_ascii_lowercase();
|
||||||
if normalized_query.is_empty() {
|
if normalized_query.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok((Vec::new(), 0));
|
||||||
}
|
}
|
||||||
let favorites_flag = i64::from(favorites_only);
|
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(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT DISTINCT i.id
|
"SELECT DISTINCT i.id
|
||||||
FROM images i
|
FROM images i
|
||||||
@@ -1539,7 +1558,7 @@ pub fn search_images_by_tag(
|
|||||||
AND i.rating >= ?4
|
AND i.rating >= ?4
|
||||||
AND LOWER(TRIM(t.tag)) = ?5
|
AND LOWER(TRIM(t.tag)) = ?5
|
||||||
ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC
|
ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC
|
||||||
LIMIT ?6",
|
LIMIT ?6 OFFSET ?7",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let image_ids = stmt
|
let image_ids = stmt
|
||||||
@@ -1550,13 +1569,14 @@ pub fn search_images_by_tag(
|
|||||||
favorites_flag,
|
favorites_flag,
|
||||||
rating_min,
|
rating_min,
|
||||||
normalized_query,
|
normalized_query,
|
||||||
limit as i64
|
limit as i64,
|
||||||
|
offset as i64
|
||||||
],
|
],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)?
|
)?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.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(
|
pub fn search_tags_autocomplete(
|
||||||
|
|||||||
+18
-4
@@ -725,7 +725,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsedSearch.mode === "tag" && parsedSearch.query) {
|
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: {
|
params: {
|
||||||
query: parsedSearch.query,
|
query: parsedSearch.query,
|
||||||
folder_id: selectedFolderId,
|
folder_id: selectedFolderId,
|
||||||
@@ -733,14 +734,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
favorites_only: favoritesOnly,
|
favorites_only: favoritesOnly,
|
||||||
rating_min: minimumRating > 0 ? minimumRating : null,
|
rating_min: minimumRating > 0 ? minimumRating : null,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
|
offset,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (requestToken !== galleryRequestToken) return;
|
if (requestToken !== galleryRequestToken) return;
|
||||||
|
if (reset) {
|
||||||
set({
|
set({
|
||||||
images,
|
images: result.images,
|
||||||
totalImages: images.length,
|
totalImages: result.total,
|
||||||
loadedCount: images.length,
|
loadedCount: result.images.length,
|
||||||
loadingImages: false,
|
loadingImages: false,
|
||||||
collectionTitle: `Tag search: ${parsedSearch.query}`,
|
collectionTitle: `Tag search: ${parsedSearch.query}`,
|
||||||
selectedFolderId,
|
selectedFolderId,
|
||||||
@@ -749,6 +752,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
similarHasMore: false,
|
similarHasMore: false,
|
||||||
similarFolderId: null,
|
similarFolderId: null,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
set((state) => ({
|
||||||
|
images: [...state.images, ...result.images],
|
||||||
|
loadedCount: state.loadedCount + result.images.length,
|
||||||
|
totalImages: result.total,
|
||||||
|
loadingImages: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1608,6 +1619,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
"AI tagging complete",
|
"AI tagging complete",
|
||||||
`${folderName} finished generating tags.${failureDetail}`,
|
`${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