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
8 changed files with 203 additions and 35 deletions
Showing only changes of commit 7871d52d39 - Show all commits
+34 -2
View File
@@ -325,6 +325,37 @@ pub async fn reindex_folder(
Ok(())
}
#[tauri::command]
pub async fn update_folder_path(
app: AppHandle,
db: State<'_, DbState>,
folder_id: i64,
new_path: String,
) -> Result<(), String> {
let new_path_buf = PathBuf::from(&new_path);
if !new_path_buf.is_dir() {
return Err(format!("Path is not a valid directory: {}", new_path));
}
let new_name = new_path_buf
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| new_path.clone());
{
let conn = db.get().map_err(|e| e.to_string())?;
// Fetch the old path before updating so image paths can be rewritten.
let old_path = db::get_folders(&conn)
.map_err(|e| e.to_string())?
.into_iter()
.find(|f| f.id == folder_id)
.map(|f| f.path)
.ok_or("Folder not found")?;
db::update_folder_path(&conn, folder_id, &old_path, &new_path, &new_name)
.map_err(|e| e.to_string())?;
}
indexer::index_folder(app, db.inner().clone(), folder_id, new_path_buf);
Ok(())
}
#[tauri::command]
pub async fn find_similar_images(
db: State<'_, DbState>,
@@ -403,9 +434,10 @@ pub async fn find_similar_by_region(
)
.map_err(|e| e.to_string())?,
None => {
let mut ids = vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 1)
// Fetch one extra candidate to compensate for the source image that
// will be removed, so has_more is accurate and results span multiple pages.
let mut ids = vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2)
.map_err(|e| e.to_string())?;
// Exclude the source image from global results
ids.retain(|&id| id != params.image_id);
ids
}
+37 -2
View File
@@ -30,6 +30,7 @@ pub struct Folder {
pub name: String,
pub image_count: i64,
pub indexed_at: Option<String>,
pub scan_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -172,7 +173,8 @@ pub fn migrate(conn: &Connection) -> Result<()> {
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
image_count INTEGER NOT NULL DEFAULT 0,
indexed_at TEXT
indexed_at TEXT,
scan_error TEXT
);
CREATE TABLE IF NOT EXISTS images (
@@ -304,6 +306,7 @@ pub fn migrate(conn: &Connection) -> Result<()> {
ensure_column(conn, "images", "ai_tagger_model", "TEXT")?;
ensure_column(conn, "images", "ai_tagged_at", "TEXT")?;
ensure_column(conn, "images", "ai_tagger_error", "TEXT")?;
ensure_column(conn, "folders", "scan_error", "TEXT")?;
vector::migrate(conn)?;
Ok(())
@@ -1339,7 +1342,7 @@ pub fn get_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result<Vec<Ima
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
let mut stmt =
conn.prepare("SELECT id, path, name, image_count, indexed_at FROM folders ORDER BY name")?;
conn.prepare("SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name")?;
let rows = stmt.query_map([], |row| {
Ok(Folder {
id: row.get(0)?,
@@ -1347,11 +1350,43 @@ pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
name: row.get(2)?,
image_count: row.get(3)?,
indexed_at: row.get(4)?,
scan_error: row.get(5)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn update_folder_path(conn: &Connection, folder_id: i64, old_path: &str, new_path: &str, new_name: &str) -> Result<()> {
conn.execute(
"UPDATE folders SET path = ?2, name = ?3, scan_error = NULL WHERE id = ?1",
params![folder_id, new_path, new_name],
)?;
// Rewrite image paths so the indexer can match them by path and skip
// re-generating thumbnails and embeddings for unchanged files.
// SQLite's replace() does a literal prefix substitution on each path.
conn.execute(
"UPDATE images SET path = replace(path, ?1, ?2) WHERE folder_id = ?3",
params![old_path, new_path, folder_id],
)?;
Ok(())
}
pub fn set_folder_scan_error(conn: &Connection, folder_id: i64, error: &str) -> Result<()> {
conn.execute(
"UPDATE folders SET scan_error = ?2 WHERE id = ?1",
params![folder_id, error],
)?;
Ok(())
}
pub fn clear_folder_scan_error(conn: &Connection, folder_id: i64) -> Result<()> {
conn.execute(
"UPDATE folders SET scan_error = NULL WHERE id = ?1",
[folder_id],
)?;
Ok(())
}
pub fn get_images(
conn: &Connection,
folder_id: Option<i64>,
+32 -3
View File
@@ -147,11 +147,39 @@ pub struct MediaJobProgressEvent {
pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) {
std::thread::spawn(move || {
set_folder_indexing_state(folder_id, true);
// If the folder path no longer exists on disk, record the error and
// emit done. Images are intentionally kept in the DB so the user can
// choose to relocate the folder or remove it explicitly — they should
// not be silently destroyed.
if !folder_path.is_dir() {
let error_msg = format!("Folder not found: {}", folder_path.display());
eprintln!("Indexing error for folder {}: {}", folder_id, error_msg);
if let Ok(conn) = pool.get() {
let _ = db::set_folder_scan_error(&conn, folder_id, &error_msg);
}
emit_progress(
&app,
&IndexProgress {
folder_id,
total: 0,
indexed: 0,
current_file: String::new(),
done: true,
},
);
set_folder_indexing_state(folder_id, false);
return;
}
let storage_profile = detect_storage_profile(&folder_path);
set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile));
set_folder_indexing_state(folder_id, true);
if let Err(error) = do_index(app.clone(), pool, folder_id, folder_path) {
if let Err(error) = do_index(app.clone(), &pool, folder_id, folder_path) {
eprintln!("Indexing error for folder {}: {}", folder_id, error);
if let Ok(conn) = pool.get() {
let _ = db::set_folder_scan_error(&conn, folder_id, &error.to_string());
}
// Always emit done so the frontend reloads and recovers from partial state.
emit_progress(
&app,
@@ -246,7 +274,7 @@ pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf)
});
}
fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
let existing_entries = {
let conn = pool.get()?;
db::get_folder_media_index(&conn, folder_id)?
@@ -344,6 +372,7 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf)
}
let _ = db::backfill_embedding_jobs(&conn)?;
db::update_folder_count(&conn, folder_id)?;
let _ = db::clear_folder_scan_error(&conn, folder_id);
}
emit_progress(
+1
View File
@@ -133,6 +133,7 @@ pub fn run() {
commands::find_duplicates,
commands::load_duplicate_scan_cache,
commands::delete_images_from_disk,
commands::update_folder_path,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+1
View File
@@ -191,6 +191,7 @@ pub fn set_tagger_threshold(app_data_dir: &Path, threshold: f32) -> Result<f32>
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, clamped.to_string())?;
TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed);
Ok(clamped)
}
-2
View File
@@ -57,7 +57,6 @@ export function BackgroundTasks() {
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false);
@@ -130,7 +129,6 @@ export function BackgroundTasks() {
const dismissTask = (id: number, snapshot: string) => {
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed
void clearTaggingJobs(id);
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
setExpanded(false);
};
+47 -5
View File
@@ -11,9 +11,18 @@ function FolderItem({
selected: boolean;
progress: IndexProgress | undefined;
}) {
const { selectFolder, removeFolder, reindexFolder } = useGalleryStore();
const { selectFolder, removeFolder, reindexFolder, updateFolderPath } = useGalleryStore();
const isIndexing = progress && !progress.done;
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const isMissing = !!folder.scan_error && !isIndexing;
const handleLocateFolder = async (e: React.MouseEvent) => {
e.stopPropagation();
const selected = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` });
if (selected && typeof selected === "string") {
await updateFolderPath(folder.id, selected);
}
};
useEffect(() => {
if (!confirmingRemoval) return;
@@ -31,6 +40,7 @@ function FolderItem({
};
return (
<>
<div
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selected
@@ -39,10 +49,19 @@ function FolderItem({
}`}
onClick={() => selectFolder(folder.id)}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
{folder.scan_error ? (
<span title={folder.scan_error} className="shrink-0 text-amber-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</span>
) : (
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
)}
<div className="flex-1 min-w-0">
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
@@ -112,6 +131,29 @@ function FolderItem({
</div>
)}
</div>
{isMissing && (
<div className="mx-2 mb-1 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<p className="text-[11px] text-amber-400 font-medium mb-1.5">Folder not found</p>
<p className="text-[10px] text-gray-500 mb-2 leading-snug">
This folder may have been moved or renamed. Locate it to resume, or remove it from the app.
</p>
<div className="flex gap-1.5">
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 hover:text-amber-200 transition-colors"
onClick={handleLocateFolder}
>
Locate Folder
</button>
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-white/5 text-gray-400 hover:bg-red-500/15 hover:text-red-400 transition-colors"
onClick={(e) => { e.stopPropagation(); void removeFolder(folder.id); }}
>
Remove
</button>
</div>
</div>
)}
</>
);
}
+51 -21
View File
@@ -10,6 +10,7 @@ export interface Folder {
name: string;
image_count: number;
indexed_at: string | null;
scan_error: string | null;
}
export type MediaKind = "image" | "video";
@@ -279,12 +280,14 @@ interface GalleryState {
duplicateScanProgress: { scanned: number; total: number } | null;
duplicateSelectedIds: Set<number>;
duplicateLastScanned: number | null; // Unix timestamp (seconds)
duplicateScanFolderId: number | null | undefined; // undefined = never scanned
loadFolders: () => Promise<void>;
loadBackgroundJobProgress: () => Promise<void>;
addFolder: (path: string) => Promise<void>;
removeFolder: (folderId: number) => Promise<void>;
reindexFolder: (folderId: number) => Promise<void>;
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
selectFolder: (folderId: number | null) => void;
loadImages: (reset?: boolean) => Promise<void>;
loadMoreImages: () => Promise<void>;
@@ -611,6 +614,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
duplicateScanProgress: null,
duplicateSelectedIds: new Set(),
duplicateLastScanned: null,
duplicateScanFolderId: undefined,
setCacheDir: (cacheDir) => set({ cacheDir }),
@@ -667,6 +671,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
await loadBackgroundJobProgress();
},
updateFolderPath: async (folderId, newPath) => {
const { loadFolders, loadBackgroundJobProgress } = get();
await invoke("update_folder_path", { folderId, newPath });
await loadFolders();
await loadBackgroundJobProgress();
},
selectFolder: (folderId) => {
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, imageLoadError: null });
void get().loadImages(true);
@@ -785,24 +796,33 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) {
if (!similarHasMore) return;
const result = await invoke<SimilarImagesPage>("find_similar_by_region", {
params: {
image_id: similarSourceImageId,
crop_x: similarCrop.x,
crop_y: similarCrop.y,
crop_w: similarCrop.w,
crop_h: similarCrop.h,
folder_id: similarFolderId,
offset: loadedCount,
limit: PAGE_SIZE,
},
});
set((state) => ({
images: [...state.images, ...result.images],
loadedCount: state.loadedCount + result.images.length,
totalImages: result.has_more ? state.loadedCount + result.images.length + 1 : state.loadedCount + result.images.length,
similarHasMore: result.has_more,
}));
const requestToken = ++galleryRequestToken;
set({ loadingImages: true });
try {
const result = await invoke<SimilarImagesPage>("find_similar_by_region", {
params: {
image_id: similarSourceImageId,
crop_x: similarCrop.x,
crop_y: similarCrop.y,
crop_w: similarCrop.w,
crop_h: similarCrop.h,
folder_id: similarFolderId,
offset: loadedCount,
limit: PAGE_SIZE,
},
});
if (requestToken !== galleryRequestToken) return;
set((state) => ({
images: [...state.images, ...result.images],
loadedCount: state.loadedCount + result.images.length,
totalImages: result.has_more ? state.loadedCount + result.images.length + 1 : state.loadedCount + result.images.length,
similarHasMore: result.has_more,
loadingImages: false,
}));
} catch {
if (requestToken !== galleryRequestToken) return;
set({ loadingImages: false });
}
return;
}
await get().loadImages(false);
@@ -858,7 +878,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
openImage: (image) => set({ selectedImage: image }),
closeImage: () => set({ selectedImage: null }),
setView: (activeView) => set({ activeView }),
setView: (activeView) => {
if (activeView === "duplicates") {
const { selectedFolderId, duplicateScanFolderId } = get();
if (duplicateScanFolderId !== selectedFolderId) {
set({ activeView, duplicateGroups: [], duplicateLastScanned: null, duplicateScanFolderId: undefined });
void get().loadDuplicateScanCache(selectedFolderId);
return;
}
}
set({ activeView });
},
setExploreMode: (exploreMode) => set({ exploreMode }),
@@ -1409,7 +1439,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
if (cached) {
set({ duplicateGroups: cached.groups, duplicateLastScanned: cached.scanned_at });
set({ duplicateGroups: cached.groups, duplicateLastScanned: cached.scanned_at, duplicateScanFolderId: folderId });
}
},
@@ -1422,7 +1452,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
});
try {
const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null });
set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000) });
set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000), duplicateScanFolderId: folderId });
void notifyTaskComplete(
"Duplicate scan complete",
groups.length === 1 ? "Found 1 duplicate group." : `Found ${groups.length.toLocaleString()} duplicate groups.`,