feat: expand media discovery and AI workflows #8
@@ -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