Compare commits
3 Commits
d7595703de
...
c1070649fa
| Author | SHA1 | Date | |
|---|---|---|---|
| c1070649fa | |||
| 2cdab000fb | |||
| 6824dcffb3 |
+2
-1
@@ -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",
|
||||
|
||||
Generated
+10
@@ -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
|
||||
|
||||
Generated
+68
-1
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
)?;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user