Compare commits

...

3 Commits

Author SHA1 Message Date
LyAhn c1070649fa feat(notifications): report completed background tasks
Register the Tauri notification plugin and request notification permission during startup. Send completion notifications for folder scans, embeddings, AI tagging, and duplicate scans, including failure counts where available.
2026-06-06 19:52:24 +01:00
LyAhn 2cdab000fb feat(discovery): surface all tags and guard folder removal
Include tags used by a single image in Explore and invalidate the tag cache after edits so changes appear immediately. Add an accessible, time-limited confirmation step before removing indexed folders from the sidebar.
2026-06-06 19:52:05 +01:00
LyAhn 6824dcffb3 fix(indexer): recover cleanly from indexing failures
Move sqlite-vec embedding deletions outside the image transaction to avoid transactional virtual-table failures. Emit a terminal indexing progress event on errors so the frontend reloads partial state and clears its active scan state.
2026-06-06 19:51:44 +01:00
12 changed files with 256 additions and 28 deletions
+1
View File
@@ -16,6 +16,7 @@
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"d3-force": "^3.0.0", "d3-force": "^3.0.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
+10
View File
@@ -20,6 +20,9 @@ importers:
'@tauri-apps/plugin-fs': '@tauri-apps/plugin-fs':
specifier: ^2.5.0 specifier: ^2.5.0
version: 2.5.0 version: 2.5.0
'@tauri-apps/plugin-notification':
specifier: ^2.3.3
version: 2.3.3
'@tauri-apps/plugin-opener': '@tauri-apps/plugin-opener':
specifier: ^2 specifier: ^2
version: 2.5.3 version: 2.5.3
@@ -653,6 +656,9 @@ packages:
'@tauri-apps/plugin-fs@2.5.0': '@tauri-apps/plugin-fs@2.5.0':
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==} resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==}
'@tauri-apps/plugin-notification@2.3.3':
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
'@tauri-apps/plugin-opener@2.5.3': '@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
@@ -1448,6 +1454,10 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-notification@2.3.3':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-opener@2.5.3': '@tauri-apps/plugin-opener@2.5.3':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
+68 -1
View File
@@ -3293,6 +3293,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50efa634682b3fc5a1ab6f3dd5b2bce7b848011fc485b53b063dc68f2f74feae"
dependencies = [
"cc",
"objc2",
"objc2-foundation",
"time",
]
[[package]] [[package]]
name = "mach2" name = "mach2"
version = "0.4.3" version = "0.4.3"
@@ -3594,6 +3606,20 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "notify-rust"
version = "4.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]] [[package]]
name = "ntapi" name = "ntapi"
version = "0.4.3" version = "0.4.3"
@@ -4279,6 +4305,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-fs", "tauri-plugin-fs",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tokenizers", "tokenizers",
"tokio", "tokio",
@@ -4326,7 +4353,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.1", "indexmap 2.13.1",
"quick-xml", "quick-xml 0.38.4",
"serde", "serde",
"time", "time",
] ]
@@ -4543,6 +4570,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -6016,6 +6052,25 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.2",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.3" version = "2.5.3"
@@ -6138,6 +6193,18 @@ dependencies = [
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
+1
View File
@@ -50,6 +50,7 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "n
ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] } ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] }
zip = { version = "4.6.1", default-features = false, features = ["deflate"] } zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
csv = "1" csv = "1"
tauri-plugin-notification = "2"
# ── Dev-mode performance ──────────────────────────────────────────────────── # ── Dev-mode performance ────────────────────────────────────────────────────
# opt-level=1 on the main crate keeps incremental compile short. # opt-level=1 on the main crate keeps incremental compile short.
+1
View File
@@ -13,6 +13,7 @@
"fs:allow-read-dir", "fs:allow-read-dir",
"fs:read-files", "fs:read-files",
"fs:read-dirs", "fs:read-dirs",
"notification:default",
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
+7 -3
View File
@@ -901,10 +901,14 @@ pub fn get_folder_media_index(conn: &Connection, folder_id: i64) -> Result<Vec<I
} }
pub fn delete_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result<()> { pub fn delete_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result<()> {
// Delete from sqlite-vec virtual tables outside the transaction — sqlite-vec
// has known issues with transactional DML in early versions.
for image_id in image_ids {
vector::delete_embedding(conn, *image_id)?;
vector::delete_caption_embedding(conn, *image_id)?;
}
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
for image_id in image_ids { for image_id in image_ids {
vector::delete_embedding(&tx, *image_id)?;
vector::delete_caption_embedding(&tx, *image_id)?;
tx.execute("DELETE FROM images WHERE id = ?1", [image_id])?; tx.execute("DELETE FROM images WHERE id = ?1", [image_id])?;
} }
tx.commit()?; tx.commit()?;
@@ -1519,7 +1523,7 @@ pub fn get_explore_tags(
JOIN images i ON i.id = t.image_id JOIN images i ON i.id = t.image_id
WHERE (?1 IS NULL OR i.folder_id = ?1) WHERE (?1 IS NULL OR i.folder_id = ?1)
GROUP BY t.tag GROUP BY t.tag
HAVING COUNT(DISTINCT t.image_id) >= 2 HAVING COUNT(DISTINCT t.image_id) >= 1
ORDER BY tag_count DESC, t.tag ASC ORDER BY tag_count DESC, t.tag ASC
LIMIT ?2", LIMIT ?2",
)?; )?;
+13 -2
View File
@@ -150,8 +150,19 @@ pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: P
let storage_profile = detect_storage_profile(&folder_path); let storage_profile = detect_storage_profile(&folder_path);
set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile)); set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile));
set_folder_indexing_state(folder_id, true); set_folder_indexing_state(folder_id, true);
if let Err(error) = do_index(app, pool, folder_id, folder_path) { if let Err(error) = do_index(app.clone(), pool, folder_id, folder_path) {
eprintln!("Indexing error: {}", error); eprintln!("Indexing error for folder {}: {}", folder_id, error);
// Always emit done so the frontend reloads and recovers from partial state.
emit_progress(
&app,
&IndexProgress {
folder_id,
total: 0,
indexed: 0,
current_file: String::new(),
done: true,
},
);
} }
set_folder_indexing_state(folder_id, false); set_folder_indexing_state(folder_id, false);
}); });
+1
View File
@@ -19,6 +19,7 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| { .setup(|app| {
let app_dir = app let app_dir = app
.path() .path()
+2
View File
@@ -9,6 +9,7 @@ import { TagCloud } from "./components/TagCloud";
import { DuplicateFinder } from "./components/DuplicateFinder"; import { DuplicateFinder } from "./components/DuplicateFinder";
import { TitleBar } from "./components/TitleBar"; import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal"; import { SettingsModal } from "./components/SettingsModal";
import { initializeNotifications } from "./notifications";
export default function App() { export default function App() {
const loadFolders = useGalleryStore((state) => state.loadFolders); const loadFolders = useGalleryStore((state) => state.loadFolders);
@@ -20,6 +21,7 @@ export default function App() {
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView);
useEffect(() => { useEffect(() => {
void initializeNotifications();
loadFolders().then(() => { loadFolders().then(() => {
void loadBackgroundJobProgress(); void loadBackgroundJobProgress();
void loadCaptionModelStatus(); void loadCaptionModelStatus();
+46 -2
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store"; import { useGalleryStore, Folder, IndexProgress } from "../store";
@@ -12,6 +13,22 @@ function FolderItem({
}) { }) {
const { selectFolder, removeFolder, reindexFolder } = useGalleryStore(); const { selectFolder, removeFolder, reindexFolder } = useGalleryStore();
const isIndexing = progress && !progress.done; const isIndexing = progress && !progress.done;
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
useEffect(() => {
if (!confirmingRemoval) return;
const timeout = window.setTimeout(() => {
setConfirmingRemoval(false);
}, 4000);
return () => window.clearTimeout(timeout);
}, [confirmingRemoval]);
const handleRemove = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
await removeFolder(folder.id);
};
return ( return (
<div <div
@@ -46,10 +63,32 @@ function FolderItem({
)} )}
</div> </div>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> {confirmingRemoval ? (
<div
className="flex items-center gap-1 shrink-0"
role="group"
aria-label={`Confirm removal of ${folder.name}`}
onClick={(event) => event.stopPropagation()}
>
<button
className="px-1.5 py-1 rounded-md text-[10px] font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 hover:text-red-300"
onClick={handleRemove}
>
Remove
</button>
<button
className="px-1.5 py-1 rounded-md text-[10px] font-medium text-gray-400 hover:text-gray-100 hover:bg-white/10"
onClick={() => setConfirmingRemoval(false)}
>
Cancel
</button>
</div>
) : (
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity shrink-0">
<button <button
className="p-1 rounded-md hover:bg-white/10 text-gray-500 hover:text-gray-200" className="p-1 rounded-md hover:bg-white/10 text-gray-500 hover:text-gray-200"
title="Re-index" title="Re-index"
aria-label={`Re-index ${folder.name}`}
onClick={(e) => { e.stopPropagation(); reindexFolder(folder.id); }} onClick={(e) => { e.stopPropagation(); reindexFolder(folder.id); }}
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -60,13 +99,18 @@ function FolderItem({
<button <button
className="p-1 rounded-md hover:bg-red-500/15 text-gray-500 hover:text-red-400" className="p-1 rounded-md hover:bg-red-500/15 text-gray-500 hover:text-red-400"
title="Remove folder" title="Remove folder"
onClick={(e) => { e.stopPropagation(); removeFolder(folder.id); }} aria-label={`Remove ${folder.name}`}
onClick={(event) => {
event.stopPropagation();
setConfirmingRemoval(true);
}}
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
)}
</div> </div>
); );
} }
+31
View File
@@ -0,0 +1,31 @@
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
let permissionPromise: Promise<boolean> | null = null;
export function initializeNotifications(): Promise<boolean> {
permissionPromise ??= (async () => {
try {
if (await isPermissionGranted()) return true;
return (await requestPermission()) === "granted";
} catch (error) {
console.warn("Windows notifications are unavailable:", error);
return false;
}
})();
return permissionPromise;
}
export async function notifyTaskComplete(title: string, body: string): Promise<void> {
if (!(await initializeNotifications())) return;
try {
sendNotification({ title, body });
} catch (error) {
console.warn("Could not send task completion notification:", error);
}
}
+56 -1
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event"; import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { appDataDir, join } from "@tauri-apps/api/path"; import { appDataDir, join } from "@tauri-apps/api/path";
import { notifyTaskComplete } from "./notifications";
export interface Folder { export interface Folder {
id: number; id: number;
@@ -1360,15 +1361,20 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
addUserTag: async (imageId, tag) => { addUserTag: async (imageId, tag) => {
return invoke<ImageTag>("add_user_tag", { const result = await invoke<ImageTag>("add_user_tag", {
params: { image_id: imageId, tag }, params: { image_id: imageId, tag },
}); });
// Invalidate explore tags cache so new tag appears immediately
set({ exploreTagsFolderId: undefined });
return result;
}, },
removeTag: async (tagId) => { removeTag: async (tagId) => {
await invoke<void>("remove_tag", { await invoke<void>("remove_tag", {
params: { tag_id: tagId }, params: { tag_id: tagId },
}); });
// Invalidate explore tags cache so removed tag disappears immediately
set({ exploreTagsFolderId: undefined });
}, },
loadDuplicateScanCache: async (folderId = null) => { loadDuplicateScanCache: async (folderId = null) => {
@@ -1389,6 +1395,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
try { try {
const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null }); const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null });
set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000) }); set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000) });
void notifyTaskComplete(
"Duplicate scan complete",
groups.length === 1 ? "Found 1 duplicate group." : `Found ${groups.length.toLocaleString()} duplicate groups.`,
);
} finally { } finally {
unlisten(); unlisten();
set({ duplicateScanning: false }); set({ duplicateScanning: false });
@@ -1461,6 +1471,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
subscribeToProgress: async () => { subscribeToProgress: async () => {
const unlistenProgress = await listen<IndexProgress>("index-progress", (event) => { const unlistenProgress = await listen<IndexProgress>("index-progress", (event) => {
const progress = event.payload; const progress = event.payload;
const previous = get().indexingProgress[progress.folder_id];
set((state) => ({ set((state) => ({
indexingProgress: { indexingProgress: {
...state.indexingProgress, ...state.indexingProgress,
@@ -1469,6 +1480,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
})); }));
if (progress.done) { if (progress.done) {
if (
previous &&
!previous.done &&
progress.total > 0 &&
progress.indexed >= progress.total
) {
const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name;
void notifyTaskComplete(
"Folder scan complete",
folderName ? `${folderName} has finished scanning.` : "A folder has finished scanning.",
);
}
void get().loadFolders(); void get().loadFolders();
void get().loadBackgroundJobProgress(); void get().loadBackgroundJobProgress();
if (get().activeView !== "explore" && !isDerivedCollectionTitle(get().collectionTitle)) { if (get().activeView !== "explore" && !isDerivedCollectionTitle(get().collectionTitle)) {
@@ -1486,6 +1509,38 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}); });
const unlistenMediaJobs = await listen<MediaJobProgressEvent>("media-job-progress", (event) => { const unlistenMediaJobs = await listen<MediaJobProgressEvent>("media-job-progress", (event) => {
const previousProgress = get().mediaJobProgress;
for (const progress of event.payload.progress) {
const previous = previousProgress[progress.folder_id];
if (!previous) continue;
const folderName =
get().folders.find((folder) => folder.id === progress.folder_id)?.name ?? "Folder";
if (previous.embedding_pending > 0 && progress.embedding_pending === 0) {
const failureDetail =
progress.embedding_failed > 0
? ` ${progress.embedding_failed.toLocaleString()} failed.`
: "";
void notifyTaskComplete(
"Embeddings complete",
`${folderName} finished generating embeddings.${failureDetail}`,
);
}
if (previous.tagging_pending > 0 && progress.tagging_pending === 0) {
const failureDetail =
progress.tagging_failed > 0
? ` ${progress.tagging_failed.toLocaleString()} failed.`
: "";
void notifyTaskComplete(
"AI tagging complete",
`${folderName} finished generating tags.${failureDetail}`,
);
}
}
set((state) => { set((state) => {
const next = { ...state.mediaJobProgress }; const next = { ...state.mediaJobProgress };
for (const progress of event.payload.progress) { for (const progress of event.payload.progress) {