diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4b00dc3..9a80c58 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2179,6 +2179,12 @@ pub struct ClearTaggingJobsParams { pub folder_ids: Option>, } +#[derive(Deserialize)] +pub struct ResetAiTagsParams { + pub folder_id: Option, + pub folder_ids: Option>, +} + #[derive(Deserialize)] pub struct GetImageTagsParams { pub image_id: i64, @@ -2377,6 +2383,42 @@ pub async fn clear_tagging_jobs( Ok(n) } +#[tauri::command] +pub async fn reset_ai_tags( + app: AppHandle, + db: State<'_, DbState>, + params: ResetAiTagsParams, +) -> Result { + let conn = db.get().map_err(|e| e.to_string())?; + let requested_folder_ids = params.folder_ids.unwrap_or_default(); + let (n, folder_ids): (usize, Vec) = + match (params.folder_id, requested_folder_ids.is_empty()) { + (Some(id), _) => ( + db::reset_ai_tags(&conn, Some(id)).map_err(|e| e.to_string())?, + vec![id], + ), + (None, false) => { + let mut total = 0usize; + for &folder_id in &requested_folder_ids { + total += db::reset_ai_tags(&conn, Some(folder_id)) + .map_err(|e| e.to_string())?; + } + (total, requested_folder_ids) + } + (None, true) => ( + db::reset_ai_tags(&conn, None).map_err(|e| e.to_string())?, + db::get_folders(&conn) + .map_err(|e| e.to_string())? + .into_iter() + .map(|f| f.id) + .collect(), + ), + }; + drop(conn); + indexer::emit_folder_job_progress(&app, db.inner(), &folder_ids, true); + Ok(n) +} + #[tauri::command] pub async fn get_image_tags( db: State<'_, DbState>, diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 8249ef0..d486fa3 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -146,6 +146,8 @@ pub struct ExploreTagEntry { pub count: i64, pub representative_image_id: i64, pub thumbnail_path: Option, + pub has_ai_source: bool, + pub has_user_source: bool, } #[derive(Debug, Clone)] @@ -2341,7 +2343,9 @@ pub fn search_tags_autocomplete( ) -> Result> { let pattern = format!("%{}%", query.to_lowercase()); let mut stmt = conn.prepare( - "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id + "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id, + MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source, + MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source FROM image_tags t JOIN images i ON i.id = t.image_id WHERE (?1 IS NULL OR i.folder_id = ?1) @@ -2357,6 +2361,8 @@ pub fn search_tags_autocomplete( count: row.get(1)?, representative_image_id: row.get(2)?, thumbnail_path: None, // skip per-suggestion thumbnail for speed + has_ai_source: row.get::<_, i64>(3)? != 0, + has_user_source: row.get::<_, i64>(4)? != 0, }) })? .collect::>>()?; @@ -2440,7 +2446,9 @@ pub fn get_explore_tags( limit: usize, ) -> Result> { let mut stmt = conn.prepare( - "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id + "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id, + MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source, + MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source FROM image_tags t JOIN images i ON i.id = t.image_id WHERE (?1 IS NULL OR i.folder_id = ?1) @@ -2462,6 +2470,8 @@ pub fn get_explore_tags( count: row.get(1)?, representative_image_id, thumbnail_path, + has_ai_source: row.get::<_, i64>(3)? != 0, + has_user_source: row.get::<_, i64>(4)? != 0, }) })? .collect::>>()?; @@ -2848,6 +2858,110 @@ pub fn delete_tag(conn: &Connection, name: &str) -> Result { Ok(removed) } +pub fn reset_ai_tags(conn: &Connection, folder_id: Option) -> Result { + let image_ids = match folder_id { + Some(folder_id) => { + let mut stmt = conn.prepare( + "SELECT DISTINCT i.id + FROM images i + LEFT JOIN image_tags t ON t.image_id = i.id AND t.source = 'ai' + LEFT JOIN tagging_jobs j ON j.image_id = i.id + WHERE i.folder_id = ?1 + AND i.media_kind = 'image' + AND ( + t.id IS NOT NULL + OR i.ai_rating IS NOT NULL + OR i.ai_tagger_model IS NOT NULL + OR i.ai_tagged_at IS NOT NULL + OR i.ai_tagger_error IS NOT NULL + OR j.image_id IS NOT NULL + )", + )?; + let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?; + rows.collect::>>()? + } + None => { + let mut stmt = conn.prepare( + "SELECT DISTINCT i.id + FROM images i + LEFT JOIN image_tags t ON t.image_id = i.id AND t.source = 'ai' + LEFT JOIN tagging_jobs j ON j.image_id = i.id + WHERE i.media_kind = 'image' + AND ( + t.id IS NOT NULL + OR i.ai_rating IS NOT NULL + OR i.ai_tagger_model IS NOT NULL + OR i.ai_tagged_at IS NOT NULL + OR i.ai_tagger_error IS NOT NULL + OR j.image_id IS NOT NULL + )", + )?; + let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?; + rows.collect::>>()? + } + }; + + let tx = conn.unchecked_transaction()?; + match folder_id { + Some(folder_id) => { + tx.execute( + "DELETE FROM image_tags + WHERE source = 'ai' + AND image_id IN (SELECT id FROM images WHERE folder_id = ?1)", + [folder_id], + )?; + tx.execute( + "UPDATE images + SET ai_rating = NULL, + ai_tagger_model = NULL, + ai_tagged_at = NULL, + ai_tagger_error = NULL + WHERE folder_id = ?1 + AND media_kind = 'image'", + [folder_id], + )?; + tx.execute( + "UPDATE tagging_jobs + SET status = 'cancelled', last_error = NULL, updated_at = datetime('now') + WHERE status = 'processing' + AND image_id IN (SELECT id FROM images WHERE folder_id = ?1)", + [folder_id], + )?; + tx.execute( + "DELETE FROM tagging_jobs + WHERE status NOT IN ('processing', 'cancelled') + AND image_id IN (SELECT id FROM images WHERE folder_id = ?1)", + [folder_id], + )?; + } + None => { + tx.execute("DELETE FROM image_tags WHERE source = 'ai'", [])?; + tx.execute( + "UPDATE images + SET ai_rating = NULL, + ai_tagger_model = NULL, + ai_tagged_at = NULL, + ai_tagger_error = NULL + WHERE media_kind = 'image'", + [], + )?; + tx.execute( + "UPDATE tagging_jobs + SET status = 'cancelled', last_error = NULL, updated_at = datetime('now') + WHERE status = 'processing'", + [], + )?; + tx.execute( + "DELETE FROM tagging_jobs WHERE status NOT IN ('processing', 'cancelled')", + [], + )?; + } + } + tx.execute("DELETE FROM visual_cluster_cache", [])?; + tx.commit()?; + Ok(image_ids.len()) +} + pub fn enqueue_missing_tagging_jobs_for_folder(conn: &Connection, folder_id: i64) -> Result { let inserted = conn.execute( "INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5489d92..706012a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,6 +218,7 @@ pub fn run() { commands::delete_tagger_model, commands::queue_tagging_jobs, commands::clear_tagging_jobs, + commands::reset_ai_tags, commands::get_image_tags, commands::add_user_tag, commands::remove_tag, diff --git a/src/components/ExploreView.tsx b/src/components/ExploreView.tsx index cb7c7cc..76a522b 100644 --- a/src/components/ExploreView.tsx +++ b/src/components/ExploreView.tsx @@ -678,6 +678,51 @@ const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [ { value: "za", label: "Z-A" }, ]; +function AiSourceGlyph({ className = "" }: { className?: string }) { + return ( + + ); +} + +function RenameGlyph({ className = "" }: { className?: string }) { + return ( + + ); +} + +function DeleteGlyph({ className = "" }: { className?: string }) { + return ( + + ); +} + // Compact management tile for a single tag. Rename doubles as merge when the new // name already exists, and delete applies across the scoped tag set. function TagManageTile({ @@ -719,12 +764,20 @@ function TagManageTile({ } }; + const sourceLabel = entry.has_ai_source + ? entry.has_user_source + ? "Used by AI tags and user tags" + : "AI-generated tag" + : "User tag"; + return (
{editing ? ( @@ -749,9 +802,20 @@ function TagManageTile({ )} - - {entry.count.toLocaleString()} - +
+ {entry.has_ai_source ? ( + + + + {entry.count.toLocaleString()} + + + ) : ( + + {entry.count.toLocaleString()} + + )} +
{editing ? ( @@ -789,21 +853,25 @@ function TagManageTile({
) : ( -
+
+ + + -
)}
@@ -815,17 +883,24 @@ function TagManageList({ onSearch, onRename, onDelete, + onResetAiTags, + scopeLabel, }: { entries: ExploreTagEntry[]; onSearch: (tag: string) => void; onRename: (from: string, to: string) => Promise; onDelete: (tag: string) => Promise; + onResetAiTags: () => Promise; + scopeLabel: string; }) { const scrollRef = useRef(null); const measureRef = useRef(null); const [query, setQuery] = useState(""); const [sort, setSort] = useState("count_desc"); const [columns, setColumns] = useState(3); + const [resetConfirming, setResetConfirming] = useState(false); + const [resetting, setResetting] = useState(false); + const [resetStatus, setResetStatus] = useState(null); useLayoutEffect(() => { const el = measureRef.current; @@ -870,6 +945,30 @@ function TagManageList({ const visibleItems = rowVirtualizer.getVirtualItems(); const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]); + const runResetAiTags = async () => { + if (!resetConfirming) { + setResetConfirming(true); + setResetStatus(`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`); + return; + } + + setResetting(true); + setResetStatus(null); + try { + const count = await onResetAiTags(); + setResetStatus( + count === 0 + ? "No AI tag data found for this scope." + : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`, + ); + } catch (error) { + setResetStatus(String(error)); + } finally { + setResetting(false); + setResetConfirming(false); + } + }; + return (
@@ -887,9 +986,33 @@ function TagManageList({

Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.

+ {resetStatus ?

{resetStatus}

: null}
+ + {resetConfirming ? ( + + ) : null}
state.searchForTag); const renameTag = useGalleryStore((state) => state.renameTag); const deleteTag = useGalleryStore((state) => state.deleteTag); + const resetAiTags = useGalleryStore((state) => state.resetAiTags); + const folders = useGalleryStore((state) => state.folders); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); // Manage mode lives in the store so it can be opened from elsewhere (Settings). const manageTags = useGalleryStore((state) => state.tagManagerOpen); const setManageTags = useGalleryStore((state) => state.setTagManagerOpen); const handleDeleteTag = async (tag: string) => { await deleteTag(tag); }; + const tagManagerScopeLabel = + selectedFolderId === null + ? "all media" + : folders.find((folder) => folder.id === selectedFolderId)?.name ?? "the current folder"; + const handleResetAiTags = async () => { + const count = await resetAiTags(selectedFolderId); + await loadExploreTags({ force: true }); + return count; + }; useEffect(() => { if (exploreMode === "visual") void loadVisualClusters(); @@ -1080,6 +1214,8 @@ export function ExploreView() { onSearch={searchForTag} onRename={renameTag} onDelete={handleDeleteTag} + onResetAiTags={handleResetAiTags} + scopeLabel={tagManagerScopeLabel} />
) : ( diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index e1ff9d8..b7556cc 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -166,6 +166,8 @@ export function SettingsModal() { const [taggerQueueStatus, setTaggerQueueStatus] = useState(null); const [taggerQueueing, setTaggerQueueing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false); + const [taggerResetConfirming, setTaggerResetConfirming] = useState(false); + const [taggerResetting, setTaggerResetting] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); const [taggerAccelerationError, setTaggerAccelerationError] = useState(null); const [taggerModelSwitching, setTaggerModelSwitching] = useState(false); @@ -226,6 +228,8 @@ export function SettingsModal() { const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders); const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders); + const resetAiTags = useGalleryStore((state) => state.resetAiTags); + const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders); const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); const notificationsPaused = useGalleryStore((state) => state.notificationsPaused); const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused); @@ -341,6 +345,7 @@ export function SettingsModal() { setTaggerClearing(true); } setTaggerQueueStatus(null); + setTaggerResetConfirming(false); void perform .then((count) => { @@ -368,6 +373,45 @@ export function SettingsModal() { }); }; + const runResetAiTags = () => { + if (!taggerResetConfirming) { + setTaggerResetConfirming(true); + setTaggerQueueStatus( + `Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`, + ); + return; + } + + const selectedIds = taggingQueueFolderIds; + const perform = + taggingQueueScope === "all" + ? resetAiTags(null) + : selectedIds.length > 0 + ? resetAiTagsForFolders(selectedIds) + : Promise.resolve(0); + + setTaggerResetting(true); + setTaggerQueueStatus(null); + + void perform + .then((count) => { + if (taggingQueueScope === "selected" && selectedIds.length === 0) { + setTaggerQueueStatus("Choose at least one folder before resetting AI tags."); + return; + } + setTaggerQueueStatus( + count === 0 + ? "No AI tag data found for the current target." + : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`, + ); + }) + .catch((error) => setTaggerQueueStatus(String(error))) + .finally(() => { + setTaggerResetting(false); + setTaggerResetConfirming(false); + }); + }; + return (
setSettingsOpen(false)}>
runQueueAction("queue")} - disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} + disabled={!taggerReady || taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} > {taggerQueueing ? "Queueing..." : "Queue tagging"} + + {taggerResetConfirming ? ( + + ) : null}
diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 7269d9c..d55ac27 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -106,6 +106,38 @@ function searchTagsAutocomplete(payload: unknown): ExploreTagEntry[] { .slice(0, limit); } +function rebuildExploreTags(): ExploreTagEntry[] { + const entries = new Map< + string, + { imageIds: Set; representativeImageId: number; hasAiSource: boolean; hasUserSource: boolean } + >(); + for (const [imageIdString, imageTags] of Object.entries(db.tagsByImageId)) { + const imageId = Number(imageIdString); + for (const imageTag of imageTags) { + const entry = + entries.get(imageTag.tag) ?? + { imageIds: new Set(), representativeImageId: imageId, hasAiSource: false, hasUserSource: false }; + entry.imageIds.add(imageId); + if (imageTag.source === "ai") entry.hasAiSource = true; + if (imageTag.source === "user") entry.hasUserSource = true; + entries.set(imageTag.tag, entry); + } + } + return [...entries.entries()] + .map(([tag, entry]) => { + const representative = db.images.find((image) => image.id === entry.representativeImageId); + return { + tag, + count: entry.imageIds.size, + representative_image_id: entry.representativeImageId, + thumbnail_path: representative?.thumbnail_path ?? null, + has_ai_source: entry.hasAiSource, + has_user_source: entry.hasUserSource, + }; + }) + .sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag)); +} + function semanticSearch(payload: unknown): ImageRecord[] { const p = params(payload); const query = String(p.query ?? "").toLowerCase(); @@ -174,7 +206,7 @@ function updateImages(ids: number[], updates: { favorite?: boolean | null; ratin function refreshAlbumCounts() { for (const album of db.albums) { const ids = db.albumImageIds[album.id] ?? []; - album.image_count = ids.length; + if (db.scenario !== "extreme") album.image_count = ids.length; album.cover_image_id = ids[0] ?? null; album.cover_thumbnail_path = db.images.find((image) => image.id === ids[0])?.thumbnail_path ?? null; album.updated_at = new Date().toISOString(); @@ -285,7 +317,11 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise case "get_visual_clusters": return db.scenario === "empty" ? [] : db.visualClusters; case "get_explore_tags": - return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180)); + return db.scenario === "empty" + ? [] + : db.scenario === "extreme" + ? db.exploreTags + : db.exploreTags.slice(0, Number(p.limit ?? 180)); case "get_related_tags": { const relatedCounts = new Map(); for (const imageTags of Object.values(db.tagsByImageId)) { @@ -400,6 +436,32 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise case "queue_tagging_jobs": case "clear_tagging_jobs": return 12; + case "reset_ai_tags": { + const folderIds = p.folder_ids as number[] | undefined; + const folderId = p.folder_id as number | null | undefined; + const scopedImages = db.images.filter((image) => + folderIds && folderIds.length > 0 + ? folderIds.includes(image.folder_id) + : folderId != null + ? image.folder_id === folderId + : true, + ); + let reset = 0; + for (const image of scopedImages) { + const tags = db.tagsByImageId[image.id] ?? []; + const aiTags = tags.filter((tag) => tag.source === "ai"); + if (aiTags.length > 0 || image.ai_tagged_at || image.ai_tagger_error || image.ai_tagger_model || image.ai_rating) { + reset += 1; + } + db.tagsByImageId[image.id] = tags.filter((tag) => tag.source !== "ai"); + image.ai_rating = null; + image.ai_tagger_model = null; + image.ai_tagged_at = null; + image.ai_tagger_error = null; + } + db.exploreTags = rebuildExploreTags(); + return reset; + } case "get_tagger_model_status": return { model_id: "SmilingWolf/wd-vit-tagger-v3", model_name: "WD ViT Tagger", local_dir: "mock://models/tagger", ready: true, missing_files: [] }; case "get_tagger_model": diff --git a/src/dev/mockFixtures.ts b/src/dev/mockFixtures.ts index c4abb06..f966c76 100644 --- a/src/dev/mockFixtures.ts +++ b/src/dev/mockFixtures.ts @@ -29,6 +29,17 @@ export interface MockDb { } const folderNames = ["Camera Roll", "Client Selects", "Video Archive"]; +const extremeFolderNames = [ + "Camera Roll", + "Client Selects", + "Video Archive", + "Commercial Library", + "Archive Vault", + "Reference Pulls", + "Delivery Selects", + "Personal Work", +]; +const extremeFolderCounts = [98_420, 76_880, 52_340, 44_900, 31_760, 24_180, 16_540, 12_920]; const mediaNames = [ "alpine-lake", "city-after-rain", @@ -119,6 +130,16 @@ const hugeTags = [ }), ...Array.from({ length: 320 }, (_, index) => `fixture concept ${String(index + 1).padStart(3, "0")}`), ]; +const extremeTags = [ + ...tags, + ...Array.from({ length: hugeTagThemes.length * hugeTagSubjects.length }, (_, index) => { + const theme = hugeTagThemes[index % hugeTagThemes.length]; + const subject = hugeTagSubjects[Math.floor(index / hugeTagThemes.length) % hugeTagSubjects.length]; + return `${theme} ${subject}`; + }), + ...Array.from({ length: 780 }, (_, index) => `extreme concept ${String(index + 1).padStart(4, "0")}`), + ...Array.from({ length: 240 }, (_, index) => `client taxonomy ${String(index + 1).padStart(3, "0")}`), +]; const aiRatings: AiRating[] = ["general", "sensitive", "questionable"]; function daysAgo(days: number): string { @@ -177,7 +198,8 @@ function makeImage(index: number, folderId: number, scenario: MockScenario): Ima function makeFolders(scenario: MockScenario): Folder[] { if (scenario === "empty") return []; - return folderNames.map((name, index) => ({ + const names = scenario === "extreme" ? extremeFolderNames : folderNames; + return names.map((name, index) => ({ id: index + 1, path: `C:\\Users\\phokus\\Pictures\\${name.replace(/ /g, "")}`, name, @@ -190,21 +212,25 @@ function makeFolders(scenario: MockScenario): Folder[] { function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] { if (scenario === "empty") return []; - const count = scenario === "huge" ? 720 : scenario === "errors" ? 54 : 72; + const count = scenario === "extreme" ? 1_440 : scenario === "huge" ? 720 : scenario === "errors" ? 54 : 72; const images = Array.from({ length: count }, (_, index) => makeImage(index, folders[index % folders.length].id, scenario)); for (const folder of folders) { - folder.image_count = images.filter((image) => image.folder_id === folder.id).length; + folder.image_count = + scenario === "extreme" + ? extremeFolderCounts[folder.id - 1] ?? images.filter((image) => image.folder_id === folder.id).length + : images.filter((image) => image.folder_id === folder.id).length; } return images; } function tagVocabularyForScenario(scenario: MockScenario): string[] { + if (scenario === "extreme") return extremeTags; return scenario === "huge" ? hugeTags : tags; } function makeTags(images: ImageRecord[], scenario: MockScenario): Record { const vocabulary = tagVocabularyForScenario(scenario); - const tagCount = scenario === "huge" ? 9 : 3; + const tagCount = scenario === "extreme" ? 14 : scenario === "huge" ? 9 : 3; return Object.fromEntries( images.map((image, index) => [ image.id, @@ -227,10 +253,13 @@ function makeAlbums(images: ImageRecord[], scenario: MockScenario): { albums: Al const motion = images.filter((image) => image.media_kind === "video").map((image) => image.id); const favorites = images.filter((image) => image.favorite).slice(0, 40).map((image) => image.id); const albumImageIds = { 1: selects, 2: motion, 3: favorites }; + const portfolioCount = scenario === "extreme" ? 18_450 : selects.length; + const motionCount = scenario === "extreme" ? 7_820 : motion.length; + const favoritesCount = scenario === "extreme" ? 42_610 : favorites.length; const albums: Album[] = [ - { id: 1, name: "Portfolio Shortlist", cover_image_id: selects[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === selects[0])?.thumbnail_path ?? null, image_count: selects.length, sort_order: 1, created_at: daysAgo(40), updated_at: daysAgo(2) }, - { id: 2, name: "Motion Tests", cover_image_id: motion[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === motion[0])?.thumbnail_path ?? null, image_count: motion.length, sort_order: 2, created_at: daysAgo(38), updated_at: daysAgo(3) }, - { id: 3, name: "Favorites", cover_image_id: favorites[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === favorites[0])?.thumbnail_path ?? null, image_count: favorites.length, sort_order: 3, created_at: daysAgo(20), updated_at: daysAgo(1) }, + { id: 1, name: "Portfolio Shortlist", cover_image_id: selects[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === selects[0])?.thumbnail_path ?? null, image_count: portfolioCount, sort_order: 1, created_at: daysAgo(40), updated_at: daysAgo(2) }, + { id: 2, name: "Motion Tests", cover_image_id: motion[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === motion[0])?.thumbnail_path ?? null, image_count: motionCount, sort_order: 2, created_at: daysAgo(38), updated_at: daysAgo(3) }, + { id: 3, name: "Favorites", cover_image_id: favorites[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === favorites[0])?.thumbnail_path ?? null, image_count: favoritesCount, sort_order: 3, created_at: daysAgo(20), updated_at: daysAgo(1) }, ]; return { albums, albumImageIds }; } @@ -238,15 +267,15 @@ function makeAlbums(images: ImageRecord[], scenario: MockScenario): { albums: Al function makeProgress(folders: Folder[], scenario: MockScenario): FolderJobProgress[] { return folders.map((folder, index) => ({ folder_id: folder.id, - thumbnail_pending: scenario === "busy" ? 18 - index * 3 : 0, - metadata_pending: scenario === "busy" ? 12 - index * 2 : 0, - embedding_pending: scenario === "busy" ? 36 - index * 4 : scenario === "errors" ? 4 : 0, + thumbnail_pending: scenario === "extreme" ? 180 - index * 9 : scenario === "busy" ? 18 - index * 3 : 0, + metadata_pending: scenario === "extreme" ? 140 - index * 7 : scenario === "busy" ? 12 - index * 2 : 0, + embedding_pending: scenario === "extreme" ? 260 - index * 11 : scenario === "busy" ? 36 - index * 4 : scenario === "errors" ? 4 : 0, embedding_ready: Math.max(0, folder.image_count - (scenario === "busy" ? 20 : 2)), embedding_failed: scenario === "errors" ? 3 + index : 0, - caption_pending: scenario === "busy" ? 24 - index * 2 : 0, + caption_pending: scenario === "extreme" ? 210 - index * 10 : scenario === "busy" ? 24 - index * 2 : 0, caption_ready: Math.max(0, Math.floor(folder.image_count * 0.5)), caption_failed: scenario === "errors" ? 2 : 0, - tagging_pending: scenario === "busy" ? 30 - index * 5 : 0, + tagging_pending: scenario === "extreme" ? 320 - index * 13 : scenario === "busy" ? 30 - index * 5 : 0, tagging_ready: Math.max(0, Math.floor(folder.image_count * 0.65)), tagging_failed: scenario === "errors" ? 4 : 0, })); @@ -258,8 +287,8 @@ function makeVisualClusters(images: ImageRecord[], scenario: MockScenario): Visu // "huge" scenario so Explore shows a realistic field of clusters rather than a // fixed handful, and size the counts off a large virtual library so the badges // read like a big collection. - const clusterCount = scenario === "huge" ? 30 : Math.min(30, Math.max(5, Math.round(images.length / 20))); - const virtualTotal = scenario === "huge" ? 12_000 : images.length; + const clusterCount = scenario === "huge" || scenario === "extreme" ? 30 : Math.min(30, Math.max(5, Math.round(images.length / 20))); + const virtualTotal = scenario === "extreme" ? 250_000 : scenario === "huge" ? 12_000 : images.length; // Long-tailed sizes (a few large clusters, many smaller), like real k-means output. const weights = Array.from({ length: clusterCount }, (_, i) => 1 / (i + 1.5)); const weightSum = weights.reduce((sum, weight) => sum + weight, 0); @@ -279,12 +308,23 @@ function makeExploreTags(images: ImageRecord[], scenario: MockScenario): Explore const vocabulary = tagVocabularyForScenario(scenario); return vocabulary.map((tag, index) => { const representative = images[index % Math.max(images.length, 1)]; - const divisor = scenario === "huge" ? 2 + Math.pow(index + 1, 0.58) : index + 3; + const divisor = + scenario === "extreme" + ? 1 + Math.pow(index + 1, 0.42) + : scenario === "huge" + ? 2 + Math.pow(index + 1, 0.58) + : index + 3; + const sourceCycle = index % 3; return { tag, - count: Math.max(1, Math.round(images.length / divisor)), + count: + scenario === "extreme" + ? Math.max(10_000, Math.min(100_000, Math.round(200_000 / divisor))) + : Math.max(1, Math.round(images.length / divisor)), representative_image_id: representative?.id ?? index + 1, thumbnail_path: representative?.thumbnail_path ?? null, + has_ai_source: sourceCycle !== 1, + has_user_source: sourceCycle !== 0, }; }).sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag)); } diff --git a/src/dev/mockScenarios.ts b/src/dev/mockScenarios.ts index b78a016..db7d36f 100644 --- a/src/dev/mockScenarios.ts +++ b/src/dev/mockScenarios.ts @@ -1,4 +1,4 @@ -export type MockScenario = "rich" | "empty" | "busy" | "duplicates" | "album" | "errors" | "huge"; +export type MockScenario = "rich" | "empty" | "busy" | "duplicates" | "album" | "errors" | "huge" | "extreme"; const SCENARIOS = new Set([ "rich", @@ -8,6 +8,7 @@ const SCENARIOS = new Set([ "album", "errors", "huge", + "extreme", ]); export function getMockScenario(): MockScenario { diff --git a/src/index.css b/src/index.css index c353603..4a8c44c 100644 --- a/src/index.css +++ b/src/index.css @@ -259,6 +259,33 @@ html[data-theme="subtle-light"] .tag-manager-filter:focus { border-color: #7aa2e3 !important; } +html[data-theme="subtle-light"] .tag-manager-reset-button, +html[data-theme="subtle-light"] .tag-manager-reset-cancel { + background: #ebe8df !important; + border-color: #c9c1b4 !important; + color: #5f5a52 !important; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.58) !important; +} + +html[data-theme="subtle-light"] .tag-manager-reset-button:hover, +html[data-theme="subtle-light"] .tag-manager-reset-cancel:hover { + background: #e2ded3 !important; + border-color: #b9b0a2 !important; + color: #24201b !important; +} + +html[data-theme="subtle-light"] .tag-manager-reset-button-confirm { + background: rgb(254 242 242 / 0.95) !important; + border-color: rgb(220 38 38 / 0.34) !important; + color: #991b1b !important; +} + +html[data-theme="subtle-light"] .tag-manager-reset-button-confirm:hover { + background: #fee2e2 !important; + border-color: rgb(220 38 38 / 0.48) !important; + color: #7f1d1d !important; +} + html[data-theme="subtle-light"] .tag-manager-clear { color: #6b645a !important; } @@ -292,6 +319,20 @@ html[data-theme="subtle-light"] .tag-manager-count { color: #6b645a !important; } +html[data-theme="subtle-light"] .tag-manager-tile-ai { + box-shadow: inset 0 1px 0 rgb(14 116 144 / 0.08) !important; +} + +html[data-theme="subtle-light"] .tag-manager-count-ai { + background: rgb(14 116 144 / 0.1) !important; + border-color: rgb(14 116 144 / 0.18) !important; + color: #0d5f6f !important; +} + +html[data-theme="subtle-light"] .tag-manager-ai-glyph { + color: #0d5f6f !important; +} + html[data-theme="subtle-light"] .tag-manager-edit-input { background: #fffdfa !important; color: #111827 !important; diff --git a/src/store.ts b/src/store.ts index f97cd6a..36ae2da 100644 --- a/src/store.ts +++ b/src/store.ts @@ -215,6 +215,8 @@ export interface ExploreTagEntry { count: number; representative_image_id: number; thumbnail_path: string | null; + has_ai_source: boolean; + has_user_source: boolean; } export interface RelatedTagEntry { @@ -585,6 +587,8 @@ interface GalleryState { queueTaggingForImage: (imageId: number) => Promise; clearTaggingJobs: (folderId?: number | null) => Promise; clearTaggingJobsForFolders: (folderIds: number[]) => Promise; + resetAiTags: (folderId?: number | null) => Promise; + resetAiTagsForFolders: (folderIds: number[]) => Promise; loadDuplicateScanCache: (folderId?: number | null) => Promise; scanDuplicates: (folderId?: number | null) => Promise; toggleDuplicateSelected: (imageId: number) => void; @@ -2383,6 +2387,26 @@ export const useGalleryStore = create((set, get) => ({ return cleared; }, + resetAiTags: async (folderId = get().selectedFolderId) => { + const reset = await invoke("reset_ai_tags", { + params: { folder_id: folderId ?? null, folder_ids: null }, + }); + set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); + await get().loadBackgroundJobProgress(); + await get().loadImages(true); + return reset; + }, + + resetAiTagsForFolders: async (folderIds) => { + const reset = await invoke("reset_ai_tags", { + params: { folder_id: null, folder_ids: folderIds }, + }); + set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); + await get().loadBackgroundJobProgress(); + await get().loadImages(true); + return reset; + }, + getImageTags: async (imageId) => { return invoke("get_image_tags", { params: { image_id: imageId },