feat: expand media discovery and AI workflows #8

Merged
LyAhn merged 25 commits from codex/5-discovery-features into main 2026-06-07 22:43:16 +00:00
3 changed files with 83 additions and 25 deletions
Showing only changes of commit 7efb187971 - Show all commits
+29 -5
View File
@@ -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,
&params.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
View File
@@ -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
View File
@@ -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 });
}
}