fix: five PR review bugs and folder-recovery UX for missing/renamed folders
BackgroundTasks: dismiss no longer calls clearTaggingJobs; dismissal only updates local dismissed state DuplicateFinder: entering the view clears stale groups when the stored scan scope differs from selectedFolderId; duplicateScanFolderId is recorded on each scan and cache load so scope is always tracked Tagger threshold: set_tagger_threshold now sets TAGGER_SESSION_DIRTY so the worker rebuilds its WdTagger instance and applies the new threshold on the next batch Region search global scope: fetch offset+limit+2 candidates before removing the source image so has_more is accurate across pages Region search pagination: loadMoreImages now sets loadingImages and checks galleryRequestToken before appending results, preventing duplicate pages and stale responses corrupting a new collection Folder recovery: when index_folder detects a missing path it records the error in a new folders.scan_error column (ensure_column migration) and emits done without deleting images, preserving them for recovery. A new update_folder_path command rewrites image paths in the DB before reindexing so thumbnails and embeddings are not needlessly regenerated for unchanged files. The sidebar shows an amber warning icon on affected folders and a recovery banner with Locate Folder and Remove actions
This commit is contained in:
@@ -325,6 +325,37 @@ pub async fn reindex_folder(
|
|||||||
Ok(())
|
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]
|
#[tauri::command]
|
||||||
pub async fn find_similar_images(
|
pub async fn find_similar_images(
|
||||||
db: State<'_, DbState>,
|
db: State<'_, DbState>,
|
||||||
@@ -403,9 +434,10 @@ pub async fn find_similar_by_region(
|
|||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?,
|
.map_err(|e| e.to_string())?,
|
||||||
None => {
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
// Exclude the source image from global results
|
|
||||||
ids.retain(|&id| id != params.image_id);
|
ids.retain(|&id| id != params.image_id);
|
||||||
ids
|
ids
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-2
@@ -30,6 +30,7 @@ pub struct Folder {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub image_count: i64,
|
pub image_count: i64,
|
||||||
pub indexed_at: Option<String>,
|
pub indexed_at: Option<String>,
|
||||||
|
pub scan_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -172,7 +173,8 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
path TEXT NOT NULL UNIQUE,
|
path TEXT NOT NULL UNIQUE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
image_count INTEGER NOT NULL DEFAULT 0,
|
image_count INTEGER NOT NULL DEFAULT 0,
|
||||||
indexed_at TEXT
|
indexed_at TEXT,
|
||||||
|
scan_error TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS images (
|
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_tagger_model", "TEXT")?;
|
||||||
ensure_column(conn, "images", "ai_tagged_at", "TEXT")?;
|
ensure_column(conn, "images", "ai_tagged_at", "TEXT")?;
|
||||||
ensure_column(conn, "images", "ai_tagger_error", "TEXT")?;
|
ensure_column(conn, "images", "ai_tagger_error", "TEXT")?;
|
||||||
|
ensure_column(conn, "folders", "scan_error", "TEXT")?;
|
||||||
|
|
||||||
vector::migrate(conn)?;
|
vector::migrate(conn)?;
|
||||||
Ok(())
|
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>> {
|
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
|
||||||
let mut stmt =
|
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| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(Folder {
|
Ok(Folder {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
@@ -1347,11 +1350,43 @@ pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
|
|||||||
name: row.get(2)?,
|
name: row.get(2)?,
|
||||||
image_count: row.get(3)?,
|
image_count: row.get(3)?,
|
||||||
indexed_at: row.get(4)?,
|
indexed_at: row.get(4)?,
|
||||||
|
scan_error: row.get(5)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
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(
|
pub fn get_images(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder_id: Option<i64>,
|
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) {
|
pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) {
|
||||||
std::thread::spawn(move || {
|
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);
|
let storage_profile = detect_storage_profile(&folder_path);
|
||||||
set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile));
|
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);
|
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.
|
// Always emit done so the frontend reloads and recovers from partial state.
|
||||||
emit_progress(
|
emit_progress(
|
||||||
&app,
|
&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 existing_entries = {
|
||||||
let conn = pool.get()?;
|
let conn = pool.get()?;
|
||||||
db::get_folder_media_index(&conn, folder_id)?
|
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)?;
|
let _ = db::backfill_embedding_jobs(&conn)?;
|
||||||
db::update_folder_count(&conn, folder_id)?;
|
db::update_folder_count(&conn, folder_id)?;
|
||||||
|
let _ = db::clear_folder_scan_error(&conn, folder_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_progress(
|
emit_progress(
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ pub fn run() {
|
|||||||
commands::find_duplicates,
|
commands::find_duplicates,
|
||||||
commands::load_duplicate_scan_cache,
|
commands::load_duplicate_scan_cache,
|
||||||
commands::delete_images_from_disk,
|
commands::delete_images_from_disk,
|
||||||
|
commands::update_folder_path,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
std::fs::write(path, clamped.to_string())?;
|
std::fs::write(path, clamped.to_string())?;
|
||||||
|
TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed);
|
||||||
Ok(clamped)
|
Ok(clamped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export function BackgroundTasks() {
|
|||||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||||
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
||||||
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
|
|
||||||
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
|
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
|
||||||
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -130,7 +129,6 @@ export function BackgroundTasks() {
|
|||||||
|
|
||||||
const dismissTask = (id: number, snapshot: string) => {
|
const dismissTask = (id: number, snapshot: string) => {
|
||||||
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed
|
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed
|
||||||
void clearTaggingJobs(id);
|
|
||||||
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
|
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,9 +11,18 @@ function FolderItem({
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
progress: IndexProgress | undefined;
|
progress: IndexProgress | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { selectFolder, removeFolder, reindexFolder } = useGalleryStore();
|
const { selectFolder, removeFolder, reindexFolder, updateFolderPath } = useGalleryStore();
|
||||||
const isIndexing = progress && !progress.done;
|
const isIndexing = progress && !progress.done;
|
||||||
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!confirmingRemoval) return;
|
if (!confirmingRemoval) return;
|
||||||
@@ -31,6 +40,7 @@ function FolderItem({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||||
selected
|
selected
|
||||||
@@ -39,10 +49,19 @@ function FolderItem({
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => selectFolder(folder.id)}
|
onClick={() => selectFolder(folder.id)}
|
||||||
>
|
>
|
||||||
|
{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">
|
<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}
|
<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" />
|
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>
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
|
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
|
||||||
@@ -112,6 +131,29 @@ function FolderItem({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-3
@@ -10,6 +10,7 @@ export interface Folder {
|
|||||||
name: string;
|
name: string;
|
||||||
image_count: number;
|
image_count: number;
|
||||||
indexed_at: string | null;
|
indexed_at: string | null;
|
||||||
|
scan_error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaKind = "image" | "video";
|
export type MediaKind = "image" | "video";
|
||||||
@@ -279,12 +280,14 @@ interface GalleryState {
|
|||||||
duplicateScanProgress: { scanned: number; total: number } | null;
|
duplicateScanProgress: { scanned: number; total: number } | null;
|
||||||
duplicateSelectedIds: Set<number>;
|
duplicateSelectedIds: Set<number>;
|
||||||
duplicateLastScanned: number | null; // Unix timestamp (seconds)
|
duplicateLastScanned: number | null; // Unix timestamp (seconds)
|
||||||
|
duplicateScanFolderId: number | null | undefined; // undefined = never scanned
|
||||||
|
|
||||||
loadFolders: () => Promise<void>;
|
loadFolders: () => Promise<void>;
|
||||||
loadBackgroundJobProgress: () => Promise<void>;
|
loadBackgroundJobProgress: () => Promise<void>;
|
||||||
addFolder: (path: string) => Promise<void>;
|
addFolder: (path: string) => Promise<void>;
|
||||||
removeFolder: (folderId: number) => Promise<void>;
|
removeFolder: (folderId: number) => Promise<void>;
|
||||||
reindexFolder: (folderId: number) => Promise<void>;
|
reindexFolder: (folderId: number) => Promise<void>;
|
||||||
|
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
|
||||||
selectFolder: (folderId: number | null) => void;
|
selectFolder: (folderId: number | null) => void;
|
||||||
loadImages: (reset?: boolean) => Promise<void>;
|
loadImages: (reset?: boolean) => Promise<void>;
|
||||||
loadMoreImages: () => Promise<void>;
|
loadMoreImages: () => Promise<void>;
|
||||||
@@ -611,6 +614,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
duplicateScanProgress: null,
|
duplicateScanProgress: null,
|
||||||
duplicateSelectedIds: new Set(),
|
duplicateSelectedIds: new Set(),
|
||||||
duplicateLastScanned: null,
|
duplicateLastScanned: null,
|
||||||
|
duplicateScanFolderId: undefined,
|
||||||
|
|
||||||
setCacheDir: (cacheDir) => set({ cacheDir }),
|
setCacheDir: (cacheDir) => set({ cacheDir }),
|
||||||
|
|
||||||
@@ -667,6 +671,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
await loadBackgroundJobProgress();
|
await loadBackgroundJobProgress();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateFolderPath: async (folderId, newPath) => {
|
||||||
|
const { loadFolders, loadBackgroundJobProgress } = get();
|
||||||
|
await invoke("update_folder_path", { folderId, newPath });
|
||||||
|
await loadFolders();
|
||||||
|
await loadBackgroundJobProgress();
|
||||||
|
},
|
||||||
|
|
||||||
selectFolder: (folderId) => {
|
selectFolder: (folderId) => {
|
||||||
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, imageLoadError: null });
|
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, imageLoadError: null });
|
||||||
void get().loadImages(true);
|
void get().loadImages(true);
|
||||||
@@ -785,6 +796,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) {
|
if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) {
|
||||||
if (!similarHasMore) return;
|
if (!similarHasMore) return;
|
||||||
|
const requestToken = ++galleryRequestToken;
|
||||||
|
set({ loadingImages: true });
|
||||||
|
try {
|
||||||
const result = await invoke<SimilarImagesPage>("find_similar_by_region", {
|
const result = await invoke<SimilarImagesPage>("find_similar_by_region", {
|
||||||
params: {
|
params: {
|
||||||
image_id: similarSourceImageId,
|
image_id: similarSourceImageId,
|
||||||
@@ -797,12 +811,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (requestToken !== galleryRequestToken) return;
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
images: [...state.images, ...result.images],
|
images: [...state.images, ...result.images],
|
||||||
loadedCount: state.loadedCount + result.images.length,
|
loadedCount: state.loadedCount + result.images.length,
|
||||||
totalImages: result.has_more ? state.loadedCount + result.images.length + 1 : state.loadedCount + result.images.length,
|
totalImages: result.has_more ? state.loadedCount + result.images.length + 1 : state.loadedCount + result.images.length,
|
||||||
similarHasMore: result.has_more,
|
similarHasMore: result.has_more,
|
||||||
|
loadingImages: false,
|
||||||
}));
|
}));
|
||||||
|
} catch {
|
||||||
|
if (requestToken !== galleryRequestToken) return;
|
||||||
|
set({ loadingImages: false });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await get().loadImages(false);
|
await get().loadImages(false);
|
||||||
@@ -858,7 +878,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
openImage: (image) => set({ selectedImage: image }),
|
openImage: (image) => set({ selectedImage: image }),
|
||||||
closeImage: () => set({ selectedImage: null }),
|
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 }),
|
setExploreMode: (exploreMode) => set({ exploreMode }),
|
||||||
|
|
||||||
@@ -1409,7 +1439,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
|
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
|
||||||
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
|
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
|
||||||
if (cached) {
|
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 {
|
try {
|
||||||
const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null });
|
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(
|
void notifyTaskComplete(
|
||||||
"Duplicate scan complete",
|
"Duplicate scan complete",
|
||||||
groups.length === 1 ? "Found 1 duplicate group." : `Found ${groups.length.toLocaleString()} duplicate groups.`,
|
groups.length === 1 ? "Found 1 duplicate group." : `Found ${groups.length.toLocaleString()} duplicate groups.`,
|
||||||
|
|||||||
Reference in New Issue
Block a user