Compare commits
3 Commits
d5b93b2e21
...
29d9106039
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d9106039 | |||
| a78111c8d4 | |||
| 4cdbc54d18 |
@@ -2179,6 +2179,12 @@ pub struct ClearTaggingJobsParams {
|
||||
pub folder_ids: Option<Vec<i64>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetAiTagsParams {
|
||||
pub folder_id: Option<i64>,
|
||||
pub folder_ids: Option<Vec<i64>>,
|
||||
}
|
||||
|
||||
#[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<usize, String> {
|
||||
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<i64>) =
|
||||
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>,
|
||||
|
||||
+116
-2
@@ -146,6 +146,8 @@ pub struct ExploreTagEntry {
|
||||
pub count: i64,
|
||||
pub representative_image_id: i64,
|
||||
pub thumbnail_path: Option<String>,
|
||||
pub has_ai_source: bool,
|
||||
pub has_user_source: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -2341,7 +2343,9 @@ pub fn search_tags_autocomplete(
|
||||
) -> Result<Vec<ExploreTagEntry>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
@@ -2440,7 +2446,9 @@ pub fn get_explore_tags(
|
||||
limit: usize,
|
||||
) -> Result<Vec<ExploreTagEntry>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
@@ -2848,6 +2858,110 @@ pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub fn reset_ai_tags(conn: &Connection, folder_id: Option<i64>) -> Result<usize> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?
|
||||
}
|
||||
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::<rusqlite::Result<Vec<_>>>()?
|
||||
}
|
||||
};
|
||||
|
||||
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<usize> {
|
||||
let inserted = conn.execute(
|
||||
"INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -678,6 +678,51 @@ const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [
|
||||
{ value: "za", label: "Z-A" },
|
||||
];
|
||||
|
||||
function AiSourceGlyph({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8 2.75l.82 2.42a2.1 2.1 0 0 0 1.32 1.32l2.41.81-2.41.82a2.1 2.1 0 0 0-1.32 1.32L8 11.85l-.82-2.41a2.1 2.1 0 0 0-1.32-1.32L3.45 7.3l2.41-.81a2.1 2.1 0 0 0 1.32-1.32L8 2.75Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.35"
|
||||
/>
|
||||
<path d="M12.25 11.15l.25.74.75.26-.75.25-.25.75-.26-.75-.74-.25.74-.26.26-.74Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RenameGlyph({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3.25 11.85l.45-2.14 5.95-5.95a1.33 1.33 0 0 1 1.88 0l.71.71a1.33 1.33 0 0 1 0 1.88L6.29 12.3l-2.14.45a.76.76 0 0 1-.9-.9Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.35"
|
||||
/>
|
||||
<path d="M8.75 4.65l2.6 2.6" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteGlyph({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M3.25 4.65h9.5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
|
||||
<path d="M6.35 4.55V3.7c0-.55.45-1 1-1h1.3c.55 0 1 .45 1 1v.85" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
|
||||
<path
|
||||
d="M5.1 6.2l.35 5.65c.04.63.56 1.12 1.19 1.12h2.72c.63 0 1.15-.49 1.19-1.12l.35-5.65"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.35"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
data-tag-manager-tile
|
||||
className={`tag-manager-tile group relative min-w-0 rounded-lg border border-white/[0.06] bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:border-white/[0.14] focus-within:bg-white/[0.045] hover:border-white/[0.12] hover:bg-white/[0.04] ${
|
||||
editing || confirming ? "min-h-[82px]" : "min-h-[46px]"
|
||||
}`}
|
||||
className={`tag-manager-tile ${entry.has_ai_source ? "tag-manager-tile-ai" : ""} group relative min-w-0 rounded-lg border bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:bg-white/[0.045] hover:bg-white/[0.04] ${
|
||||
entry.has_ai_source
|
||||
? "border-white/[0.08] shadow-[inset_0_1px_0_rgba(125,211,252,0.055)] focus-within:border-white/[0.15] hover:border-white/[0.13]"
|
||||
: "border-white/[0.06] focus-within:border-white/[0.14] hover:border-white/[0.12]"
|
||||
} ${editing || confirming ? "min-h-[82px]" : "min-h-[46px]"}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
{editing ? (
|
||||
@@ -749,9 +802,20 @@ function TagManageTile({
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="tag-manager-count absolute right-2.5 top-2 shrink-0 rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] tabular-nums text-white/38">
|
||||
<div className="absolute right-2.5 top-2 shrink-0">
|
||||
{entry.has_ai_source ? (
|
||||
<Tooltip label={sourceLabel} delay={400} anchorToCursor>
|
||||
<span className="tag-manager-count tag-manager-count-ai inline-flex h-5 items-center gap-1 rounded-full border border-sky-300/10 bg-sky-300/[0.075] px-1.5 text-[10px] tabular-nums text-sky-200/75">
|
||||
<AiSourceGlyph className="tag-manager-ai-glyph h-3 w-3" />
|
||||
{entry.count.toLocaleString()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="tag-manager-count rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] tabular-nums text-white/38">
|
||||
{entry.count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
@@ -789,21 +853,25 @@ function TagManageTile({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute right-12 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
|
||||
<div className="pointer-events-none absolute right-[5rem] top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
|
||||
<Tooltip label="Rename or merge into another tag" delay={400} anchorToCursor>
|
||||
<button
|
||||
className="tag-manager-action rounded-md border border-white/10 bg-gray-950/80 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
|
||||
className="tag-manager-action inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 bg-gray-950/80 text-white/55 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label={`Rename ${entry.tag}`}
|
||||
>
|
||||
Rename
|
||||
<RenameGlyph className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete this tag in the current scope" delay={400} anchorToCursor>
|
||||
<button
|
||||
className="tag-manager-action tag-manager-action-danger rounded-md border border-white/10 bg-gray-950/80 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
|
||||
className="tag-manager-action tag-manager-action-danger inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 bg-gray-950/80 text-white/55 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
|
||||
onClick={() => setConfirming(true)}
|
||||
aria-label={`Delete ${entry.tag}`}
|
||||
>
|
||||
Delete
|
||||
<DeleteGlyph className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -815,17 +883,24 @@ function TagManageList({
|
||||
onSearch,
|
||||
onRename,
|
||||
onDelete,
|
||||
onResetAiTags,
|
||||
scopeLabel,
|
||||
}: {
|
||||
entries: ExploreTagEntry[];
|
||||
onSearch: (tag: string) => void;
|
||||
onRename: (from: string, to: string) => Promise<void>;
|
||||
onDelete: (tag: string) => Promise<void>;
|
||||
onResetAiTags: () => Promise<number>;
|
||||
scopeLabel: string;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<TagManageSort>("count_desc");
|
||||
const [columns, setColumns] = useState(3);
|
||||
const [resetConfirming, setResetConfirming] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetStatus, setResetStatus] = useState<string | null>(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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="tag-manager-header shrink-0 border-b border-white/[0.05] bg-black/[0.08] px-6 py-4">
|
||||
@@ -887,9 +986,33 @@ function TagManageList({
|
||||
<p className="tag-manager-help mt-2 max-w-2xl text-[11px] leading-relaxed text-white/28">
|
||||
Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.
|
||||
</p>
|
||||
{resetStatus ? <p className="mt-2 text-[11px] leading-relaxed text-amber-200/80">{resetStatus}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2">
|
||||
<button
|
||||
className={`tag-manager-reset-button h-9 rounded-lg border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
resetConfirming
|
||||
? "tag-manager-reset-button-confirm border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25"
|
||||
: "border-white/8 bg-black/20 text-white/50 hover:bg-white/[0.06] hover:text-white/80"
|
||||
}`}
|
||||
onClick={() => void runResetAiTags()}
|
||||
disabled={resetting}
|
||||
>
|
||||
{resetting ? "Resetting..." : resetConfirming ? "Confirm reset" : "Reset AI tags"}
|
||||
</button>
|
||||
{resetConfirming ? (
|
||||
<button
|
||||
className="tag-manager-reset-cancel h-9 rounded-lg border border-white/8 bg-black/20 px-3 text-xs text-white/45 transition-colors hover:bg-white/[0.06] hover:text-white/75 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setResetConfirming(false);
|
||||
setResetStatus(null);
|
||||
}}
|
||||
disabled={resetting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
<div className="relative min-w-[220px] flex-1 sm:max-w-sm">
|
||||
<input
|
||||
className="tag-manager-filter h-9 w-full rounded-lg border border-white/8 bg-black/20 px-3 pr-8 text-sm text-white/85 outline-none transition-colors placeholder:text-white/22 focus:border-blue-400/35 focus:bg-black/28"
|
||||
@@ -983,11 +1106,22 @@ export function ExploreView() {
|
||||
const searchForTag = useGalleryStore((state) => 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}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -166,6 +166,8 @@ export function SettingsModal() {
|
||||
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(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<string | null>(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 (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
|
||||
<div
|
||||
@@ -673,17 +717,40 @@ export function SettingsModal() {
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||
onClick={() => 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"}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100"
|
||||
onClick={() => runQueueAction("clear")}
|
||||
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
|
||||
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
|
||||
>
|
||||
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||
taggerResetConfirming
|
||||
? "border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25 light-theme:border-red-500/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
|
||||
: "border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||
}`}
|
||||
onClick={runResetAiTags}
|
||||
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
|
||||
>
|
||||
{taggerResetting ? "Resetting..." : taggerResetConfirming ? "Confirm reset" : "Reset AI tags"}
|
||||
</button>
|
||||
{taggerResetConfirming ? (
|
||||
<button
|
||||
className="rounded-md border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:border-gray-700/50 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||
onClick={() => {
|
||||
setTaggerResetConfirming(false);
|
||||
setTaggerQueueStatus(null);
|
||||
}}
|
||||
disabled={taggerResetting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
|
||||
+64
-2
@@ -106,6 +106,38 @@ function searchTagsAutocomplete(payload: unknown): ExploreTagEntry[] {
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function rebuildExploreTags(): ExploreTagEntry[] {
|
||||
const entries = new Map<
|
||||
string,
|
||||
{ imageIds: Set<number>; 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<number>(), 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<string, number>();
|
||||
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":
|
||||
|
||||
+56
-16
@@ -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<number, ImageTag[]> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<MockScenario>([
|
||||
"rich",
|
||||
@@ -8,6 +8,7 @@ const SCENARIOS = new Set<MockScenario>([
|
||||
"album",
|
||||
"errors",
|
||||
"huge",
|
||||
"extreme",
|
||||
]);
|
||||
|
||||
export function getMockScenario(): MockScenario {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<number>;
|
||||
clearTaggingJobs: (folderId?: number | null) => Promise<number>;
|
||||
clearTaggingJobsForFolders: (folderIds: number[]) => Promise<number>;
|
||||
resetAiTags: (folderId?: number | null) => Promise<number>;
|
||||
resetAiTagsForFolders: (folderIds: number[]) => Promise<number>;
|
||||
loadDuplicateScanCache: (folderId?: number | null) => Promise<void>;
|
||||
scanDuplicates: (folderId?: number | null) => Promise<void>;
|
||||
toggleDuplicateSelected: (imageId: number) => void;
|
||||
@@ -2383,6 +2387,26 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
return cleared;
|
||||
},
|
||||
|
||||
resetAiTags: async (folderId = get().selectedFolderId) => {
|
||||
const reset = await invoke<number>("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<number>("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<ImageTag[]>("get_image_tags", {
|
||||
params: { image_id: imageId },
|
||||
|
||||
Reference in New Issue
Block a user