feat: expand media discovery and AI workflows #8
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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.`,
|
||||
|
||||
Reference in New Issue
Block a user