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
+2 -1
View File
@@ -8,7 +8,7 @@
"build:vite": "tsc && vite build",
"dev:app": "tauri dev",
"dev:vite": "vite",
"preview": "vite preview",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
@@ -16,6 +16,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"d3-force": "^3.0.0",
"framer-motion": "^12.38.0",
+10
View File
@@ -20,6 +20,9 @@ importers:
'@tauri-apps/plugin-fs':
specifier: ^2.5.0
version: 2.5.0
'@tauri-apps/plugin-notification':
specifier: ^2.3.3
version: 2.3.3
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.3
@@ -653,6 +656,9 @@ packages:
'@tauri-apps/plugin-fs@2.5.0':
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':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
@@ -1448,6 +1454,10 @@ snapshots:
dependencies:
'@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':
dependencies:
'@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"
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]]
name = "mach2"
version = "0.4.3"
@@ -3594,6 +3606,20 @@ dependencies = [
"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]]
name = "ntapi"
version = "0.4.3"
@@ -4279,6 +4305,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tokenizers",
"tokio",
@@ -4326,7 +4353,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.1",
"quick-xml",
"quick-xml 0.38.4",
"serde",
"time",
]
@@ -4543,6 +4570,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "quick-xml"
version = "0.38.4"
@@ -6016,6 +6052,25 @@ dependencies = [
"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]]
name = "tauri-plugin-opener"
version = "2.5.3"
@@ -6138,6 +6193,18 @@ dependencies = [
"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]]
name = "tempfile"
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"] }
zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
csv = "1"
tauri-plugin-notification = "2"
# ── Dev-mode performance ────────────────────────────────────────────────────
# opt-level=1 on the main crate keeps incremental compile short.
+1
View File
@@ -13,6 +13,7 @@
"fs:allow-read-dir",
"fs:read-files",
"fs:read-dirs",
"notification:default",
"core:window:allow-minimize",
"core:window:allow-close",
"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<()> {
// 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()?;
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.commit()?;
@@ -1519,7 +1523,7 @@ pub fn get_explore_tags(
JOIN images i ON i.id = t.image_id
WHERE (?1 IS NULL OR i.folder_id = ?1)
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
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);
set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile));
set_folder_indexing_state(folder_id, true);
if let Err(error) = do_index(app, pool, folder_id, folder_path) {
eprintln!("Indexing error: {}", error);
if let Err(error) = do_index(app.clone(), pool, folder_id, folder_path) {
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);
});
+1
View File
@@ -19,6 +19,7 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| {
let app_dir = app
.path()
+2
View File
@@ -9,6 +9,7 @@ import { TagCloud } from "./components/TagCloud";
import { DuplicateFinder } from "./components/DuplicateFinder";
import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal";
import { initializeNotifications } from "./notifications";
export default function App() {
const loadFolders = useGalleryStore((state) => state.loadFolders);
@@ -20,6 +21,7 @@ export default function App() {
const activeView = useGalleryStore((state) => state.activeView);
useEffect(() => {
void initializeNotifications();
loadFolders().then(() => {
void loadBackgroundJobProgress();
void loadCaptionModelStatus();
+64 -20
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store";
@@ -12,6 +13,22 @@ function FolderItem({
}) {
const { selectFolder, removeFolder, reindexFolder } = useGalleryStore();
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 (
<div
@@ -46,27 +63,54 @@ function FolderItem({
)}
</div>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
className="p-1 rounded-md hover:bg-white/10 text-gray-500 hover:text-gray-200"
title="Re-index"
onClick={(e) => { e.stopPropagation(); reindexFolder(folder.id); }}
{confirmingRemoval ? (
<div
className="flex items-center gap-1 shrink-0"
role="group"
aria-label={`Confirm removal of ${folder.name}`}
onClick={(event) => event.stopPropagation()}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
className="p-1 rounded-md hover:bg-red-500/15 text-gray-500 hover:text-red-400"
title="Remove folder"
onClick={(e) => { e.stopPropagation(); removeFolder(folder.id); }}
>
<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" />
</svg>
</button>
</div>
<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
className="p-1 rounded-md hover:bg-white/10 text-gray-500 hover:text-gray-200"
title="Re-index"
aria-label={`Re-index ${folder.name}`}
onClick={(e) => { e.stopPropagation(); reindexFolder(folder.id); }}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
className="p-1 rounded-md hover:bg-red-500/15 text-gray-500 hover:text-red-400"
title="Remove folder"
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</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 { listen, UnlistenFn } from "@tauri-apps/api/event";
import { appDataDir, join } from "@tauri-apps/api/path";
import { notifyTaskComplete } from "./notifications";
export interface Folder {
id: number;
@@ -1360,15 +1361,20 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
addUserTag: async (imageId, tag) => {
return invoke<ImageTag>("add_user_tag", {
const result = await invoke<ImageTag>("add_user_tag", {
params: { image_id: imageId, tag },
});
// Invalidate explore tags cache so new tag appears immediately
set({ exploreTagsFolderId: undefined });
return result;
},
removeTag: async (tagId) => {
await invoke<void>("remove_tag", {
params: { tag_id: tagId },
});
// Invalidate explore tags cache so removed tag disappears immediately
set({ exploreTagsFolderId: undefined });
},
loadDuplicateScanCache: async (folderId = null) => {
@@ -1389,6 +1395,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
try {
const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null });
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 {
unlisten();
set({ duplicateScanning: false });
@@ -1461,6 +1471,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
subscribeToProgress: async () => {
const unlistenProgress = await listen<IndexProgress>("index-progress", (event) => {
const progress = event.payload;
const previous = get().indexingProgress[progress.folder_id];
set((state) => ({
indexingProgress: {
...state.indexingProgress,
@@ -1469,6 +1480,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}));
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().loadBackgroundJobProgress();
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 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) => {
const next = { ...state.mediaJobProgress };
for (const progress of event.payload.progress) {