refactor(explore): rename misnamed "tag cloud" to "visual clusters"
The visual k-means cluster feature was confusingly named tag_cloud / tagCloud / TagCloud across the whole stack, while the actual tag list is explore_tags — the two were trivially easy to mix up (and did cause confusion). Rename the cluster side to visual_cluster / visualCluster / VisualCluster everywhere: command get_tag_cloud -> get_visual_clusters (+ lib.rs registration and the invoke string), VisualClusterEntry, the store fields/actions/tokens, and the mock backend. Old names are retired rather than reused, so any missed reference fails loudly instead of silently resolving to the wrong concept. The tags side keeps its accurate explore_tags naming, and the user-facing "Tag Cloud" UI label is unchanged. Also rename the SQLite tag_cloud_cache table -> visual_cluster_cache (the old table is dropped during schema setup — it is a disposable cache already invalidated by the clustering version bump) and the TagCloud.tsx component file -> ExploreView.tsx, since it is the Explore container hosting both the cluster and tag views.
This commit is contained in:
+11
-11
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,7 +252,7 @@ function makeProgress(folders: Folder[], scenario: MockScenario): FolderJobProgr
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] {
|
function makeVisualClusters(images: ImageRecord[]): VisualClusterEntry[] {
|
||||||
return tags.slice(0, 10).map((_, index) => {
|
return tags.slice(0, 10).map((_, 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];
|
||||||
@@ -305,7 +305,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),
|
||||||
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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user