Merge: rename 'tag cloud' to 'visual clusters' across the stack
github/actions/ci GitHub Actions CI finished: success

Disambiguate the Explore feature naming: the visual k-means cluster view was
named tag_cloud/tagCloud/TagCloud everywhere while the actual tag list is
explore_tags, which was easy to confuse. The cluster side is now
visual_cluster/visualCluster/VisualCluster (command, types, store, DB cache
table, and the ExploreView component); the tags side and the user-facing
"Tag Cloud" label are unchanged. Includes a mock-fixture fix so the dev
"huge" scenario surfaces a realistic field of clusters.
This commit is contained in:
2026-06-30 10:01:25 +01:00
9 changed files with 98 additions and 84 deletions
+11 -11
View File
@@ -1161,7 +1161,7 @@ pub async fn reset_generated_captions(
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct TagCloudEntry { pub struct VisualClusterEntry {
pub count: usize, pub count: usize,
pub representative_image_id: i64, pub representative_image_id: i64,
pub thumbnail_path: Option<String>, pub thumbnail_path: Option<String>,
@@ -1174,10 +1174,10 @@ pub struct TagCloudEntry {
/// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated /// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated
/// calls (including across app restarts) return instantly when the library hasn't changed. /// calls (including across app restarts) return instantly when the library hasn't changed.
#[tauri::command] #[tauri::command]
pub async fn get_tag_cloud( pub async fn get_visual_clusters(
db: State<'_, DbState>, db: State<'_, DbState>,
folder_id: Option<i64>, folder_id: Option<i64>,
) -> Result<Vec<TagCloudEntry>, String> { ) -> Result<Vec<VisualClusterEntry>, String> {
let folder_scope = match folder_id { let folder_scope = match folder_id {
Some(id) => format!("folder_{id}"), Some(id) => format!("folder_{id}"),
None => "all".to_string(), None => "all".to_string(),
@@ -1214,10 +1214,10 @@ pub async fn get_tag_cloud(
// Try to return a valid SQLite cache before loading any embeddings. // Try to return a valid SQLite cache before loading any embeddings.
{ {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
if let Some(json) = db::get_tag_cloud_cache(&conn, &folder_scope, current_hash) if let Some(json) = db::get_visual_cluster_cache(&conn, &folder_scope, current_hash)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
{ {
if let Ok(entries) = serde_json::from_str::<Vec<TagCloudEntry>>(&json) { if let Ok(entries) = serde_json::from_str::<Vec<VisualClusterEntry>>(&json) {
// Reject cache entries written before image_ids were tracked — they all // Reject cache entries written before image_ids were tracked — they all
// have empty image_ids which causes "No media found" when a cluster is opened. // have empty image_ids which causes "No media found" when a cluster is opened.
if entries.iter().all(|e| !e.image_ids.is_empty()) { if entries.iter().all(|e| !e.image_ids.is_empty()) {
@@ -1247,12 +1247,12 @@ pub async fn get_tag_cloud(
let kmeans_start = std::time::Instant::now(); let kmeans_start = std::time::Instant::now();
let (centroids, cluster_counts, assignments) = kmeans_cosine(&points, k, 40); let (centroids, cluster_counts, assignments) = kmeans_cosine(&points, k, 40);
log::debug!( log::debug!(
"tag-cloud clustering: {n} embeddings, k={k}, sampled={} → {:.2?}", "visual-cluster clustering: {n} embeddings, k={k}, sampled={} → {:.2?}",
n > MAX_KMEANS_SAMPLE, n > MAX_KMEANS_SAMPLE,
kmeans_start.elapsed(), kmeans_start.elapsed(),
); );
let mut entries: Vec<TagCloudEntry> = Vec::new(); let mut entries: Vec<VisualClusterEntry> = Vec::new();
let mut order: Vec<usize> = (0..k).collect(); let mut order: Vec<usize> = (0..k).collect();
order.sort_unstable_by(|&a, &b| cluster_counts[b].cmp(&cluster_counts[a])); order.sort_unstable_by(|&a, &b| cluster_counts[b].cmp(&cluster_counts[a]));
@@ -1285,7 +1285,7 @@ pub async fn get_tag_cloud(
.ok() .ok()
.and_then(|img| img.thumbnail_path); .and_then(|img| img.thumbnail_path);
entries.push(TagCloudEntry { entries.push(VisualClusterEntry {
count, count,
representative_image_id: best_id, representative_image_id: best_id,
thumbnail_path, thumbnail_path,
@@ -1296,8 +1296,8 @@ pub async fn get_tag_cloud(
// Persist to SQLite. The cache is best-effort, but a silent write failure here // Persist to SQLite. The cache is best-effort, but a silent write failure here
// would defeat the whole optimization (every visit would recompute), so log it. // would defeat the whole optimization (every visit would recompute), so log it.
if let Ok(json) = serde_json::to_string(&entries) { if let Ok(json) = serde_json::to_string(&entries) {
if let Err(e) = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json) { if let Err(e) = db::set_visual_cluster_cache(&conn, &folder_scope, current_hash, &json) {
log::warn!("failed to persist tag-cloud cache for {folder_scope}: {e}"); log::warn!("failed to persist visual-cluster cache for {folder_scope}: {e}");
} }
} }
@@ -1861,7 +1861,7 @@ fn normalize(v: &mut [f32]) {
/// large libraries while the full assignment still places every image. /// large libraries while the full assignment still places every image.
const MAX_KMEANS_SAMPLE: usize = 3000; const MAX_KMEANS_SAMPLE: usize = 3000;
/// Bumped whenever the clustering algorithm changes so persisted tag-cloud caches /// Bumped whenever the clustering algorithm changes so persisted visual-cluster caches
/// computed by an older algorithm are invalidated and recomputed on next view. /// computed by an older algorithm are invalidated and recomputed on next view.
const CLUSTER_CACHE_VERSION: &str = "sampled-v2"; const CLUSTER_CACHE_VERSION: &str = "sampled-v2";
+15 -11
View File
@@ -267,12 +267,16 @@ pub fn migrate(conn: &Connection) -> Result<()> {
UNIQUE(image_id, tag) UNIQUE(image_id, tag)
); );
CREATE TABLE IF NOT EXISTS tag_cloud_cache ( CREATE TABLE IF NOT EXISTS visual_cluster_cache (
folder_scope TEXT PRIMARY KEY, folder_scope TEXT PRIMARY KEY,
image_ids_hash INTEGER NOT NULL, image_ids_hash INTEGER NOT NULL,
entries_json TEXT NOT NULL, entries_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- Renamed from the misnamed tag_cloud_cache (it caches visual clusters,
-- not tags). The cache is disposable and was already invalidated by a
-- cluster-algorithm version bump, so the old table is simply dropped.
DROP TABLE IF EXISTS tag_cloud_cache;
CREATE TABLE IF NOT EXISTS duplicate_scan_cache ( CREATE TABLE IF NOT EXISTS duplicate_scan_cache (
folder_scope TEXT PRIMARY KEY, folder_scope TEXT PRIMARY KEY,
@@ -407,7 +411,7 @@ fn remove_filtered_ai_tags(conn: &Connection) -> Result<()> {
delete_stmt.execute([id])?; delete_stmt.execute([id])?;
} }
} }
tx.execute("DELETE FROM tag_cloud_cache", [])?; tx.execute("DELETE FROM visual_cluster_cache", [])?;
tx.commit()?; tx.commit()?;
log::info!( log::info!(
@@ -2799,9 +2803,9 @@ pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
)?; )?;
// …then drop the now-duplicate leftovers still under the old name. // …then drop the now-duplicate leftovers still under the old name.
tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?; tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?;
// The tag-cloud cache keys on image-id hashes, not tag text, so a rename // The visual-cluster cache keys on image-id hashes, not tag text, so a rename
// wouldn't invalidate it automatically — clear it. // wouldn't invalidate it automatically — clear it.
tx.execute("DELETE FROM tag_cloud_cache", [])?; tx.execute("DELETE FROM visual_cluster_cache", [])?;
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
@@ -2810,7 +2814,7 @@ pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> { pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64; let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64;
tx.execute("DELETE FROM tag_cloud_cache", [])?; tx.execute("DELETE FROM visual_cluster_cache", [])?;
tx.commit()?; tx.commit()?;
Ok(removed) Ok(removed)
} }
@@ -2983,14 +2987,14 @@ fn map_image_row(row: &Row<'_>) -> rusqlite::Result<ImageRecord> {
}) })
} }
/// Returns cached tag-cloud entries if the stored hash matches `current_hash`. /// Returns cached visual-cluster entries if the stored hash matches `current_hash`.
pub fn get_tag_cloud_cache( pub fn get_visual_cluster_cache(
conn: &Connection, conn: &Connection,
folder_scope: &str, folder_scope: &str,
current_hash: u64, current_hash: u64,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
let result: rusqlite::Result<(i64, String)> = conn.query_row( let result: rusqlite::Result<(i64, String)> = conn.query_row(
"SELECT image_ids_hash, entries_json FROM tag_cloud_cache WHERE folder_scope = ?1", "SELECT image_ids_hash, entries_json FROM visual_cluster_cache WHERE folder_scope = ?1",
params![folder_scope], params![folder_scope],
|row| Ok((row.get(0)?, row.get(1)?)), |row| Ok((row.get(0)?, row.get(1)?)),
); );
@@ -3002,15 +3006,15 @@ pub fn get_tag_cloud_cache(
} }
} }
/// Upserts the tag-cloud cache for the given scope. /// Upserts the visual-cluster cache for the given scope.
pub fn set_tag_cloud_cache( pub fn set_visual_cluster_cache(
conn: &Connection, conn: &Connection,
folder_scope: &str, folder_scope: &str,
image_ids_hash: u64, image_ids_hash: u64,
entries_json: &str, entries_json: &str,
) -> Result<()> { ) -> Result<()> {
conn.execute( conn.execute(
"INSERT INTO tag_cloud_cache (folder_scope, image_ids_hash, entries_json, created_at) "INSERT INTO visual_cluster_cache (folder_scope, image_ids_hash, entries_json, created_at)
VALUES (?1, ?2, ?3, datetime('now')) VALUES (?1, ?2, ?3, datetime('now'))
ON CONFLICT(folder_scope) DO UPDATE SET ON CONFLICT(folder_scope) DO UPDATE SET
image_ids_hash = excluded.image_ids_hash, image_ids_hash = excluded.image_ids_hash,
+1 -1
View File
@@ -198,7 +198,7 @@ pub fn run() {
commands::get_worker_states, commands::get_worker_states,
commands::get_worker_pauses_persist, commands::get_worker_pauses_persist,
commands::set_worker_pauses_persist, commands::set_worker_pauses_persist,
commands::get_tag_cloud, commands::get_visual_clusters,
commands::get_explore_tags, commands::get_explore_tags,
commands::get_related_tags, commands::get_related_tags,
commands::get_images_by_ids, commands::get_images_by_ids,
+1 -1
View File
@@ -268,7 +268,7 @@ pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
/// exact set of IDs, so it is membership-sensitive: adding, removing, or moving an /// exact set of IDs, so it is membership-sensitive: adding, removing, or moving an
/// image between folders changes it even when the count happens to stay the same. /// image between folders changes it even when the count happens to stay the same.
/// Used (together with the embedding revision, which catches an image being /// Used (together with the embedding revision, which catches an image being
/// re-embedded in place) as the cheap tag-cloud cache key so a cache hit doesn't /// re-embedded in place) as the cheap visual-cluster cache key so a cache hit doesn't
/// have to read and unpack hundreds of MB of embeddings just to validate freshness. /// have to read and unpack hundreds of MB of embeddings just to validate freshness.
pub fn embedding_ids_signature(conn: &Connection, folder_id: Option<i64>) -> Result<(i64, u64)> { pub fn embedding_ids_signature(conn: &Connection, folder_id: Option<i64>) -> Result<(i64, u64)> {
use xxhash_rust::xxh3::Xxh3; use xxhash_rust::xxh3::Xxh3;
+2 -2
View File
@@ -5,7 +5,7 @@ import { BackgroundTasks } from "./components/BackgroundTasks";
import { Toolbar } from "./components/Toolbar"; import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery"; import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox"; import { Lightbox } from "./components/Lightbox";
import { TagCloud } from "./components/TagCloud"; import { ExploreView } from "./components/ExploreView";
import { DuplicateFinder } from "./components/DuplicateFinder"; import { DuplicateFinder } from "./components/DuplicateFinder";
import { Timeline } from "./components/Timeline"; import { Timeline } from "./components/Timeline";
import { TitleBar } from "./components/TitleBar"; import { TitleBar } from "./components/TitleBar";
@@ -88,7 +88,7 @@ export default function App() {
) : activeView === "explore" ? ( ) : activeView === "explore" ? (
<> <>
<BackgroundTasks /> <BackgroundTasks />
<TagCloud /> <ExploreView />
</> </>
) : activeView === "duplicates" ? ( ) : activeView === "duplicates" ? (
<> <>
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { ExploreMode, ExploreTagEntry, RelatedTagEntry, VisualClusterEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ThemedDropdown } from "./ThemedDropdown"; import { ThemedDropdown } from "./ThemedDropdown";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
@@ -43,7 +43,7 @@ function seeded(n: number): number {
} }
interface PlacedNode { interface PlacedNode {
entry: TagCloudEntry; entry: VisualClusterEntry;
index: number; index: number;
x: number; x: number;
y: number; y: number;
@@ -57,7 +57,7 @@ interface PlacedNode {
rotateSeed: number; rotateSeed: number;
} }
function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: number): PlacedNode[] { function buildCloud(entries: VisualClusterEntry[], containerW: number, containerH: number): PlacedNode[] {
if (!entries.length || containerW <= 0 || containerH <= 0) return []; if (!entries.length || containerW <= 0 || containerH <= 0) return [];
const maxCount = Math.max(...entries.map((e) => e.count)); const maxCount = Math.max(...entries.map((e) => e.count));
@@ -622,13 +622,13 @@ function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) {
} }
// Separate component so its useLayoutEffect fires when the canvas is actually // Separate component so its useLayoutEffect fires when the canvas is actually
// mounted — not at TagCloud mount time when the container may still be hidden // mounted — not at ExploreView mount time when the container may still be hidden
// behind a loading state. // behind a loading state.
function ClusterCloud({ function ClusterCloud({
entries, entries,
onOpen, onOpen,
}: { }: {
entries: TagCloudEntry[]; entries: VisualClusterEntry[];
onOpen: (imageIds: number[]) => void; onOpen: (imageIds: number[]) => void;
}) { }) {
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
@@ -968,12 +968,12 @@ function TagManageList({
); );
} }
export function TagCloud() { export function ExploreView() {
const exploreMode = useGalleryStore((state) => state.exploreMode); const exploreMode = useGalleryStore((state) => state.exploreMode);
const setExploreMode = useGalleryStore((state) => state.setExploreMode); const setExploreMode = useGalleryStore((state) => state.setExploreMode);
const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries); const visualClusterEntries = useGalleryStore((state) => state.visualClusterEntries);
const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading); const visualClusterLoading = useGalleryStore((state) => state.visualClusterLoading);
const loadTagCloud = useGalleryStore((state) => state.loadTagCloud); const loadVisualClusters = useGalleryStore((state) => state.loadVisualClusters);
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries); const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading); const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags); const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
@@ -989,13 +989,13 @@ export function TagCloud() {
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); }; const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
useEffect(() => { useEffect(() => {
if (exploreMode === "visual") void loadTagCloud(); if (exploreMode === "visual") void loadVisualClusters();
else void loadExploreTags(); else void loadExploreTags();
}, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]); }, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags]);
const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading; const loading = exploreMode === "visual" ? visualClusterLoading : exploreTagLoading;
const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0; const hasEntries = exploreMode === "visual" ? visualClusterEntries.length > 0 : exploreTagEntries.length > 0;
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length; const entryCount = exploreMode === "visual" ? visualClusterEntries.length : exploreTagEntries.length;
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE); const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE);
return ( return (
@@ -1070,7 +1070,7 @@ export function TagCloud() {
</div> </div>
) : exploreMode === "visual" ? ( ) : exploreMode === "visual" ? (
<div className="relative flex min-h-0 flex-1 flex-col"> <div className="relative flex min-h-0 flex-1 flex-col">
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} /> <ClusterCloud entries={visualClusterEntries} onOpen={showVisualCluster} />
</div> </div>
) : manageTags ? ( ) : manageTags ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
+2 -2
View File
@@ -250,8 +250,8 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
case "find_similar_images": case "find_similar_images":
case "find_similar_by_region": case "find_similar_by_region":
return similarImages(payload); return similarImages(payload);
case "get_tag_cloud": case "get_visual_clusters":
return db.scenario === "empty" ? [] : db.tagCloud; return db.scenario === "empty" ? [] : db.visualClusters;
case "get_explore_tags": case "get_explore_tags":
return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180)); return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180));
case "get_related_tags": { case "get_related_tags": {
+17 -7
View File
@@ -9,7 +9,7 @@ import type {
ImageRecord, ImageRecord,
ImageTag, ImageTag,
SortOrder, SortOrder,
TagCloudEntry, VisualClusterEntry,
} from "../store"; } from "../store";
import type { MockScenario } from "./mockScenarios"; import type { MockScenario } from "./mockScenarios";
import { fixtureMediaPath } from "./mockMedia"; import { fixtureMediaPath } from "./mockMedia";
@@ -21,7 +21,7 @@ export interface MockDb {
albums: Album[]; albums: Album[];
albumImageIds: Record<number, number[]>; albumImageIds: Record<number, number[]>;
tagsByImageId: Record<number, ImageTag[]>; tagsByImageId: Record<number, ImageTag[]>;
tagCloud: TagCloudEntry[]; visualClusters: VisualClusterEntry[];
exploreTags: ExploreTagEntry[]; exploreTags: ExploreTagEntry[];
backgroundJobs: FolderJobProgress[]; backgroundJobs: FolderJobProgress[];
duplicateGroups: DuplicateGroup[]; duplicateGroups: DuplicateGroup[];
@@ -252,12 +252,22 @@ function makeProgress(folders: Folder[], scenario: MockScenario): FolderJobProgr
})); }));
} }
function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] { function makeVisualClusters(images: ImageRecord[], scenario: MockScenario): VisualClusterEntry[] {
return tags.slice(0, 10).map((_, index) => { if (images.length < 5) return [];
// Mirror the backend's k = (n / 20).clamp(5, 30). Force the full spread for the
// "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;
// 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);
return weights.map((weight, index) => {
const group = images.filter((image) => image.id % (index + 2) === 0).slice(0, 28); const group = images.filter((image) => image.id % (index + 2) === 0).slice(0, 28);
const representative = group[0] ?? images[index]; const representative = group[0] ?? images[index % images.length];
return { return {
count: Math.max(group.length, 1), count: Math.max(1, Math.round((virtualTotal * weight) / weightSum)),
representative_image_id: representative?.id ?? index + 1, representative_image_id: representative?.id ?? index + 1,
thumbnail_path: representative?.thumbnail_path ?? null, thumbnail_path: representative?.thumbnail_path ?? null,
image_ids: group.length ? group.map((image) => image.id) : [representative?.id ?? 1], image_ids: group.length ? group.map((image) => image.id) : [representative?.id ?? 1],
@@ -305,7 +315,7 @@ export function createMockDb(scenario: MockScenario): MockDb {
albums, albums,
albumImageIds, albumImageIds,
tagsByImageId: makeTags(images, scenario), tagsByImageId: makeTags(images, scenario),
tagCloud: makeTagCloud(images), visualClusters: makeVisualClusters(images, scenario),
exploreTags: makeExploreTags(images, scenario), exploreTags: makeExploreTags(images, scenario),
backgroundJobs: makeProgress(folders, scenario), backgroundJobs: makeProgress(folders, scenario),
duplicateGroups: makeDuplicateGroups(images, scenario), duplicateGroups: makeDuplicateGroups(images, scenario),
+34 -34
View File
@@ -203,7 +203,7 @@ export interface ImageExif {
gps_lon: number | null; gps_lon: number | null;
} }
export interface TagCloudEntry { export interface VisualClusterEntry {
count: number; count: number;
representative_image_id: number; representative_image_id: number;
thumbnail_path: string | null; thumbnail_path: string | null;
@@ -376,9 +376,9 @@ interface GalleryState {
activeView: ActiveView; activeView: ActiveView;
exploreMode: ExploreMode; exploreMode: ExploreMode;
tagManagerOpen: boolean; tagManagerOpen: boolean;
tagCloudEntries: TagCloudEntry[]; visualClusterEntries: VisualClusterEntry[];
tagCloudLoading: boolean; visualClusterLoading: boolean;
tagCloudFolderId: number | null | undefined; // undefined = never loaded visualClusterFolderId: number | null | undefined; // undefined = never loaded
exploreTagEntries: ExploreTagEntry[]; exploreTagEntries: ExploreTagEntry[];
exploreTagLoading: boolean; exploreTagLoading: boolean;
// Cache-freshness key: the folder the loaded tags belong to. Set to undefined // Cache-freshness key: the folder the loaded tags belong to. Set to undefined
@@ -497,7 +497,7 @@ interface GalleryState {
setExploreMode: (mode: ExploreMode) => void; setExploreMode: (mode: ExploreMode) => void;
setTagManagerOpen: (open: boolean) => void; setTagManagerOpen: (open: boolean) => void;
openTagManager: () => void; openTagManager: () => void;
loadTagCloud: (options?: { force?: boolean }) => Promise<void>; loadVisualClusters: (options?: { force?: boolean }) => Promise<void>;
loadExploreTags: (options?: { force?: boolean }) => Promise<void>; loadExploreTags: (options?: { force?: boolean }) => Promise<void>;
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>; loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
showVisualCluster: (imageIds: number[]) => Promise<void>; showVisualCluster: (imageIds: number[]) => Promise<void>;
@@ -633,7 +633,7 @@ const SIMILAR_DISTANCE_THRESHOLD = 0.24;
// similarity, region search). Any new request increments it so a stale response // similarity, region search). Any new request increments it so a stale response
// from a previous collection type cannot overwrite newer results. // from a previous collection type cannot overwrite newer results.
let galleryRequestToken = 0; let galleryRequestToken = 0;
let tagCloudRequestToken = 0; let visualClusterRequestToken = 0;
let exploreTagRequestToken = 0; let exploreTagRequestToken = 0;
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null; let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
@@ -878,9 +878,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
activeView: "gallery", activeView: "gallery",
exploreMode: "visual", exploreMode: "visual",
tagManagerOpen: false, tagManagerOpen: false,
tagCloudEntries: [], visualClusterEntries: [],
tagCloudLoading: false, visualClusterLoading: false,
tagCloudFolderId: undefined, visualClusterFolderId: undefined,
exploreTagEntries: [], exploreTagEntries: [],
exploreTagLoading: false, exploreTagLoading: false,
exploreTagsFolderId: undefined, exploreTagsFolderId: undefined,
@@ -1010,7 +1010,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
await loadFolders(); await loadFolders();
await loadBackgroundJobProgress(); await loadBackgroundJobProgress();
// Invalidate tag cloud and explore-tags cache since library content changed. // Invalidate tag cloud and explore-tags cache since library content changed.
set({ tagCloudFolderId: undefined, tagCloudEntries: [], exploreTagsFolderId: undefined }); set({ visualClusterFolderId: undefined, visualClusterEntries: [], exploreTagsFolderId: undefined });
// Always refresh the gallery: the removed folder's images may be on screen // Always refresh the gallery: the removed folder's images may be on screen
// (e.g. in All Media), not only when that folder was the active selection. // (e.g. in All Media), not only when that folder was the active selection.
await loadImages(true); await loadImages(true);
@@ -1021,7 +1021,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
await invoke("reindex_folder", { folderId }); await invoke("reindex_folder", { folderId });
await loadFolders(); await loadFolders();
// Invalidate tag cloud cache since embeddings will be regenerated // Invalidate tag cloud cache since embeddings will be regenerated
set({ tagCloudFolderId: undefined, tagCloudEntries: [] }); set({ visualClusterFolderId: undefined, visualClusterEntries: [] });
await loadBackgroundJobProgress(); await loadBackgroundJobProgress();
}, },
@@ -1085,7 +1085,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return; return;
} }
// Explore reloads itself via TagCloud's useEffect on selectedFolderId. // Explore reloads itself via ExploreView's useEffect on selectedFolderId.
if (activeView === "explore") return; if (activeView === "explore") return;
void get().loadImages(true); void get().loadImages(true);
@@ -1425,33 +1425,33 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
set({ exploreMode: "tags", tagManagerOpen: true, settingsOpen: false }); set({ exploreMode: "tags", tagManagerOpen: true, settingsOpen: false });
}, },
loadTagCloud: async (options) => { loadVisualClusters: async (options) => {
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get(); const { selectedFolderId, visualClusterFolderId, visualClusterLoading } = get();
const force = options?.force ?? false; const force = options?.force ?? false;
// Skip if already loaded for this folder and not currently loading // Skip if already loaded for this folder and not currently loading
if (!force && !tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) { if (!force && !visualClusterLoading && visualClusterFolderId !== undefined && visualClusterFolderId === selectedFolderId) {
return; return;
} }
const requestToken = ++tagCloudRequestToken; const requestToken = ++visualClusterRequestToken;
// On a real folder switch, drop the previous folder's clusters so the loading // On a real folder switch, drop the previous folder's clusters so the loading
// panel shows instead of lingering stale results. A same-folder refresh keeps // panel shows instead of lingering stale results. A same-folder refresh keeps
// them to avoid a flicker when the cache returns instantly. // them to avoid a flicker when the cache returns instantly.
const isFolderSwitch = tagCloudFolderId !== selectedFolderId; const isFolderSwitch = visualClusterFolderId !== selectedFolderId;
set({ set({
tagCloudLoading: true, visualClusterLoading: true,
tagCloudFolderId: selectedFolderId, visualClusterFolderId: selectedFolderId,
...(isFolderSwitch ? { tagCloudEntries: [] } : {}), ...(isFolderSwitch ? { visualClusterEntries: [] } : {}),
}); });
try { try {
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", { const entries = await invoke<VisualClusterEntry[]>("get_visual_clusters", {
folderId: selectedFolderId, folderId: selectedFolderId,
}); });
if (requestToken !== tagCloudRequestToken) return; if (requestToken !== visualClusterRequestToken) return;
set({ tagCloudEntries: entries, tagCloudLoading: false }); set({ visualClusterEntries: entries, visualClusterLoading: false });
} catch (error) { } catch (error) {
if (requestToken !== tagCloudRequestToken) return; if (requestToken !== visualClusterRequestToken) return;
console.error("Failed to load tag cloud:", error); console.error("Failed to load tag cloud:", error);
set({ tagCloudLoading: false }); set({ visualClusterLoading: false });
} }
}, },
@@ -2411,13 +2411,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
renameTag: async (from, to) => { renameTag: async (from, to) => {
await invoke("rename_tag", { params: { from, to } }); await invoke("rename_tag", { params: { from, to } });
// Tag content changed — invalidate the explore-tags and tag-cloud caches. // Tag content changed — invalidate the explore-tags and visual-cluster caches.
// Keep the current tag list visible while the refresh runs so manager UI // Keep the current tag list visible while the refresh runs so manager UI
// state such as filtering and sorting is not lost to a loading remount. // state such as filtering and sorting is not lost to a loading remount.
set({ set({
exploreTagsFolderId: undefined, exploreTagsFolderId: undefined,
tagCloudFolderId: undefined, visualClusterFolderId: undefined,
tagCloudEntries: [], visualClusterEntries: [],
}); });
const parsed = parseSearchValue(get().search); const parsed = parseSearchValue(get().search);
if (parsed.mode === "tag" && parsed.query === from) { if (parsed.mode === "tag" && parsed.query === from) {
@@ -2435,8 +2435,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
// state such as filtering and sorting is not lost to a loading remount. // state such as filtering and sorting is not lost to a loading remount.
set({ set({
exploreTagsFolderId: undefined, exploreTagsFolderId: undefined,
tagCloudFolderId: undefined, visualClusterFolderId: undefined,
tagCloudEntries: [], visualClusterEntries: [],
}); });
const parsed = parseSearchValue(get().search); const parsed = parseSearchValue(get().search);
if (parsed.mode === "tag" && parsed.query === tag) { if (parsed.mode === "tag" && parsed.query === tag) {
@@ -2507,14 +2507,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
if (ids.length === 0 || cleaned.length === 0) return; if (ids.length === 0 || cleaned.length === 0) return;
await invoke<void>("bulk_add_tags", { params: { image_ids: ids, tags: cleaned } }); await invoke<void>("bulk_add_tags", { params: { image_ids: ids, tags: cleaned } });
// New tags landed — invalidate Explore tag caches. // New tags landed — invalidate Explore tag caches.
set({ exploreTagsFolderId: undefined, tagCloudFolderId: undefined, tagCloudEntries: [] }); set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] });
}, },
bulkRemoveTag: async (tag) => { bulkRemoveTag: async (tag) => {
const ids = Array.from(get().gallerySelectedIds); const ids = Array.from(get().gallerySelectedIds);
if (ids.length === 0 || !tag.trim()) return; if (ids.length === 0 || !tag.trim()) return;
await invoke<void>("bulk_remove_tag", { params: { image_ids: ids, tag: tag.trim() } }); await invoke<void>("bulk_remove_tag", { params: { image_ids: ids, tag: tag.trim() } });
set({ exploreTagsFolderId: undefined, tagCloudFolderId: undefined, tagCloudEntries: [] }); set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] });
}, },
bulkDeleteSelected: async () => { bulkDeleteSelected: async () => {
@@ -2532,8 +2532,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
totalImages: Math.max(0, state.totalImages - succeededIds.length), totalImages: Math.max(0, state.totalImages - succeededIds.length),
gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !succeededSet.has(id))), gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !succeededSet.has(id))),
// Deletion changes tag/duplicate/album aggregates. // Deletion changes tag/duplicate/album aggregates.
tagCloudFolderId: undefined, visualClusterFolderId: undefined,
tagCloudEntries: [], visualClusterEntries: [],
exploreTagsFolderId: undefined, exploreTagsFolderId: undefined,
})); }));
// The DB cascade already removed these from album_images; refresh counts/covers. // The DB cascade already removed these from album_images; refresh counts/covers.