refactor(store): split store.ts into feature slices under src/store/
Break the 3,244-line monolithic store into a Zustand slice-per-feature layout (types, helpers, librarySlice, gallerySlice, searchSlice, exploreSlice, albumSlice, duplicateSlice, taggerSlice, captionSlice, settingsSlice, appSlice, events) combined in index.ts. Components keep calling useGalleryStore(s => s.field) against the same flat state object — no component changes required. Completes the de-godify effort started with the menu/Dropdown/Sidebar extractions and icons.tsx.
This commit is contained in:
+5
-7
@@ -31,9 +31,7 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
an album can now stay inside that album, jump back to the source folder, or
|
||||
search everything.
|
||||
- **Tag manager** — Explore's Tag Cloud now has a Manage mode for renaming,
|
||||
merging, and deleting tags across the whole library. There is also an "Open tag
|
||||
manager" shortcut in Settings -> AI Workspace when you want to get straight to
|
||||
the cleanup.
|
||||
merging, and deleting tags across the whole library.
|
||||
- **Camera info in the lightbox** — the info panel now shows EXIF details like
|
||||
camera, lens, aperture, shutter speed, ISO, and focal length. Geotagged photos
|
||||
also get a browser link for their GPS coordinates, and already-indexed images
|
||||
@@ -43,7 +41,6 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
- **Choose your tagging model** — Settings -> AI Workspace now lets you pick
|
||||
between the anime-focused WD tagger and JoyTag, which is better suited to photo
|
||||
libraries and stronger on NSFW concepts (if that's your thing, we don't judge).
|
||||
Models download only when needed, and tags remember which model created them.
|
||||
- **Related tags in Explore** — Hover over a tag in the Tag Cloud to see the tags
|
||||
that most often appear with it, complete with connection lines and image counts.
|
||||
Handy for finding little clusters you did not know were there.
|
||||
@@ -53,9 +50,7 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
- **Editable folder path** — the folder picker now has an address bar, so you can
|
||||
paste a path directly while still using breadcrumbs for quick jumps.
|
||||
- **Slideshow mode** — turn the lightbox into a fullscreen, image-only slideshow
|
||||
from whatever collection you are already browsing. Videos are skipped, controls
|
||||
tuck themselves away after a few seconds, and Settings lets you pick the pace
|
||||
and whether playback follows the current order or goes random.
|
||||
from whatever collection you are already browsing.
|
||||
- **Add to album from the right-click menu** — right-click any image in the
|
||||
Gallery or Timeline and file it straight into an album from the new "Add to
|
||||
Album" submenu. One image, one click, zero ceremony.
|
||||
@@ -131,6 +126,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
cleaned up on startup. Your manually-added tags are left alone.
|
||||
- **Selected Folders starts empty** — choosing "Selected Folders" for AI tagging
|
||||
no longer pre-selects the first folder. You decide exactly what gets queued.
|
||||
- **A couple of tiny UI papercuts are gone** — the zoom buttons now show the
|
||||
right tile size when you hover them, and the folder picker no longer adds an
|
||||
odd trailing slash to Windows drive breadcrumbs.
|
||||
|
||||
## [0.1.1] — 2026-06-23
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ There are no test suites configured.
|
||||
|
||||
### Frontend (`src/`)
|
||||
|
||||
- **`store.ts`** — single Zustand store (`useGalleryStore`) that owns all app state and all `invoke()` calls to the Tauri backend. Every feature (folders, images, search, similar images, tags, captions, tagger, duplicates) is implemented as store actions here. React components are thin consumers.
|
||||
- **`src/store/`** — single Zustand store (`useGalleryStore`) that owns all app state and all `invoke()` calls to the Tauri backend, split into per-feature slices combined in `index.ts` (which exports `useGalleryStore` and the `GalleryStore` type). `types.ts` holds all interfaces/type unions (including `ImageRecord`); `helpers.ts` holds pure functions and cross-slice module state (search parsing, image sort/merge, request-token guards). Slices: `librarySlice` (folders), `gallerySlice` (image paging/filters/bulk actions), `searchSlice` (search + similar-images), `exploreSlice` (visual clusters, tag cloud, tags), `albumSlice`, `duplicateSlice`, `taggerSlice`, `captionSlice`, `settingsSlice`, `appSlice` (updates, onboarding, ffmpeg, worker pauses); `events.ts` wires the Tauri event listeners (`subscribeToProgress`). Components still call `useGalleryStore(s => s.field)` against one flat state object — the slice split is internal. React components are thin consumers.
|
||||
- **`App.tsx`** — sets up Tauri event listeners (`subscribeToProgress`) and renders the top-level layout (sidebar + active view).
|
||||
- **`src/components/`** — UI components: `Gallery`, `Lightbox`, `Sidebar`, `Toolbar`, `Timeline`, `ExploreView`, `DuplicateFinder`, `BackgroundTasks`, `SettingsModal`, `TitleBar`.
|
||||
- **`src/components/menu/`** — shared floating-UI primitives: `useDismissable`, `MenuPanel`/`MenuItem`/`SubMenu`, the portal-based `ContextMenu`, and the app-wide `Dropdown`. Build menus, dropdowns, and popovers on these instead of hand-rolling.
|
||||
@@ -44,7 +44,7 @@ There are no test suites configured.
|
||||
|
||||
### Search modes
|
||||
|
||||
The search bar supports prefix syntax parsed by `parseSearchValue` in `store.ts`:
|
||||
The search bar supports prefix syntax parsed by `parseSearchValue` in `src/store/helpers.ts`:
|
||||
- No prefix / `f:` — filename search (paginated, DB-backed)
|
||||
- `/s <query>` or `s: <query>` — semantic (embedding) search
|
||||
- `/t <tag>` or `t: <tag>` — tag search
|
||||
@@ -88,7 +88,7 @@ Database: SQLite with WAL mode, stored in the Tauri app data directory as `galle
|
||||
|
||||
### Key types
|
||||
|
||||
`ImageRecord` (mirrored in `store.ts` and `db.rs`) is the central data type. It carries embedding status, tagging status, caption data, and media metadata. The frontend type must stay in sync with the Rust struct serialization.
|
||||
`ImageRecord` (mirrored in `src/store/types.ts` and `db.rs`) is the central data type. It carries embedding status, tagging status, caption data, and media metadata. The frontend type must stay in sync with the Rust struct serialization.
|
||||
|
||||
## Development notes
|
||||
|
||||
|
||||
-3243
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { GalleryStore } from "./index";
|
||||
import { PAGE_SIZE, isCurrentGalleryRequest, nextGalleryRequestToken } from "./helpers";
|
||||
import type { Album, ImageRecord } from "./types";
|
||||
|
||||
export interface AlbumSlice {
|
||||
albums: Album[];
|
||||
albumsLoaded: boolean;
|
||||
selectedAlbumId: number | null;
|
||||
|
||||
loadAlbums: () => Promise<void>;
|
||||
createAlbum: (name: string) => Promise<Album>;
|
||||
renameAlbum: (albumId: number, name: string) => Promise<void>;
|
||||
deleteAlbum: (albumId: number) => Promise<void>;
|
||||
deleteAlbums: (albumIds: number[]) => Promise<void>;
|
||||
reorderAlbums: (albumIds: number[]) => Promise<void>;
|
||||
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>;
|
||||
removeFromAlbum: (albumId: number, imageIds: number[]) => Promise<void>;
|
||||
viewAlbum: (albumId: number) => void;
|
||||
}
|
||||
|
||||
export const createAlbumSlice: StateCreator<GalleryStore, [], [], AlbumSlice> = (set, get) => ({
|
||||
albums: [],
|
||||
albumsLoaded: false,
|
||||
selectedAlbumId: null,
|
||||
|
||||
loadAlbums: async () => {
|
||||
const albums = await invoke<Album[]>("list_albums");
|
||||
set({ albums, albumsLoaded: true });
|
||||
},
|
||||
|
||||
createAlbum: async (name) => {
|
||||
const album = await invoke<Album>("create_album", { params: { name } });
|
||||
await get().loadAlbums();
|
||||
return album;
|
||||
},
|
||||
|
||||
renameAlbum: async (albumId, name) => {
|
||||
await invoke("rename_album", { params: { album_id: albumId, new_name: name } });
|
||||
await get().loadAlbums();
|
||||
},
|
||||
|
||||
deleteAlbum: async (albumId) => {
|
||||
await invoke("delete_album", { params: { album_id: albumId } });
|
||||
// If the deleted album is being viewed, drop back to All Media.
|
||||
if (get().activeView === "album" && get().selectedAlbumId === albumId) {
|
||||
set({ activeView: "gallery", selectedAlbumId: null, collectionTitle: null });
|
||||
void get().loadImages(true);
|
||||
}
|
||||
await get().loadAlbums();
|
||||
},
|
||||
|
||||
deleteAlbums: async (albumIds) => {
|
||||
if (albumIds.length === 0) return;
|
||||
await invoke("delete_albums", { params: { album_ids: albumIds } });
|
||||
// If a deleted album is being viewed, drop back to All Media.
|
||||
if (get().activeView === "album" && get().selectedAlbumId !== null && albumIds.includes(get().selectedAlbumId!)) {
|
||||
set({ activeView: "gallery", selectedAlbumId: null, collectionTitle: null });
|
||||
void get().loadImages(true);
|
||||
}
|
||||
await get().loadAlbums();
|
||||
},
|
||||
|
||||
reorderAlbums: async (albumIds) => {
|
||||
const previous = get().albums;
|
||||
const byId = new Map(previous.map((album) => [album.id, album]));
|
||||
const albums = albumIds
|
||||
.map((id, index) => {
|
||||
const album = byId.get(id);
|
||||
return album ? { ...album, sort_order: index + 1 } : null;
|
||||
})
|
||||
.filter((album): album is Album => album !== null);
|
||||
set({ albums });
|
||||
try {
|
||||
await invoke("reorder_albums", { params: { album_ids: albumIds } });
|
||||
} catch (error) {
|
||||
set({ albums: previous });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
addToAlbum: async (albumId, imageIds) => {
|
||||
if (imageIds.length === 0) return 0;
|
||||
const added = await invoke<number>("add_images_to_album", {
|
||||
params: { album_id: albumId, image_ids: imageIds },
|
||||
});
|
||||
await get().loadAlbums();
|
||||
return added;
|
||||
},
|
||||
|
||||
removeFromAlbum: async (albumId, imageIds) => {
|
||||
if (imageIds.length === 0) return;
|
||||
await invoke("remove_images_from_album", {
|
||||
params: { album_id: albumId, image_ids: imageIds },
|
||||
});
|
||||
// If viewing this album, splice the removed images out immediately.
|
||||
if (get().activeView === "album" && get().selectedAlbumId === albumId) {
|
||||
const removed = new Set(imageIds);
|
||||
set((state) => {
|
||||
const nextImages = state.images.filter((image) => !removed.has(image.id));
|
||||
// Decrement by what was actually on screen, not the requested count —
|
||||
// some ids may live beyond the loaded page.
|
||||
const removedFromView = state.images.length - nextImages.length;
|
||||
return {
|
||||
images: nextImages,
|
||||
loadedCount: nextImages.length,
|
||||
totalImages: Math.max(0, state.totalImages - removedFromView),
|
||||
gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !removed.has(id))),
|
||||
};
|
||||
});
|
||||
}
|
||||
await get().loadAlbums();
|
||||
},
|
||||
|
||||
viewAlbum: (albumId) => {
|
||||
const requestToken = nextGalleryRequestToken();
|
||||
const album = get().albums.find((entry) => entry.id === albumId);
|
||||
const sort = get().sort;
|
||||
set((state) => ({
|
||||
activeView: "album",
|
||||
selectedAlbumId: albumId,
|
||||
search: "",
|
||||
images: [],
|
||||
totalImages: album?.image_count ?? 0,
|
||||
loadedCount: 0,
|
||||
loadingImages: true,
|
||||
collectionTitle: album?.name ?? "Album",
|
||||
imageLoadError: null,
|
||||
similarSourceImageId: null,
|
||||
similarSourceFolderId: null,
|
||||
similarSourceAlbumId: albumId,
|
||||
similarScope: "current_album",
|
||||
similarHasMore: false,
|
||||
similarFolderId: null,
|
||||
similarCrop: null,
|
||||
gallerySelectedIds: new Set<number>(),
|
||||
galleryScrollResetKey: state.galleryScrollResetKey + 1,
|
||||
}));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", {
|
||||
params: { album_id: albumId, sort, offset: 0, limit: PAGE_SIZE },
|
||||
});
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
set({
|
||||
images: result.images,
|
||||
totalImages: result.total,
|
||||
loadedCount: result.images.length,
|
||||
loadingImages: false,
|
||||
imageLoadError: null,
|
||||
collectionTitle: album?.name ?? "Album",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
set({ images: [], totalImages: 0, loadedCount: 0, loadingImages: false, imageLoadError: String(error) });
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { check, Update } from "@tauri-apps/plugin-updater";
|
||||
import type { StateCreator } from "zustand";
|
||||
import { getChangelogForVersion } from "../changelog";
|
||||
import type { GalleryStore } from "./index";
|
||||
import type { FfmpegStatus, FolderWorkerStates, UpdateStatus, WorkerKey } from "./types";
|
||||
import { WORKER_KEYS } from "./types";
|
||||
|
||||
// The Update handle from the plugin carries the download method; it's not
|
||||
// serializable state, so it lives outside the store.
|
||||
let pendingUpdate: Update | null = null;
|
||||
|
||||
export interface AppSlice {
|
||||
appVersion: string | null;
|
||||
buildVariant: "cpu" | "cuda" | null;
|
||||
updateStatus: UpdateStatus;
|
||||
updateVersion: string | null;
|
||||
updateProgress: number | null; // 0..1 download progress, null while size unknown
|
||||
updateError: string | null;
|
||||
updateDismissed: boolean;
|
||||
// "What's New" greeting after a version change. `whatsNewToast` holds the
|
||||
// version to advertise in the corner toast (null = hidden); `whatsNewOpen`
|
||||
// controls the full changelog modal.
|
||||
whatsNewOpen: boolean;
|
||||
whatsNewToast: string | null;
|
||||
|
||||
ffmpegStatus: FfmpegStatus;
|
||||
ffmpegProgress: { downloaded_bytes: number; total_bytes: number } | null;
|
||||
ffmpegError: string | null;
|
||||
onboardingCompleted: boolean | null; // null = not loaded yet
|
||||
onboardingOpen: boolean;
|
||||
onboardingStep: number;
|
||||
|
||||
workerPausesPersist: boolean;
|
||||
// Per-folder background-worker pause flags, shared by the BackgroundTasks
|
||||
// bar and the sidebar folder context menu.
|
||||
workerPaused: Record<number, Record<WorkerKey, boolean>>;
|
||||
|
||||
colorBackfill: { processed: number; total: number; done: boolean } | null;
|
||||
|
||||
loadAppVersion: () => Promise<void>;
|
||||
checkForUpdates: (options?: { quiet?: boolean }) => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
dismissUpdate: () => void;
|
||||
initWhatsNew: () => Promise<void>;
|
||||
openWhatsNew: () => void;
|
||||
closeWhatsNew: () => void;
|
||||
dismissWhatsNewToast: () => void;
|
||||
loadFfmpegStatus: () => Promise<void>;
|
||||
retryFfmpegDownload: () => Promise<void>;
|
||||
loadOnboardingCompleted: () => Promise<void>;
|
||||
completeOnboarding: () => void;
|
||||
openOnboarding: () => void;
|
||||
setOnboardingStep: (step: number) => void;
|
||||
loadWorkerPausesPersist: () => Promise<void>;
|
||||
setWorkerPausesPersist: (persist: boolean) => void;
|
||||
loadWorkerStates: () => Promise<void>;
|
||||
setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void;
|
||||
setAllWorkersPaused: (folderId: number, paused: boolean) => void;
|
||||
}
|
||||
|
||||
export const createAppSlice: StateCreator<GalleryStore, [], [], AppSlice> = (set, get) => ({
|
||||
appVersion: null,
|
||||
buildVariant: null,
|
||||
updateStatus: "idle",
|
||||
updateVersion: null,
|
||||
updateProgress: null,
|
||||
updateError: null,
|
||||
updateDismissed: false,
|
||||
whatsNewOpen: false,
|
||||
whatsNewToast: null,
|
||||
|
||||
ffmpegStatus: "unknown",
|
||||
ffmpegProgress: null,
|
||||
ffmpegError: null,
|
||||
onboardingCompleted: null,
|
||||
onboardingOpen: false,
|
||||
onboardingStep: 0,
|
||||
|
||||
workerPausesPersist: false,
|
||||
workerPaused: {},
|
||||
|
||||
colorBackfill: null,
|
||||
|
||||
loadAppVersion: async () => {
|
||||
try {
|
||||
set({ appVersion: await getVersion() });
|
||||
} catch {
|
||||
// leave null; the UI falls back to a dash
|
||||
}
|
||||
try {
|
||||
const variant = await invoke<string>("get_build_variant");
|
||||
set({ buildVariant: variant === "cuda" ? "cuda" : "cpu" });
|
||||
} catch {
|
||||
// leave null; the badge is hidden until known
|
||||
}
|
||||
},
|
||||
|
||||
checkForUpdates: async (options) => {
|
||||
const quiet = options?.quiet ?? false;
|
||||
const { updateStatus } = get();
|
||||
if (updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing") return;
|
||||
|
||||
set({ updateStatus: "checking", updateError: null });
|
||||
try {
|
||||
const update = await check();
|
||||
if (update) {
|
||||
pendingUpdate = update;
|
||||
set({ updateStatus: "available", updateVersion: update.version, updateDismissed: false });
|
||||
} else {
|
||||
pendingUpdate = null;
|
||||
set({ updateStatus: "upToDate", updateVersion: null });
|
||||
}
|
||||
} catch (error) {
|
||||
pendingUpdate = null;
|
||||
if (quiet) {
|
||||
// Launch-time check: stay silent on network/endpoint failures.
|
||||
set({ updateStatus: "idle" });
|
||||
} else {
|
||||
set({ updateStatus: "error", updateError: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
installUpdate: async () => {
|
||||
const update = pendingUpdate;
|
||||
if (!update || get().updateStatus !== "available") return;
|
||||
|
||||
// Clearing the dismissed flag re-surfaces the progress toast: the user may
|
||||
// have clicked "Later" on the prompt and then triggered the install from the
|
||||
// title-bar button or Settings, and they should still see download progress.
|
||||
set({ updateStatus: "downloading", updateProgress: null, updateError: null, updateDismissed: false });
|
||||
try {
|
||||
let contentLength: number | null = null;
|
||||
let downloaded = 0;
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case "Started":
|
||||
contentLength = event.data.contentLength ?? null;
|
||||
set({ updateProgress: contentLength ? 0 : null });
|
||||
break;
|
||||
case "Progress":
|
||||
downloaded += event.data.chunkLength;
|
||||
if (contentLength) {
|
||||
set({ updateProgress: Math.min(downloaded / contentLength, 1) });
|
||||
}
|
||||
break;
|
||||
case "Finished":
|
||||
set({ updateStatus: "installing", updateProgress: 1 });
|
||||
break;
|
||||
}
|
||||
});
|
||||
await relaunch();
|
||||
} catch (error) {
|
||||
set({ updateStatus: "error", updateError: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
dismissUpdate: () => set({ updateDismissed: true }),
|
||||
|
||||
initWhatsNew: async () => {
|
||||
try {
|
||||
const current = await getVersion();
|
||||
const lastSeen = (await invoke<string | null>("get_last_seen_version")) || null;
|
||||
// Already greeted this version — nothing to do, and no need to rewrite.
|
||||
if (lastSeen === current) return;
|
||||
|
||||
let shouldShow: boolean;
|
||||
if (lastSeen) {
|
||||
// A recorded earlier version means this launch is a genuine upgrade.
|
||||
shouldShow = true;
|
||||
} else {
|
||||
// No record yet. Fresh installs are covered by the welcome tour, so only
|
||||
// greet users who have already completed onboarding (i.e. upgraded into
|
||||
// this feature) rather than someone opening the app for the first time.
|
||||
shouldShow = await invoke<boolean>("get_onboarding_completed").catch(() => false);
|
||||
}
|
||||
|
||||
// Only surface the prompt if we actually have notes for this version.
|
||||
if (shouldShow && getChangelogForVersion(current)) {
|
||||
set({ whatsNewToast: current });
|
||||
}
|
||||
await invoke("set_last_seen_version", { version: current }).catch(() => {});
|
||||
} catch {
|
||||
// Non-fatal: the greeting is a nicety, never block startup on it.
|
||||
}
|
||||
},
|
||||
|
||||
openWhatsNew: () => set({ whatsNewOpen: true, whatsNewToast: null }),
|
||||
|
||||
closeWhatsNew: () => set({ whatsNewOpen: false }),
|
||||
|
||||
dismissWhatsNewToast: () => set({ whatsNewToast: null }),
|
||||
|
||||
loadFfmpegStatus: async () => {
|
||||
try {
|
||||
const status = await invoke<{ installed: boolean; downloading: boolean; failed: boolean }>(
|
||||
"get_ffmpeg_status",
|
||||
);
|
||||
if (status.installed) {
|
||||
set({ ffmpegStatus: "installed" });
|
||||
} else if (status.failed) {
|
||||
// The download failed before our event listener attached — surface
|
||||
// the error state so the retry button is reachable.
|
||||
set({ ffmpegStatus: "error", ffmpegError: "The download could not be completed. Check your connection and retry." });
|
||||
} else {
|
||||
// Not installed and possibly not downloading yet — the provision
|
||||
// thread starts with the app, so treat the gap as "starting" and let
|
||||
// the first ffmpeg-progress event settle the real state.
|
||||
set({ ffmpegStatus: "starting" });
|
||||
}
|
||||
} catch {
|
||||
// leave "unknown"; events will correct it
|
||||
}
|
||||
},
|
||||
|
||||
retryFfmpegDownload: async () => {
|
||||
set({ ffmpegStatus: "starting", ffmpegError: null, ffmpegProgress: null });
|
||||
try {
|
||||
await invoke("retry_ffmpeg_download");
|
||||
} catch (error) {
|
||||
set({ ffmpegStatus: "error", ffmpegError: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
loadOnboardingCompleted: async () => {
|
||||
try {
|
||||
const completed = await invoke<boolean>("get_onboarding_completed");
|
||||
set(
|
||||
completed
|
||||
? { onboardingCompleted: true }
|
||||
: { onboardingCompleted: false, onboardingOpen: true, onboardingStep: 0 },
|
||||
);
|
||||
} catch {
|
||||
// If the flag can't be read, don't trap the user in onboarding.
|
||||
set({ onboardingCompleted: true });
|
||||
}
|
||||
},
|
||||
|
||||
completeOnboarding: () => {
|
||||
set({ onboardingOpen: false, onboardingCompleted: true });
|
||||
void invoke("set_onboarding_completed", { completed: true }).catch(() => {});
|
||||
},
|
||||
|
||||
openOnboarding: () => set({ onboardingOpen: true, onboardingStep: 0 }),
|
||||
|
||||
setOnboardingStep: (step) => set({ onboardingStep: step }),
|
||||
|
||||
loadWorkerPausesPersist: async () => {
|
||||
try {
|
||||
const persist = await invoke<boolean>("get_worker_pauses_persist");
|
||||
set({ workerPausesPersist: persist });
|
||||
} catch {
|
||||
// fall back to in-memory default
|
||||
}
|
||||
},
|
||||
|
||||
setWorkerPausesPersist: (persist) => {
|
||||
set({ workerPausesPersist: persist });
|
||||
void invoke("set_worker_pauses_persist", { persist }).catch(() => {});
|
||||
},
|
||||
|
||||
loadWorkerStates: async () => {
|
||||
const folderIds = get().folders.map((folder) => folder.id);
|
||||
if (folderIds.length === 0) {
|
||||
set({ workerPaused: {} });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const states = await invoke<FolderWorkerStates[]>("get_worker_states", { folderIds });
|
||||
set({
|
||||
workerPaused: Object.fromEntries(
|
||||
states.map((state) => [
|
||||
state.folder_id,
|
||||
{
|
||||
thumbnail: state.thumbnail_paused,
|
||||
metadata: state.metadata_paused,
|
||||
embedding: state.embedding_paused,
|
||||
tagging: state.tagging_paused,
|
||||
},
|
||||
]),
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
// leave the existing snapshot in place
|
||||
}
|
||||
},
|
||||
|
||||
setWorkerPaused: (folderId, worker, paused) => {
|
||||
set((state) => {
|
||||
const current = state.workerPaused[folderId] ?? {
|
||||
thumbnail: false,
|
||||
metadata: false,
|
||||
embedding: false,
|
||||
tagging: false,
|
||||
};
|
||||
return {
|
||||
workerPaused: {
|
||||
...state.workerPaused,
|
||||
[folderId]: { ...current, [worker]: paused },
|
||||
},
|
||||
};
|
||||
});
|
||||
void invoke("set_worker_paused", { worker, folderId, paused }).catch(() => {});
|
||||
},
|
||||
|
||||
setAllWorkersPaused: (folderId, paused) => {
|
||||
set((state) => ({
|
||||
workerPaused: {
|
||||
...state.workerPaused,
|
||||
[folderId]: { thumbnail: paused, metadata: paused, embedding: paused, tagging: paused },
|
||||
},
|
||||
}));
|
||||
for (const worker of WORKER_KEYS) {
|
||||
void invoke("set_worker_paused", { worker, folderId, paused }).catch(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { StateCreator } from "zustand";
|
||||
import { initialAiCaptionsEnabled, replaceImage } from "./helpers";
|
||||
import type { GalleryStore } from "./index";
|
||||
import type {
|
||||
CaptionAcceleration,
|
||||
CaptionDetail,
|
||||
CaptionModelProgress,
|
||||
CaptionModelStatus,
|
||||
CaptionRuntimeProbe,
|
||||
CaptionVisionProbe,
|
||||
ImageRecord,
|
||||
} from "./types";
|
||||
|
||||
const AI_CAPTIONS_ENABLED_KEY = "phokus.aiCaptionsEnabled";
|
||||
|
||||
export interface CaptionSlice {
|
||||
captionModelStatus: CaptionModelStatus | null;
|
||||
captionModelPreparing: boolean;
|
||||
captionModelError: string | null;
|
||||
captionModelProgress: CaptionModelProgress | null;
|
||||
captionRuntimeProbe: CaptionRuntimeProbe | null;
|
||||
captionRuntimeChecking: boolean;
|
||||
captionAcceleration: CaptionAcceleration;
|
||||
captionDetail: CaptionDetail;
|
||||
aiCaptionsEnabled: boolean;
|
||||
|
||||
loadCaptionModelStatus: () => Promise<void>;
|
||||
prepareCaptionModel: () => Promise<void>;
|
||||
deleteCaptionModel: () => Promise<void>;
|
||||
probeCaptionRuntime: () => Promise<void>;
|
||||
probeCaptionImage: (imageId: number) => Promise<CaptionVisionProbe>;
|
||||
generateCaptionForImage: (imageId: number) => Promise<ImageRecord>;
|
||||
queueCaptionJobs: (folderId?: number | null) => Promise<number>;
|
||||
queueCaptionForImage: (imageId: number) => Promise<number>;
|
||||
clearCaptionJobs: (folderId?: number | null) => Promise<number>;
|
||||
resetGeneratedCaptions: (folderId?: number | null) => Promise<number>;
|
||||
loadCaptionAcceleration: () => Promise<void>;
|
||||
setCaptionAcceleration: (acceleration: CaptionAcceleration) => Promise<void>;
|
||||
loadCaptionDetail: () => Promise<void>;
|
||||
setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
|
||||
setAiCaptionsEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const createCaptionSlice: StateCreator<GalleryStore, [], [], CaptionSlice> = (set, get) => ({
|
||||
captionModelStatus: null,
|
||||
captionModelPreparing: false,
|
||||
captionModelError: null,
|
||||
captionModelProgress: null,
|
||||
captionRuntimeProbe: null,
|
||||
captionRuntimeChecking: false,
|
||||
captionAcceleration: "auto",
|
||||
captionDetail: "paragraph",
|
||||
aiCaptionsEnabled: initialAiCaptionsEnabled(AI_CAPTIONS_ENABLED_KEY),
|
||||
|
||||
loadCaptionModelStatus: async () => {
|
||||
try {
|
||||
const captionModelStatus = await invoke<CaptionModelStatus>("get_caption_model_status");
|
||||
set({ captionModelStatus, captionModelError: null });
|
||||
} catch (error) {
|
||||
set({ captionModelError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
loadCaptionAcceleration: async () => {
|
||||
try {
|
||||
const captionAcceleration = await invoke<CaptionAcceleration>("get_caption_acceleration");
|
||||
set({ captionAcceleration });
|
||||
} catch (error) {
|
||||
set({ captionModelError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
setCaptionAcceleration: async (acceleration) => {
|
||||
const captionAcceleration = await invoke<CaptionAcceleration>("set_caption_acceleration", {
|
||||
params: { acceleration },
|
||||
});
|
||||
set({ captionAcceleration, captionRuntimeProbe: null });
|
||||
},
|
||||
|
||||
loadCaptionDetail: async () => {
|
||||
try {
|
||||
const captionDetail = await invoke<CaptionDetail>("get_caption_detail");
|
||||
set({ captionDetail });
|
||||
} catch (error) {
|
||||
set({ captionModelError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
setCaptionDetail: async (detail) => {
|
||||
const captionDetail = await invoke<CaptionDetail>("set_caption_detail", {
|
||||
params: { detail },
|
||||
});
|
||||
set({ captionDetail, captionRuntimeProbe: null });
|
||||
},
|
||||
|
||||
prepareCaptionModel: async () => {
|
||||
set({ captionModelPreparing: true, captionModelError: null, captionModelProgress: null });
|
||||
try {
|
||||
const captionModelStatus = await invoke<CaptionModelStatus>("prepare_caption_model");
|
||||
window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, String(captionModelStatus.ready));
|
||||
set({ captionModelStatus, captionModelPreparing: false, captionModelError: null, captionModelProgress: null, aiCaptionsEnabled: captionModelStatus.ready });
|
||||
} catch (error) {
|
||||
set({ captionModelPreparing: false, captionModelError: String(error), captionModelProgress: null });
|
||||
}
|
||||
},
|
||||
|
||||
deleteCaptionModel: async () => {
|
||||
set({ captionModelPreparing: true, captionModelError: null, captionModelProgress: null });
|
||||
try {
|
||||
const captionModelStatus = await invoke<CaptionModelStatus>("delete_caption_model");
|
||||
window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, "false");
|
||||
set({ captionModelStatus, captionModelPreparing: false, captionModelError: null, captionModelProgress: null, captionRuntimeProbe: null, aiCaptionsEnabled: false });
|
||||
} catch (error) {
|
||||
set({ captionModelPreparing: false, captionModelError: String(error), captionModelProgress: null });
|
||||
}
|
||||
},
|
||||
|
||||
probeCaptionRuntime: async () => {
|
||||
set({ captionRuntimeChecking: true, captionModelError: null });
|
||||
try {
|
||||
const captionRuntimeProbe = await invoke<CaptionRuntimeProbe>("probe_caption_runtime");
|
||||
set({ captionRuntimeProbe, captionRuntimeChecking: false, captionModelError: null });
|
||||
} catch (error) {
|
||||
set({ captionRuntimeChecking: false, captionModelError: String(error), captionRuntimeProbe: null });
|
||||
}
|
||||
},
|
||||
|
||||
probeCaptionImage: async (imageId) => {
|
||||
return invoke<CaptionVisionProbe>("probe_caption_image", {
|
||||
params: { image_id: imageId },
|
||||
});
|
||||
},
|
||||
|
||||
generateCaptionForImage: async (imageId) => {
|
||||
const updatedImage = await invoke<ImageRecord>("generate_caption_for_image", {
|
||||
params: { image_id: imageId },
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
images: replaceImage(state.images, updatedImage, state.sort),
|
||||
selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage,
|
||||
}));
|
||||
|
||||
return updatedImage;
|
||||
},
|
||||
|
||||
queueCaptionJobs: async (folderId = get().selectedFolderId) => {
|
||||
const queued = await invoke<number>("queue_caption_jobs", {
|
||||
params: { folder_id: folderId ?? null, image_id: null },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
return queued;
|
||||
},
|
||||
|
||||
queueCaptionForImage: async (imageId) => {
|
||||
const queued = await invoke<number>("queue_caption_jobs", {
|
||||
params: { folder_id: null, image_id: imageId },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
return queued;
|
||||
},
|
||||
|
||||
clearCaptionJobs: async (folderId = get().selectedFolderId) => {
|
||||
const cleared = await invoke<number>("clear_caption_jobs", {
|
||||
params: { folder_id: folderId ?? null },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
return cleared;
|
||||
},
|
||||
|
||||
resetGeneratedCaptions: async (folderId = get().selectedFolderId) => {
|
||||
const reset = await invoke<number>("reset_generated_captions", {
|
||||
params: { folder_id: folderId ?? null },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
await get().loadImages(true);
|
||||
return reset;
|
||||
},
|
||||
|
||||
setAiCaptionsEnabled: (aiCaptionsEnabled) => {
|
||||
window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, String(aiCaptionsEnabled));
|
||||
set({ aiCaptionsEnabled });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { StateCreator } from "zustand";
|
||||
import { notifyTaskComplete } from "../notifications";
|
||||
import type { GalleryStore } from "./index";
|
||||
import type { DuplicateGroup, DuplicateScanProgress, DuplicateScanResult } from "./types";
|
||||
|
||||
export interface DuplicateSlice {
|
||||
duplicateGroups: DuplicateGroup[];
|
||||
duplicateScanning: boolean;
|
||||
duplicateScanProgress: DuplicateScanProgress | null;
|
||||
duplicateScanError: string | null;
|
||||
duplicateScanWarning: string | null;
|
||||
duplicateSelectedIds: Set<number>;
|
||||
duplicateLastScanned: number | null; // Unix timestamp (seconds)
|
||||
duplicateScanFolderId: number | null | undefined; // undefined = never scanned
|
||||
|
||||
loadDuplicateScanCache: (folderId?: number | null) => Promise<void>;
|
||||
scanDuplicates: (folderId?: number | null) => Promise<void>;
|
||||
toggleDuplicateSelected: (imageId: number) => void;
|
||||
selectAllDuplicates: (imageIds: number[]) => void;
|
||||
selectKeepFirstAllGroups: () => void;
|
||||
clearDuplicateSelection: () => void;
|
||||
deleteSelectedDuplicates: () => Promise<number>;
|
||||
}
|
||||
|
||||
export const createDuplicateSlice: StateCreator<GalleryStore, [], [], DuplicateSlice> = (set, get) => ({
|
||||
duplicateGroups: [],
|
||||
duplicateScanning: false,
|
||||
duplicateScanProgress: null,
|
||||
duplicateScanError: null,
|
||||
duplicateScanWarning: null,
|
||||
duplicateSelectedIds: new Set(),
|
||||
duplicateLastScanned: null,
|
||||
duplicateScanFolderId: undefined,
|
||||
|
||||
loadDuplicateScanCache: async (folderId = null) => {
|
||||
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
|
||||
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
|
||||
if (cached) {
|
||||
set({
|
||||
duplicateGroups: cached.groups,
|
||||
duplicateLastScanned: cached.scanned_at,
|
||||
duplicateScanFolderId: folderId,
|
||||
duplicateScanWarning: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
scanDuplicates: async (folderId = null) => {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
set({
|
||||
duplicateScanning: true,
|
||||
duplicateGroups: [],
|
||||
duplicateScanProgress: null,
|
||||
duplicateScanError: null,
|
||||
duplicateScanWarning: null,
|
||||
duplicateSelectedIds: new Set(),
|
||||
});
|
||||
const unlisten = await listen<DuplicateScanProgress>("duplicate_scan_progress", (event) => {
|
||||
set({ duplicateScanProgress: event.payload });
|
||||
});
|
||||
try {
|
||||
const result = await invoke<DuplicateScanResult>("find_duplicates", { folderId: folderId ?? null });
|
||||
const warning = result.skipped_files > 0
|
||||
? `${result.skipped_files.toLocaleString()} file${result.skipped_files === 1 ? "" : "s"} could not be read and were skipped.`
|
||||
: null;
|
||||
set({
|
||||
duplicateGroups: result.groups,
|
||||
duplicateLastScanned: Math.floor(Date.now() / 1000),
|
||||
duplicateScanFolderId: folderId,
|
||||
duplicateScanWarning: warning,
|
||||
});
|
||||
void notifyTaskComplete(
|
||||
"Duplicate scan complete",
|
||||
`${result.groups.length === 1 ? "Found 1 duplicate group." : `Found ${result.groups.length.toLocaleString()} duplicate groups.`}${warning ? ` ${warning}` : ""}`,
|
||||
);
|
||||
} catch (e) {
|
||||
set({ duplicateScanError: String(e) });
|
||||
} finally {
|
||||
unlisten();
|
||||
set({ duplicateScanning: false });
|
||||
}
|
||||
},
|
||||
|
||||
toggleDuplicateSelected: (imageId) => {
|
||||
set((state) => {
|
||||
const next = new Set(state.duplicateSelectedIds);
|
||||
if (next.has(imageId)) next.delete(imageId);
|
||||
else next.add(imageId);
|
||||
return { duplicateSelectedIds: next };
|
||||
});
|
||||
},
|
||||
|
||||
selectAllDuplicates: (imageIds) => {
|
||||
set((state) => {
|
||||
const next = new Set(state.duplicateSelectedIds);
|
||||
for (const id of imageIds) next.add(id);
|
||||
return { duplicateSelectedIds: next };
|
||||
});
|
||||
},
|
||||
|
||||
selectKeepFirstAllGroups: () => {
|
||||
const { duplicateGroups } = get();
|
||||
const toMark = new Set<number>();
|
||||
for (const group of duplicateGroups) {
|
||||
for (const img of group.images.slice(1)) toMark.add(img.id);
|
||||
}
|
||||
set({ duplicateSelectedIds: toMark });
|
||||
},
|
||||
|
||||
clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }),
|
||||
|
||||
deleteSelectedDuplicates: async () => {
|
||||
const { duplicateSelectedIds, duplicateGroups } = get();
|
||||
const ids = Array.from(duplicateSelectedIds);
|
||||
if (ids.length === 0) return 0;
|
||||
// Backend returns only the IDs that were actually removed from disk.
|
||||
const succeededIds = await invoke<number[]>("delete_images_from_disk", { params: { image_ids: ids } });
|
||||
const succeededSet = new Set(succeededIds);
|
||||
// Only remove images confirmed deleted — failed files remain visible so the user can retry.
|
||||
set((state) => ({
|
||||
duplicateSelectedIds: new Set(),
|
||||
duplicateGroups: state.duplicateGroups
|
||||
.map((g) => ({ ...g, images: g.images.filter((img) => !succeededSet.has(img.id)) }))
|
||||
.filter((g) => g.images.length > 1),
|
||||
}));
|
||||
// Invalidate the persisted cache for every affected scope:
|
||||
// - global "all" cache (always, since a folder-scoped deletion still makes the global result stale)
|
||||
// - each folder that contained a deleted image (so a folder-scoped scan is also evicted)
|
||||
const affectedFolderIds = new Set<number>(
|
||||
duplicateGroups
|
||||
.flatMap((g) => g.images)
|
||||
.filter((img) => succeededSet.has(img.id))
|
||||
.map((img) => img.folder_id),
|
||||
);
|
||||
await invoke("invalidate_duplicate_scan_cache", { folderId: null }); // global
|
||||
for (const folderId of affectedFolderIds) {
|
||||
await invoke("invalidate_duplicate_scan_cache", { folderId });
|
||||
}
|
||||
return succeededIds.length;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type { StoreApi } from "zustand";
|
||||
import { notifyTaskComplete } from "../notifications";
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
countNewImages,
|
||||
imagesAffectScope,
|
||||
isDerivedCollectionTitle,
|
||||
matchesFilters,
|
||||
mergeIntoVisibleWindow,
|
||||
replaceExistingImages,
|
||||
scopeHasTaggingPending,
|
||||
taggingProgressAffectsScope,
|
||||
} from "./helpers";
|
||||
import type { GalleryStore } from "./index";
|
||||
import type {
|
||||
CaptionModelProgress,
|
||||
FfmpegProgressEvent,
|
||||
IndexProgress,
|
||||
IndexedImagesBatch,
|
||||
MediaJobProgressEvent,
|
||||
TaggerModelProgress,
|
||||
ThumbnailBatch,
|
||||
} from "./types";
|
||||
|
||||
// Per-folder debounce timers for batching notifications.
|
||||
// Keyed as `${folderId}:embedding` or `${folderId}:tagging`.
|
||||
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const NOTIFICATION_DEBOUNCE_MS = 6000;
|
||||
|
||||
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const EXPLORE_TAG_REFRESH_IDLE_MS = 900;
|
||||
const EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS = 4500;
|
||||
|
||||
function scheduleExploreTagRefresh(load: () => void, delayMs: number) {
|
||||
if (exploreTagRefreshTimer) clearTimeout(exploreTagRefreshTimer);
|
||||
exploreTagRefreshTimer = setTimeout(() => {
|
||||
exploreTagRefreshTimer = null;
|
||||
load();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
export async function subscribeToProgress(
|
||||
set: StoreApi<GalleryStore>["setState"],
|
||||
get: StoreApi<GalleryStore>["getState"],
|
||||
): Promise<UnlistenFn> {
|
||||
const unlistenProgress = await listen<IndexProgress>("index-progress", (event) => {
|
||||
const progress = event.payload;
|
||||
const previous = get().indexingProgress[progress.folder_id];
|
||||
set((state) => ({
|
||||
indexingProgress: {
|
||||
...state.indexingProgress,
|
||||
[progress.folder_id]: progress,
|
||||
},
|
||||
}));
|
||||
|
||||
if (progress.done) {
|
||||
if (
|
||||
previous &&
|
||||
!previous.done &&
|
||||
progress.total > 0 &&
|
||||
progress.indexed >= progress.total
|
||||
) {
|
||||
const { notificationsPaused, mutedFolderIds } = get();
|
||||
if (!notificationsPaused && !mutedFolderIds.includes(progress.folder_id)) {
|
||||
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)) {
|
||||
void get().loadImages(true);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
set((state) => {
|
||||
const next = { ...state.indexingProgress };
|
||||
delete next[progress.folder_id];
|
||||
return { indexingProgress: next };
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
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 { notificationsPaused, mutedFolderIds } = get();
|
||||
const suppressed = notificationsPaused || mutedFolderIds.includes(progress.folder_id);
|
||||
const folderName =
|
||||
get().folders.find((folder) => folder.id === progress.folder_id)?.name ?? "Folder";
|
||||
|
||||
// Embeddings — debounced so rapid file additions don't fire per-file.
|
||||
const embeddingKey = `${progress.folder_id}:embedding`;
|
||||
if (!suppressed) {
|
||||
if (previous.embedding_pending > 0 && progress.embedding_pending === 0) {
|
||||
clearTimeout(notificationTimers.get(embeddingKey));
|
||||
const failureDetail =
|
||||
progress.embedding_failed > 0
|
||||
? ` ${progress.embedding_failed.toLocaleString()} failed.`
|
||||
: "";
|
||||
const body = `${folderName} finished generating embeddings.${failureDetail}`;
|
||||
notificationTimers.set(embeddingKey, setTimeout(() => {
|
||||
notificationTimers.delete(embeddingKey);
|
||||
void notifyTaskComplete("Embeddings complete", body);
|
||||
}, NOTIFICATION_DEBOUNCE_MS));
|
||||
} else if (previous.embedding_pending === 0 && progress.embedding_pending > 0) {
|
||||
// More jobs queued — cancel the pending notification.
|
||||
clearTimeout(notificationTimers.get(embeddingKey));
|
||||
notificationTimers.delete(embeddingKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Tagging — same debounce pattern.
|
||||
const taggingKey = `${progress.folder_id}:tagging`;
|
||||
if (!suppressed) {
|
||||
if (previous.tagging_pending > 0 && progress.tagging_pending === 0) {
|
||||
clearTimeout(notificationTimers.get(taggingKey));
|
||||
const failureDetail =
|
||||
progress.tagging_failed > 0
|
||||
? ` ${progress.tagging_failed.toLocaleString()} failed.`
|
||||
: "";
|
||||
const body = `${folderName} finished generating tags.${failureDetail}`;
|
||||
notificationTimers.set(taggingKey, setTimeout(() => {
|
||||
notificationTimers.delete(taggingKey);
|
||||
void notifyTaskComplete("AI tagging complete", body);
|
||||
}, NOTIFICATION_DEBOUNCE_MS));
|
||||
const state = get();
|
||||
if (taggingProgressAffectsScope(progress.folder_id, state.selectedFolderId)) {
|
||||
// New tags landed — refresh the Explore tag cloud after the worker
|
||||
// settles instead of waiting for the user to revisit Explore.
|
||||
set({ exploreTagsFolderId: undefined });
|
||||
if (state.activeView === "explore" && state.exploreMode === "tags") {
|
||||
scheduleExploreTagRefresh(() => {
|
||||
void get().loadExploreTags({ force: true });
|
||||
}, EXPLORE_TAG_REFRESH_IDLE_MS);
|
||||
}
|
||||
}
|
||||
} else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) {
|
||||
clearTimeout(notificationTimers.get(taggingKey));
|
||||
notificationTimers.delete(taggingKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const next = { ...state.mediaJobProgress };
|
||||
for (const progress of event.payload.progress) {
|
||||
next[progress.folder_id] = progress;
|
||||
}
|
||||
return { mediaJobProgress: next };
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenCaptionModelProgress = await listen<CaptionModelProgress>("caption-model-progress", (event) => {
|
||||
set({
|
||||
captionModelProgress: event.payload.done ? null : event.payload,
|
||||
captionModelPreparing: !event.payload.done,
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenTaggerModelProgress = await listen<TaggerModelProgress>("tagger-model-progress", (event) => {
|
||||
set({
|
||||
taggerModelProgress: event.payload.done ? null : event.payload,
|
||||
taggerModelPreparing: !event.payload.done,
|
||||
});
|
||||
if (event.payload.done) {
|
||||
void get().loadTaggerModelStatus();
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
|
||||
const batch = event.payload;
|
||||
|
||||
set((state) => {
|
||||
// Album view holds a fixed membership set; newly-indexed files never
|
||||
// auto-join it. Guarding on activeView also covers the brief window
|
||||
// where collectionTitle is null mid sort-change in an album.
|
||||
if (
|
||||
isDerivedCollectionTitle(state.collectionTitle) ||
|
||||
state.activeView === "explore" ||
|
||||
state.activeView === "album" ||
|
||||
state.colorFilter !== null
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const visibleImages = batch.images.filter((image) =>
|
||||
matchesFilters(
|
||||
image,
|
||||
state.selectedFolderId,
|
||||
state.mediaFilter,
|
||||
state.favoritesOnly,
|
||||
state.minimumRating,
|
||||
state.failedEmbeddingsOnly,
|
||||
state.failedTaggingOnly,
|
||||
state.search,
|
||||
),
|
||||
);
|
||||
|
||||
if (visibleImages.length === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const newVisibleCount = countNewImages(state.images, visibleImages);
|
||||
const visibleWindow = Math.max(state.loadedCount, PAGE_SIZE);
|
||||
const images = mergeIntoVisibleWindow(state.images, visibleImages, state.sort, visibleWindow);
|
||||
return {
|
||||
images,
|
||||
loadedCount: Math.max(state.loadedCount, Math.min(images.length, visibleWindow)),
|
||||
totalImages: Math.max(state.totalImages + newVisibleCount, images.length),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
|
||||
const batch = event.payload;
|
||||
const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null);
|
||||
const state = get();
|
||||
if (taggingUpdated && imagesAffectScope(batch.images, state.selectedFolderId)) {
|
||||
set({ exploreTagsFolderId: undefined });
|
||||
if (state.activeView === "explore" && state.exploreMode === "tags") {
|
||||
const delay = scopeHasTaggingPending(state.mediaJobProgress, state.selectedFolderId)
|
||||
? EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS
|
||||
: EXPLORE_TAG_REFRESH_IDLE_MS;
|
||||
scheduleExploreTagRefresh(() => {
|
||||
void get().loadExploreTags({ force: true });
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const selectedImageUpdate =
|
||||
state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id)
|
||||
? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage
|
||||
: state.selectedImage;
|
||||
|
||||
// Album view holds already-loaded images; paint thumbnail/metadata
|
||||
// fills in place (without re-sorting) so tiles refresh while browsing.
|
||||
if (state.activeView === "album") {
|
||||
return {
|
||||
images: replaceExistingImages(state.images, batch.images),
|
||||
selectedImage: selectedImageUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
|
||||
return { selectedImage: selectedImageUpdate };
|
||||
}
|
||||
|
||||
const visibleImages = batch.images.filter((image) =>
|
||||
matchesFilters(
|
||||
image,
|
||||
state.selectedFolderId,
|
||||
state.mediaFilter,
|
||||
state.favoritesOnly,
|
||||
state.minimumRating,
|
||||
state.failedEmbeddingsOnly,
|
||||
state.failedTaggingOnly,
|
||||
state.search,
|
||||
),
|
||||
);
|
||||
|
||||
if (visibleImages.length === 0) {
|
||||
return { selectedImage: selectedImageUpdate };
|
||||
}
|
||||
|
||||
return {
|
||||
images: replaceExistingImages(state.images, visibleImages),
|
||||
selectedImage: selectedImageUpdate,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenWatcherDeleted = await listen<number[]>("watcher-deleted", (event) => {
|
||||
const deletedIds = new Set(event.payload);
|
||||
set((state) => {
|
||||
const removed = state.images.filter((img) => deletedIds.has(img.id)).length;
|
||||
const images = state.images.filter((img) => !deletedIds.has(img.id));
|
||||
const selectedImage =
|
||||
state.selectedImage && deletedIds.has(state.selectedImage.id)
|
||||
? null
|
||||
: state.selectedImage;
|
||||
return {
|
||||
images,
|
||||
totalImages: Math.max(0, state.totalImages - removed),
|
||||
loadedCount: Math.max(0, state.loadedCount - removed),
|
||||
selectedImage,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenFolderCounts = await listen("folder-counts-changed", () => {
|
||||
void get().loadFolders();
|
||||
});
|
||||
|
||||
const unlistenFfmpegProgress = await listen<FfmpegProgressEvent>("ffmpeg-progress", (event) => {
|
||||
const payload = event.payload;
|
||||
switch (payload.phase) {
|
||||
case "starting":
|
||||
set({ ffmpegStatus: "starting", ffmpegError: null });
|
||||
break;
|
||||
case "downloading":
|
||||
set({
|
||||
ffmpegStatus: "downloading",
|
||||
ffmpegProgress:
|
||||
payload.downloaded_bytes !== null && payload.total_bytes !== null
|
||||
? { downloaded_bytes: payload.downloaded_bytes, total_bytes: payload.total_bytes }
|
||||
: null,
|
||||
});
|
||||
break;
|
||||
case "unpacking":
|
||||
set({ ffmpegStatus: "unpacking" });
|
||||
break;
|
||||
case "done":
|
||||
set({ ffmpegStatus: "installed", ffmpegProgress: null, ffmpegError: null });
|
||||
break;
|
||||
case "error":
|
||||
set({ ffmpegStatus: "error", ffmpegError: payload.error ?? "Download failed" });
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenColorBackfill = await listen<{ processed: number; total: number; done: boolean }>(
|
||||
"color-backfill-progress",
|
||||
(event) => {
|
||||
set({ colorBackfill: event.payload.done ? null : event.payload });
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (exploreTagRefreshTimer) {
|
||||
clearTimeout(exploreTagRefreshTimer);
|
||||
exploreTagRefreshTimer = null;
|
||||
}
|
||||
unlistenProgress();
|
||||
unlistenMediaJobs();
|
||||
unlistenCaptionModelProgress();
|
||||
unlistenTaggerModelProgress();
|
||||
unlistenImages();
|
||||
unlistenThumbnails();
|
||||
unlistenWatcherDeleted();
|
||||
unlistenFolderCounts();
|
||||
unlistenFfmpegProgress();
|
||||
unlistenColorBackfill();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { StateCreator } from "zustand";
|
||||
import { isCurrentGalleryRequest, nextGalleryRequestToken, parseSearchValue } from "./helpers";
|
||||
import type { GalleryStore } from "./index";
|
||||
import type {
|
||||
ExploreMode,
|
||||
ExploreTagEntry,
|
||||
ImageExif,
|
||||
ImageRecord,
|
||||
ImageTag,
|
||||
RelatedTagEntry,
|
||||
VisualClusterEntry,
|
||||
} from "./types";
|
||||
|
||||
let visualClusterRequestToken = 0;
|
||||
let exploreTagRequestToken = 0;
|
||||
|
||||
export interface ExploreSlice {
|
||||
exploreMode: ExploreMode;
|
||||
tagManagerOpen: boolean;
|
||||
visualClusterEntries: VisualClusterEntry[];
|
||||
visualClusterLoading: boolean;
|
||||
visualClusterFolderId: number | null | undefined; // undefined = never loaded
|
||||
exploreTagEntries: ExploreTagEntry[];
|
||||
exploreTagLoading: boolean;
|
||||
// Cache-freshness key: the folder the loaded tags belong to. Set to undefined
|
||||
// by content mutations (tag add/remove/rename/delete) to mark the cache dirty
|
||||
// and force the next load to refetch.
|
||||
exploreTagsFolderId: number | null | undefined;
|
||||
// The folder whose tags are actually on screen. Kept separate from the dirty
|
||||
// marker above so a same-folder invalidation isn't mistaken for a folder switch
|
||||
// (which would wipe the visible list and remount manager UI mid-refresh).
|
||||
exploreTagsShownFolderId: number | null | undefined;
|
||||
relatedTagsByKey: Record<string, RelatedTagEntry[]>;
|
||||
|
||||
setExploreMode: (mode: ExploreMode) => void;
|
||||
setTagManagerOpen: (open: boolean) => void;
|
||||
openTagManager: () => void;
|
||||
loadVisualClusters: (options?: { force?: boolean }) => Promise<void>;
|
||||
loadExploreTags: (options?: { force?: boolean }) => Promise<void>;
|
||||
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
|
||||
showVisualCluster: (imageIds: number[]) => Promise<void>;
|
||||
suggestImageTags: (imageId: number) => Promise<string[]>;
|
||||
getImageTags: (imageId: number) => Promise<ImageTag[]>;
|
||||
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>;
|
||||
removeTag: (tagId: number) => Promise<void>;
|
||||
getImageExif: (imageId: number) => Promise<ImageExif>;
|
||||
renameTag: (from: string, to: string) => Promise<void>;
|
||||
deleteTag: (tag: string) => Promise<number>;
|
||||
}
|
||||
|
||||
export const createExploreSlice: StateCreator<GalleryStore, [], [], ExploreSlice> = (set, get) => ({
|
||||
exploreMode: "visual",
|
||||
tagManagerOpen: false,
|
||||
visualClusterEntries: [],
|
||||
visualClusterLoading: false,
|
||||
visualClusterFolderId: undefined,
|
||||
exploreTagEntries: [],
|
||||
exploreTagLoading: false,
|
||||
exploreTagsFolderId: undefined,
|
||||
exploreTagsShownFolderId: undefined,
|
||||
relatedTagsByKey: {},
|
||||
|
||||
setExploreMode: (exploreMode) =>
|
||||
// Manage mode only exists for the tag view; drop it when switching to visual
|
||||
// clusters so re-entering the tag view starts in the normal browse state.
|
||||
set(exploreMode === "visual" ? { exploreMode, tagManagerOpen: false } : { exploreMode }),
|
||||
setTagManagerOpen: (tagManagerOpen) => set({ tagManagerOpen }),
|
||||
// Jump straight to the tag manager from anywhere (e.g. the Settings panel):
|
||||
// switch to Explore, select the tag view, open manage mode, and close Settings.
|
||||
openTagManager: () => {
|
||||
get().setView("explore");
|
||||
set({ exploreMode: "tags", tagManagerOpen: true, settingsOpen: false });
|
||||
},
|
||||
|
||||
loadVisualClusters: async (options) => {
|
||||
const { selectedFolderId, visualClusterFolderId, visualClusterLoading } = get();
|
||||
const force = options?.force ?? false;
|
||||
// Skip if already loaded for this folder and not currently loading
|
||||
if (!force && !visualClusterLoading && visualClusterFolderId !== undefined && visualClusterFolderId === selectedFolderId) {
|
||||
return;
|
||||
}
|
||||
const requestToken = ++visualClusterRequestToken;
|
||||
// On a real folder switch, drop the previous folder's clusters so the loading
|
||||
// panel shows instead of lingering stale results. A same-folder refresh keeps
|
||||
// them to avoid a flicker when the cache returns instantly.
|
||||
const isFolderSwitch = visualClusterFolderId !== selectedFolderId;
|
||||
set({
|
||||
visualClusterLoading: true,
|
||||
visualClusterFolderId: selectedFolderId,
|
||||
...(isFolderSwitch ? { visualClusterEntries: [] } : {}),
|
||||
});
|
||||
try {
|
||||
const entries = await invoke<VisualClusterEntry[]>("get_visual_clusters", {
|
||||
folderId: selectedFolderId,
|
||||
});
|
||||
if (requestToken !== visualClusterRequestToken) return;
|
||||
set({ visualClusterEntries: entries, visualClusterLoading: false });
|
||||
} catch (error) {
|
||||
if (requestToken !== visualClusterRequestToken) return;
|
||||
console.error("Failed to load tag cloud:", error);
|
||||
set({ visualClusterLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadExploreTags: async (options) => {
|
||||
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
|
||||
const force = options?.force ?? false;
|
||||
if (!force && exploreTagLoading) {
|
||||
return;
|
||||
}
|
||||
if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
|
||||
return;
|
||||
}
|
||||
const requestToken = ++exploreTagRequestToken;
|
||||
// A real folder switch is decided by what's currently *shown*, not by the
|
||||
// dirty marker — a same-folder invalidation nulls exploreTagsFolderId but
|
||||
// leaves exploreTagsShownFolderId pointing at the displayed folder, so the
|
||||
// visible list (and manager UI state) survives the refresh. On an actual
|
||||
// switch, drop the previous folder's tags so the loading panel shows.
|
||||
const { exploreTagsShownFolderId } = get();
|
||||
const isFolderSwitch =
|
||||
exploreTagsShownFolderId !== undefined && exploreTagsShownFolderId !== selectedFolderId;
|
||||
set({
|
||||
exploreTagLoading: true,
|
||||
exploreTagsFolderId: selectedFolderId,
|
||||
exploreTagsShownFolderId: selectedFolderId,
|
||||
...(isFolderSwitch ? { exploreTagEntries: [], relatedTagsByKey: {} } : {}),
|
||||
});
|
||||
try {
|
||||
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
|
||||
params: { folder_id: selectedFolderId, limit: 180 },
|
||||
});
|
||||
if (requestToken !== exploreTagRequestToken) return;
|
||||
set({ exploreTagEntries: entries, exploreTagLoading: false, relatedTagsByKey: {} });
|
||||
} catch (error) {
|
||||
if (requestToken !== exploreTagRequestToken) return;
|
||||
console.error("Failed to load explore tags:", error);
|
||||
set({ exploreTagLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadRelatedTags: async (tag) => {
|
||||
const trimmed = tag.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const { selectedFolderId, relatedTagsByKey } = get();
|
||||
const key = `${selectedFolderId ?? "all"}:${trimmed}`;
|
||||
if (relatedTagsByKey[key]) {
|
||||
return relatedTagsByKey[key];
|
||||
}
|
||||
|
||||
const entries = await invoke<RelatedTagEntry[]>("get_related_tags", {
|
||||
params: { tag: trimmed, folder_id: selectedFolderId, limit: 18 },
|
||||
});
|
||||
set((state) => ({
|
||||
relatedTagsByKey: {
|
||||
...state.relatedTagsByKey,
|
||||
[key]: entries,
|
||||
},
|
||||
}));
|
||||
return entries;
|
||||
},
|
||||
|
||||
showVisualCluster: async (imageIds) => {
|
||||
const requestToken = nextGalleryRequestToken();
|
||||
set((state) => ({
|
||||
activeView: "gallery",
|
||||
search: "",
|
||||
images: [],
|
||||
totalImages: imageIds.length,
|
||||
loadedCount: 0,
|
||||
loadingImages: true,
|
||||
collectionTitle: "Explore Cluster",
|
||||
imageLoadError: null,
|
||||
similarSourceImageId: null,
|
||||
similarSourceFolderId: null,
|
||||
similarHasMore: false,
|
||||
similarFolderId: null,
|
||||
gallerySelectedIds: new Set<number>(),
|
||||
selectedAlbumId: null,
|
||||
galleryScrollResetKey: state.galleryScrollResetKey + 1,
|
||||
}));
|
||||
|
||||
try {
|
||||
const images = await invoke<ImageRecord[]>("get_images_by_ids", {
|
||||
params: { image_ids: imageIds },
|
||||
});
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
set({
|
||||
images,
|
||||
totalImages: images.length,
|
||||
loadedCount: images.length,
|
||||
loadingImages: false,
|
||||
imageLoadError: null,
|
||||
collectionTitle: "Explore Cluster",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
set({
|
||||
images: [],
|
||||
totalImages: 0,
|
||||
loadedCount: 0,
|
||||
loadingImages: false,
|
||||
imageLoadError: String(error),
|
||||
collectionTitle: "Explore Cluster",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
suggestImageTags: async (imageId) => {
|
||||
return invoke<string[]>("suggest_image_tags", {
|
||||
params: { image_id: imageId, limit: 2 },
|
||||
});
|
||||
},
|
||||
|
||||
getImageTags: async (imageId) => {
|
||||
return invoke<ImageTag[]>("get_image_tags", {
|
||||
params: { image_id: imageId },
|
||||
});
|
||||
},
|
||||
|
||||
addUserTag: async (imageId, 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 });
|
||||
},
|
||||
|
||||
getImageExif: async (imageId) => {
|
||||
return invoke<ImageExif>("get_image_exif", { params: { image_id: imageId } });
|
||||
},
|
||||
|
||||
renameTag: async (from, to) => {
|
||||
await invoke("rename_tag", { params: { from, to } });
|
||||
// Tag content changed — invalidate the explore-tags and visual-cluster caches.
|
||||
// Keep the current tag list visible while the refresh runs so manager UI
|
||||
// state such as filtering and sorting is not lost to a loading remount.
|
||||
set({
|
||||
exploreTagsFolderId: undefined,
|
||||
visualClusterFolderId: undefined,
|
||||
visualClusterEntries: [],
|
||||
});
|
||||
const parsed = parseSearchValue(get().search);
|
||||
if (parsed.mode === "tag" && parsed.query === from) {
|
||||
// An active tag-search points at the old name — repoint it so the gallery
|
||||
// refreshes instead of showing stale results for a tag that no longer exists.
|
||||
get().setSearch(`/t ${to}`);
|
||||
} else if (get().activeView === "explore") {
|
||||
await get().loadExploreTags();
|
||||
}
|
||||
},
|
||||
|
||||
deleteTag: async (tag) => {
|
||||
const removed = await invoke<number>("delete_tag", { params: { tag } });
|
||||
// Keep the current tag list visible while the refresh runs so manager UI
|
||||
// state such as filtering and sorting is not lost to a loading remount.
|
||||
set({
|
||||
exploreTagsFolderId: undefined,
|
||||
visualClusterFolderId: undefined,
|
||||
visualClusterEntries: [],
|
||||
});
|
||||
const parsed = parseSearchValue(get().search);
|
||||
if (parsed.mode === "tag" && parsed.query === tag) {
|
||||
// The searched tag is gone — reload so the now-empty result is reflected.
|
||||
void get().loadImages(true);
|
||||
} else if (get().activeView === "explore") {
|
||||
await get().loadExploreTags();
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { StateCreator } from "zustand";
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
isCurrentGalleryRequest,
|
||||
isDerivedCollectionTitle,
|
||||
mergeImages,
|
||||
nextGalleryRequestToken,
|
||||
parseSearchValue,
|
||||
replaceExistingImages,
|
||||
replaceImage,
|
||||
} from "./helpers";
|
||||
import type { GalleryStore } from "./index";
|
||||
import type {
|
||||
ActiveView,
|
||||
ImageRecord,
|
||||
MediaFilter,
|
||||
SimilarImagesPage,
|
||||
SortOrder,
|
||||
ZoomPreset,
|
||||
} from "./types";
|
||||
|
||||
export interface GallerySlice {
|
||||
images: ImageRecord[];
|
||||
totalImages: number;
|
||||
loadedCount: number;
|
||||
loadingImages: boolean;
|
||||
imageLoadError: string | null;
|
||||
sort: SortOrder;
|
||||
mediaFilter: MediaFilter;
|
||||
favoritesOnly: boolean;
|
||||
minimumRating: number;
|
||||
failedEmbeddingsOnly: boolean;
|
||||
failedTaggingOnly: boolean;
|
||||
colorFilter: [number, number, number] | null; // [r,g,b] dominant-color filter
|
||||
colorBackfill: { processed: number; total: number; done: boolean } | null;
|
||||
zoomPreset: ZoomPreset;
|
||||
selectedImage: ImageRecord | null;
|
||||
collectionTitle: string | null;
|
||||
galleryScrollResetKey: number;
|
||||
activeView: ActiveView;
|
||||
gallerySelectedIds: Set<number>;
|
||||
|
||||
loadImages: (reset?: boolean) => Promise<void>;
|
||||
loadMoreImages: () => Promise<void>;
|
||||
setSort: (sort: SortOrder) => void;
|
||||
setMediaFilter: (filter: MediaFilter) => void;
|
||||
setFavoritesOnly: (favoritesOnly: boolean) => void;
|
||||
setMinimumRating: (minimumRating: number) => void;
|
||||
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
|
||||
setFailedTaggingOnly: (failedTaggingOnly: boolean) => void;
|
||||
setColorFilter: (color: [number, number, number] | null) => void;
|
||||
showFailedTagging: (folderId: number) => void;
|
||||
setZoomPreset: (zoomPreset: ZoomPreset) => void;
|
||||
openImage: (image: ImageRecord) => void;
|
||||
closeImage: () => void;
|
||||
setView: (view: ActiveView) => void;
|
||||
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
|
||||
|
||||
// Gallery multi-select (Feature A)
|
||||
toggleGallerySelected: (imageId: number) => void;
|
||||
selectAllGallery: () => void;
|
||||
clearGallerySelection: () => void;
|
||||
bulkSetFavorite: (favorite: boolean) => Promise<void>;
|
||||
bulkSetRating: (rating: number) => Promise<void>;
|
||||
bulkAddTags: (tags: string[]) => Promise<void>;
|
||||
bulkRemoveTag: (tag: string) => Promise<void>;
|
||||
bulkDeleteSelected: () => Promise<number>;
|
||||
}
|
||||
|
||||
const resetCollectionState = {
|
||||
images: [] as ImageRecord[],
|
||||
loadedCount: 0,
|
||||
collectionTitle: null as string | null,
|
||||
similarSourceImageId: null as number | null,
|
||||
similarHasMore: false,
|
||||
imageLoadError: null as string | null,
|
||||
};
|
||||
|
||||
export const createGallerySlice: StateCreator<GalleryStore, [], [], GallerySlice> = (set, get) => ({
|
||||
images: [],
|
||||
totalImages: 0,
|
||||
loadedCount: 0,
|
||||
loadingImages: false,
|
||||
imageLoadError: null,
|
||||
sort: "date_desc",
|
||||
mediaFilter: "all",
|
||||
favoritesOnly: false,
|
||||
minimumRating: 0,
|
||||
failedEmbeddingsOnly: false,
|
||||
failedTaggingOnly: false,
|
||||
colorFilter: null,
|
||||
colorBackfill: null,
|
||||
zoomPreset: "comfortable",
|
||||
selectedImage: null,
|
||||
collectionTitle: null,
|
||||
galleryScrollResetKey: 0,
|
||||
activeView: "gallery",
|
||||
gallerySelectedIds: new Set(),
|
||||
|
||||
loadImages: async (reset = false) => {
|
||||
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, colorFilter, activeView } = get();
|
||||
const parsedSearch = parseSearchValue(search);
|
||||
const requestToken = nextGalleryRequestToken();
|
||||
// Any fresh collection load invalidates a selection that referenced the
|
||||
// previous set of visible images.
|
||||
set({ loadingImages: true, imageLoadError: null, ...(reset ? { gallerySelectedIds: new Set<number>() } : {}) });
|
||||
|
||||
try {
|
||||
// Album view loads from the album membership, honoring sort changes from
|
||||
// the Toolbar while staying within the album (ignores folder/search/filters).
|
||||
if (activeView === "album") {
|
||||
const albumId = get().selectedAlbumId;
|
||||
if (albumId === null) {
|
||||
set({ loadingImages: false });
|
||||
return;
|
||||
}
|
||||
const offset = reset ? 0 : loadedCount;
|
||||
const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", {
|
||||
params: { album_id: albumId, sort, offset, limit: PAGE_SIZE },
|
||||
});
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
const albumName = get().albums.find((entry) => entry.id === albumId)?.name ?? "Album";
|
||||
set((state) => ({
|
||||
images: reset ? result.images : [...state.images, ...result.images],
|
||||
totalImages: result.total,
|
||||
loadedCount: reset ? result.images.length : state.loadedCount + result.images.length,
|
||||
loadingImages: false,
|
||||
collectionTitle: albumName,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedSearch.mode === "semantic" && parsedSearch.query) {
|
||||
const images = await invoke<ImageRecord[]>("semantic_search_images", {
|
||||
params: {
|
||||
query: parsedSearch.query,
|
||||
folder_id: selectedFolderId,
|
||||
media_kind: mediaFilter === "all" ? null : mediaFilter,
|
||||
favorites_only: favoritesOnly,
|
||||
rating_min: minimumRating > 0 ? minimumRating : null,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
set({
|
||||
images,
|
||||
totalImages: images.length,
|
||||
loadedCount: images.length,
|
||||
loadingImages: false,
|
||||
collectionTitle: `Semantic search: ${parsedSearch.query}`,
|
||||
selectedFolderId,
|
||||
similarSourceImageId: null,
|
||||
similarSourceFolderId: null,
|
||||
similarHasMore: false,
|
||||
similarFolderId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedSearch.mode === "tag" && parsedSearch.query) {
|
||||
const offset = reset ? 0 : loadedCount;
|
||||
const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("search_images_by_tag", {
|
||||
params: {
|
||||
query: parsedSearch.query,
|
||||
folder_id: selectedFolderId,
|
||||
media_kind: mediaFilter === "all" ? null : mediaFilter,
|
||||
favorites_only: favoritesOnly,
|
||||
rating_min: minimumRating > 0 ? minimumRating : null,
|
||||
color: colorFilter,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
if (reset) {
|
||||
set({
|
||||
images: result.images,
|
||||
totalImages: result.total,
|
||||
loadedCount: result.images.length,
|
||||
loadingImages: false,
|
||||
collectionTitle: `Tag search: ${parsedSearch.query}`,
|
||||
selectedFolderId,
|
||||
similarSourceImageId: null,
|
||||
similarSourceFolderId: null,
|
||||
similarHasMore: false,
|
||||
similarFolderId: null,
|
||||
});
|
||||
} else {
|
||||
set((state) => ({
|
||||
images: [...state.images, ...result.images],
|
||||
loadedCount: state.loadedCount + result.images.length,
|
||||
totalImages: result.total,
|
||||
loadingImages: false,
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = reset ? 0 : loadedCount;
|
||||
const result = await invoke<{
|
||||
images: ImageRecord[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}>("get_images", {
|
||||
params: {
|
||||
folder_id: selectedFolderId,
|
||||
search: parsedSearch.query || null,
|
||||
media_kind: mediaFilter === "all" ? null : mediaFilter,
|
||||
favorites_only: favoritesOnly,
|
||||
rating_min: minimumRating > 0 ? minimumRating : null,
|
||||
embedding_failed_only: failedEmbeddingsOnly,
|
||||
tagging_failed_only: failedTaggingOnly,
|
||||
color: colorFilter,
|
||||
sort,
|
||||
offset,
|
||||
limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
set((state) => ({
|
||||
images: reset ? result.images : [...state.images, ...result.images],
|
||||
totalImages: result.total,
|
||||
loadedCount: reset ? result.images.length : state.loadedCount + result.images.length,
|
||||
loadingImages: false,
|
||||
collectionTitle: reset ? null : state.collectionTitle,
|
||||
similarSourceImageId: null,
|
||||
similarSourceFolderId: null,
|
||||
similarHasMore: false,
|
||||
similarFolderId: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
console.error("Failed to load media:", error);
|
||||
set({ loadingImages: false, imageLoadError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
loadMoreImages: async () => {
|
||||
const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, similarFolderId, similarCrop } = get();
|
||||
if (loadingImages || loadedCount >= totalImages) return;
|
||||
if (collectionTitle === "Explore Cluster") return;
|
||||
const { activeView, selectedAlbumId, sort } = get();
|
||||
if (activeView === "album" && selectedAlbumId !== null) {
|
||||
const requestToken = nextGalleryRequestToken();
|
||||
set({ loadingImages: true });
|
||||
try {
|
||||
const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", {
|
||||
params: { album_id: selectedAlbumId, sort, offset: loadedCount, limit: PAGE_SIZE },
|
||||
});
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
set((state) => ({
|
||||
images: [...state.images, ...result.images],
|
||||
loadedCount: state.loadedCount + result.images.length,
|
||||
totalImages: result.total,
|
||||
loadingImages: false,
|
||||
}));
|
||||
} catch {
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
set({ loadingImages: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const pageAlbumId = get().similarScope === "current_album" ? get().similarSourceAlbumId : null;
|
||||
if (collectionTitle === "Similar Images" && similarSourceImageId !== null) {
|
||||
if (!similarHasMore) return;
|
||||
await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null, pageAlbumId);
|
||||
return;
|
||||
}
|
||||
if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) {
|
||||
if (!similarHasMore) return;
|
||||
const requestToken = nextGalleryRequestToken();
|
||||
set({ loadingImages: true });
|
||||
try {
|
||||
const result = await invoke<SimilarImagesPage>("find_similar_by_region", {
|
||||
params: {
|
||||
image_id: similarSourceImageId,
|
||||
crop_x: similarCrop.x,
|
||||
crop_y: similarCrop.y,
|
||||
crop_w: similarCrop.w,
|
||||
crop_h: similarCrop.h,
|
||||
folder_id: pageAlbumId !== null ? null : similarFolderId,
|
||||
album_id: pageAlbumId,
|
||||
offset: loadedCount,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
set((state) => ({
|
||||
images: [...state.images, ...result.images],
|
||||
loadedCount: state.loadedCount + result.images.length,
|
||||
totalImages: result.has_more ? state.loadedCount + result.images.length + 1 : state.loadedCount + result.images.length,
|
||||
similarHasMore: result.has_more,
|
||||
loadingImages: false,
|
||||
}));
|
||||
} catch {
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
set({ loadingImages: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
await get().loadImages(false);
|
||||
},
|
||||
|
||||
setSort: (sort) => {
|
||||
set({ sort, ...resetCollectionState });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setMediaFilter: (mediaFilter) => {
|
||||
set({ mediaFilter, ...resetCollectionState });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setFavoritesOnly: (favoritesOnly) => {
|
||||
set({ favoritesOnly, ...resetCollectionState });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setMinimumRating: (minimumRating) => {
|
||||
set({ minimumRating, ...resetCollectionState });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => {
|
||||
set({ failedEmbeddingsOnly, failedTaggingOnly: failedEmbeddingsOnly ? false : get().failedTaggingOnly, ...resetCollectionState });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setFailedTaggingOnly: (failedTaggingOnly) => {
|
||||
set({ failedTaggingOnly, failedEmbeddingsOnly: failedTaggingOnly ? false : get().failedEmbeddingsOnly, ...resetCollectionState });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setColorFilter: (colorFilter) => {
|
||||
set({ colorFilter, ...resetCollectionState });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
showFailedTagging: (folderId) => {
|
||||
set({
|
||||
selectedFolderId: folderId,
|
||||
activeView: "gallery",
|
||||
search: "",
|
||||
mediaFilter: "all",
|
||||
favoritesOnly: false,
|
||||
minimumRating: 0,
|
||||
failedEmbeddingsOnly: false,
|
||||
failedTaggingOnly: true,
|
||||
images: [],
|
||||
loadedCount: 0,
|
||||
collectionTitle: null,
|
||||
similarSourceImageId: null,
|
||||
similarSourceFolderId: null,
|
||||
similarHasMore: false,
|
||||
similarFolderId: null,
|
||||
similarCrop: null,
|
||||
imageLoadError: null,
|
||||
});
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setZoomPreset: (zoomPreset) => set({ zoomPreset }),
|
||||
|
||||
openImage: (image) => set({ selectedImage: image }),
|
||||
closeImage: () => set({ selectedImage: null }),
|
||||
|
||||
setView: (activeView) => {
|
||||
// Leaving an album view drops the album-origin similar scope.
|
||||
const similarScopeReset = get().similarScope === "current_album" ? "all_media" : get().similarScope;
|
||||
if (activeView === "timeline") {
|
||||
set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, similarSourceAlbumId: null, similarScope: similarScopeReset, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
return;
|
||||
}
|
||||
if (activeView === "duplicates") {
|
||||
const { selectedFolderId, duplicateScanFolderId } = get();
|
||||
if (duplicateScanFolderId !== selectedFolderId) {
|
||||
set({
|
||||
activeView,
|
||||
duplicateGroups: [],
|
||||
duplicateLastScanned: null,
|
||||
duplicateScanFolderId: undefined,
|
||||
duplicateScanWarning: null,
|
||||
});
|
||||
void get().loadDuplicateScanCache(selectedFolderId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Entering Explore normally always starts in browse mode; openTagManager() is
|
||||
// the only path that re-opens manage mode (it runs this then sets the flag).
|
||||
set({
|
||||
activeView,
|
||||
similarSourceAlbumId: null,
|
||||
similarScope: similarScopeReset,
|
||||
...(activeView === "explore" ? { tagManagerOpen: false } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
updateImageDetails: async (imageId, updates) => {
|
||||
const updatedImage = await invoke<ImageRecord>("update_image_details", {
|
||||
params: {
|
||||
image_id: imageId,
|
||||
favorite: updates.favorite ?? null,
|
||||
rating: updates.rating ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
// Derived collections (similar / region / semantic / tag / album results)
|
||||
// are ordered by relevance, not `state.sort` — re-sorting them on a
|
||||
// favorite/rating change would scramble the results. Replace in place
|
||||
// there; only the real sorted gallery re-sorts.
|
||||
images: isDerivedCollectionTitle(state.collectionTitle)
|
||||
? replaceExistingImages(state.images, [updatedImage])
|
||||
: replaceImage(state.images, updatedImage, state.sort),
|
||||
selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage,
|
||||
}));
|
||||
},
|
||||
|
||||
// ── Gallery multi-select (Feature A) ──────────────────────────────────────
|
||||
|
||||
toggleGallerySelected: (imageId) => {
|
||||
set((state) => {
|
||||
const next = new Set(state.gallerySelectedIds);
|
||||
if (next.has(imageId)) next.delete(imageId);
|
||||
else next.add(imageId);
|
||||
return { gallerySelectedIds: next };
|
||||
});
|
||||
},
|
||||
|
||||
selectAllGallery: () => {
|
||||
set((state) => ({ gallerySelectedIds: new Set(state.images.map((image) => image.id)) }));
|
||||
},
|
||||
|
||||
clearGallerySelection: () => set({ gallerySelectedIds: new Set() }),
|
||||
|
||||
bulkSetFavorite: async (favorite) => {
|
||||
const ids = Array.from(get().gallerySelectedIds);
|
||||
if (ids.length === 0) return;
|
||||
const updated = await invoke<ImageRecord[]>("bulk_update_details", {
|
||||
params: { image_ids: ids, favorite, rating: null },
|
||||
});
|
||||
set((state) => {
|
||||
const match = state.selectedImage && updated.find((image) => image.id === state.selectedImage!.id);
|
||||
// Derived collections keep their relevance order (replace in place); only
|
||||
// the real sorted gallery re-sorts.
|
||||
return {
|
||||
images: isDerivedCollectionTitle(state.collectionTitle)
|
||||
? replaceExistingImages(state.images, updated)
|
||||
: mergeImages(state.images, updated, state.sort),
|
||||
selectedImage: match ?? state.selectedImage,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
bulkSetRating: async (rating) => {
|
||||
const ids = Array.from(get().gallerySelectedIds);
|
||||
if (ids.length === 0) return;
|
||||
const updated = await invoke<ImageRecord[]>("bulk_update_details", {
|
||||
params: { image_ids: ids, favorite: null, rating },
|
||||
});
|
||||
set((state) => {
|
||||
const match = state.selectedImage && updated.find((image) => image.id === state.selectedImage!.id);
|
||||
return {
|
||||
images: isDerivedCollectionTitle(state.collectionTitle)
|
||||
? replaceExistingImages(state.images, updated)
|
||||
: mergeImages(state.images, updated, state.sort),
|
||||
selectedImage: match ?? state.selectedImage,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
bulkAddTags: async (tags) => {
|
||||
const ids = Array.from(get().gallerySelectedIds);
|
||||
const cleaned = tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0);
|
||||
if (ids.length === 0 || cleaned.length === 0) return;
|
||||
await invoke<void>("bulk_add_tags", { params: { image_ids: ids, tags: cleaned } });
|
||||
// New tags landed — invalidate Explore tag caches.
|
||||
set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] });
|
||||
},
|
||||
|
||||
bulkRemoveTag: async (tag) => {
|
||||
const ids = Array.from(get().gallerySelectedIds);
|
||||
if (ids.length === 0 || !tag.trim()) return;
|
||||
await invoke<void>("bulk_remove_tag", { params: { image_ids: ids, tag: tag.trim() } });
|
||||
set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] });
|
||||
},
|
||||
|
||||
bulkDeleteSelected: async () => {
|
||||
const ids = Array.from(get().gallerySelectedIds);
|
||||
if (ids.length === 0) return 0;
|
||||
const affectedFolderIds = new Set<number>(
|
||||
get().images.filter((image) => get().gallerySelectedIds.has(image.id)).map((image) => image.folder_id),
|
||||
);
|
||||
const succeededIds = await invoke<number[]>("delete_images_from_disk", { params: { image_ids: ids } });
|
||||
const succeededSet = new Set(succeededIds);
|
||||
set((state) => ({
|
||||
// Only remove images confirmed deleted — failed files remain selected for retry.
|
||||
images: state.images.filter((image) => !succeededSet.has(image.id)),
|
||||
loadedCount: state.images.filter((image) => !succeededSet.has(image.id)).length,
|
||||
totalImages: Math.max(0, state.totalImages - succeededIds.length),
|
||||
gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !succeededSet.has(id))),
|
||||
// Deletion changes tag/duplicate/album aggregates.
|
||||
visualClusterFolderId: undefined,
|
||||
visualClusterEntries: [],
|
||||
exploreTagsFolderId: undefined,
|
||||
}));
|
||||
// The DB cascade already removed these from album_images; refresh counts/covers.
|
||||
void get().loadAlbums();
|
||||
await invoke("invalidate_duplicate_scan_cache", { folderId: null });
|
||||
for (const folderId of affectedFolderIds) {
|
||||
await invoke("invalidate_duplicate_scan_cache", { folderId });
|
||||
}
|
||||
return succeededIds.length;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { ImageRecord, MediaFilter, ParsedSearch, SearchCommand, SortOrder, ZoomPreset } from "./types";
|
||||
|
||||
export const PAGE_SIZE = 200;
|
||||
// Timeline loads its full filtered set in one indexed taken_at query so the
|
||||
// scrubber can span the entire library and jump to any month. Rendering is
|
||||
// virtualized, so the cost is one query + records in memory — fine at this scale.
|
||||
export const TIMELINE_PAGE_SIZE = 100000;
|
||||
export const SIMILAR_DISTANCE_THRESHOLD = 0.24;
|
||||
|
||||
export function parseSearchValue(search: string): ParsedSearch {
|
||||
if (!search.trim()) {
|
||||
return { mode: "filename", query: "", prefix: null };
|
||||
}
|
||||
|
||||
const slashPrefix = search.match(/^\/([a-z])(?:\s|$)/i);
|
||||
if (slashPrefix) {
|
||||
const rawPrefix = slashPrefix[1].toLowerCase();
|
||||
const query = search.length > 3 ? search.slice(3) : "";
|
||||
if (rawPrefix === "s") {
|
||||
return { mode: "semantic", query, prefix: "/s" };
|
||||
}
|
||||
if (rawPrefix === "t") {
|
||||
return { mode: "tag", query, prefix: "/t" };
|
||||
}
|
||||
return { mode: "filename", query, prefix: rawPrefix === "f" ? "/f" : null };
|
||||
}
|
||||
|
||||
const trimmed = search.trim();
|
||||
const match = trimmed.match(/^([a-z]):\s*(.*)$/i);
|
||||
if (!match) {
|
||||
return { mode: "filename", query: trimmed, prefix: null };
|
||||
}
|
||||
|
||||
const rawPrefix = match[1].toLowerCase();
|
||||
const query = match[2].trim();
|
||||
if (rawPrefix === "s") {
|
||||
return { mode: "semantic", query, prefix: "s:" };
|
||||
}
|
||||
if (rawPrefix === "t") {
|
||||
return { mode: "tag", query, prefix: "t:" };
|
||||
}
|
||||
return { mode: "filename", query, prefix: rawPrefix === "f" ? "f:" : null };
|
||||
}
|
||||
|
||||
export function searchModeLabel(mode: SearchCommand): string {
|
||||
switch (mode) {
|
||||
case "semantic":
|
||||
return "Semantic Search";
|
||||
case "tag":
|
||||
return "Tag Search";
|
||||
default:
|
||||
return "Filename Search";
|
||||
}
|
||||
}
|
||||
|
||||
export function tileSizeForZoom(zoomPreset: ZoomPreset): number {
|
||||
switch (zoomPreset) {
|
||||
case "compact":
|
||||
return 160;
|
||||
case "detail":
|
||||
return 280;
|
||||
default:
|
||||
return 220;
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesSearch(image: ImageRecord, search: string): boolean {
|
||||
if (!search) return true;
|
||||
return image.filename.toLowerCase().includes(search.toLowerCase());
|
||||
}
|
||||
|
||||
export function isDerivedCollectionTitle(collectionTitle: string | null): boolean {
|
||||
return collectionTitle !== null;
|
||||
}
|
||||
|
||||
export function matchesFilters(
|
||||
image: ImageRecord,
|
||||
selectedFolderId: number | null,
|
||||
mediaFilter: MediaFilter,
|
||||
favoritesOnly: boolean,
|
||||
minimumRating: number,
|
||||
failedEmbeddingsOnly: boolean,
|
||||
failedTaggingOnly: boolean,
|
||||
search: string,
|
||||
): boolean {
|
||||
const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId;
|
||||
const matchesMedia = mediaFilter === "all" || image.media_kind === mediaFilter;
|
||||
const matchesFavorite = !favoritesOnly || image.favorite;
|
||||
const matchesRating = image.rating >= minimumRating;
|
||||
const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed";
|
||||
const matchesFailedTagging = !failedTaggingOnly || image.ai_tagger_error !== null;
|
||||
return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesFailedTagging && matchesSearch(image, search);
|
||||
}
|
||||
|
||||
function compareNullableNumber(a: number | null, b: number | null): number {
|
||||
return (a ?? 0) - (b ?? 0);
|
||||
}
|
||||
|
||||
function compareNullableDate(a: string | null, b: string | null): number {
|
||||
return (a ? Date.parse(a) : 0) - (b ? Date.parse(b) : 0);
|
||||
}
|
||||
|
||||
function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number {
|
||||
switch (sort) {
|
||||
case "name_asc":
|
||||
return a.filename.localeCompare(b.filename);
|
||||
case "name_desc":
|
||||
return b.filename.localeCompare(a.filename);
|
||||
case "date_asc":
|
||||
return compareNullableDate(a.modified_at, b.modified_at);
|
||||
case "date_desc":
|
||||
return compareNullableDate(b.modified_at, a.modified_at);
|
||||
case "size_asc":
|
||||
return compareNullableNumber(a.file_size, b.file_size);
|
||||
case "size_desc":
|
||||
return compareNullableNumber(b.file_size, a.file_size);
|
||||
case "rating_asc":
|
||||
return compareNullableNumber(a.rating, b.rating);
|
||||
case "rating_desc":
|
||||
return compareNullableNumber(b.rating, a.rating);
|
||||
case "duration_asc":
|
||||
return compareNullableNumber(a.duration_ms, b.duration_ms);
|
||||
case "duration_desc":
|
||||
return compareNullableNumber(b.duration_ms, a.duration_ms);
|
||||
case "taken_asc":
|
||||
return compareNullableDate(a.taken_at ?? a.modified_at, b.taken_at ?? b.modified_at);
|
||||
case "taken_desc":
|
||||
return compareNullableDate(b.taken_at ?? b.modified_at, a.taken_at ?? a.modified_at);
|
||||
default:
|
||||
return compareNullableDate(b.modified_at, a.modified_at);
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeImages(currentImages: ImageRecord[], newImages: ImageRecord[], sort: SortOrder): ImageRecord[] {
|
||||
const merged = new Map<string, ImageRecord>();
|
||||
|
||||
for (const image of currentImages) {
|
||||
merged.set(image.path, image);
|
||||
}
|
||||
|
||||
for (const image of newImages) {
|
||||
merged.set(image.path, image);
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort((a, b) => compareImages(a, b, sort));
|
||||
}
|
||||
|
||||
export function mergeIntoVisibleWindow(
|
||||
currentImages: ImageRecord[],
|
||||
newImages: ImageRecord[],
|
||||
sort: SortOrder,
|
||||
windowSize: number,
|
||||
): ImageRecord[] {
|
||||
const merged = mergeImages(currentImages, newImages, sort);
|
||||
return merged.slice(0, Math.max(windowSize, 0));
|
||||
}
|
||||
|
||||
export function countNewImages(currentImages: ImageRecord[], newImages: ImageRecord[]): number {
|
||||
const existingPaths = new Set(currentImages.map((image) => image.path));
|
||||
let count = 0;
|
||||
|
||||
for (const image of newImages) {
|
||||
if (!existingPaths.has(image.path)) {
|
||||
existingPaths.add(image.path);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: SortOrder): ImageRecord[] {
|
||||
return mergeImages(images, [updatedImage], sort);
|
||||
}
|
||||
|
||||
export function replaceExistingImages(
|
||||
currentImages: ImageRecord[],
|
||||
updatedImages: ImageRecord[],
|
||||
): ImageRecord[] {
|
||||
// Replace matched records in place WITHOUT re-sorting. `media-updated` carries
|
||||
// thumbnail/metadata fills that don't move an item in the list (Timeline
|
||||
// re-buckets by taken_at separately), and it fires constantly while the
|
||||
// background workers run. Re-sorting here meant an O(n log n) pass on every
|
||||
// batch — fine for the ~200-item gallery window, but a UI-freezing churn in
|
||||
// Timeline view where `images` can hold the entire library (TIMELINE_PAGE_SIZE).
|
||||
// Returning the same array reference when nothing matched also avoids a wasted
|
||||
// re-render. Relative order for just-updated items is corrected on next load.
|
||||
const updatesByPath = new Map(updatedImages.map((image) => [image.path, image]));
|
||||
let changed = false;
|
||||
const nextImages = currentImages.map((image) => {
|
||||
const update = updatesByPath.get(image.path);
|
||||
if (!update) return image;
|
||||
changed = true;
|
||||
return update;
|
||||
});
|
||||
return changed ? nextImages : currentImages;
|
||||
}
|
||||
|
||||
export function initialAiCaptionsEnabled(key: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem(key) === "true";
|
||||
}
|
||||
|
||||
export function initialBoolSetting(key: string, fallback: boolean): boolean {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
const stored = window.localStorage.getItem(key);
|
||||
return stored === null ? fallback : stored === "true";
|
||||
}
|
||||
|
||||
export function initialNumberSetting(key: string, fallback: number, min: number, max: number): number {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (raw === null) return fallback;
|
||||
const stored = Number(raw);
|
||||
if (!Number.isFinite(stored)) return fallback;
|
||||
return Math.min(max, Math.max(min, stored));
|
||||
}
|
||||
|
||||
// Single token shared by all gallery-producing requests (folder loads, searches,
|
||||
// similarity, region search, album views, explore clusters). Any new request
|
||||
// increments it so a stale response from a previous collection type cannot
|
||||
// overwrite newer results.
|
||||
let galleryRequestToken = 0;
|
||||
|
||||
export function nextGalleryRequestToken(): number {
|
||||
return ++galleryRequestToken;
|
||||
}
|
||||
|
||||
export function isCurrentGalleryRequest(token: number): boolean {
|
||||
return token === galleryRequestToken;
|
||||
}
|
||||
|
||||
export function scopeHasTaggingPending(
|
||||
progressByFolder: Record<number, import("./types").FolderJobProgress>,
|
||||
folderId: number | null,
|
||||
): boolean {
|
||||
if (folderId === null) {
|
||||
return Object.values(progressByFolder).some((progress) => progress.tagging_pending > 0);
|
||||
}
|
||||
return (progressByFolder[folderId]?.tagging_pending ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function taggingProgressAffectsScope(progressFolderId: number, scopeFolderId: number | null): boolean {
|
||||
return scopeFolderId === null || scopeFolderId === progressFolderId;
|
||||
}
|
||||
|
||||
export function imagesAffectScope(images: ImageRecord[], scopeFolderId: number | null): boolean {
|
||||
return scopeFolderId === null || images.some((image) => image.folder_id === scopeFolderId);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { create } from "zustand";
|
||||
import { appDataDir, join } from "@tauri-apps/api/path";
|
||||
import { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { createAlbumSlice, AlbumSlice } from "./albumSlice";
|
||||
import { createAppSlice, AppSlice } from "./appSlice";
|
||||
import { createCaptionSlice, CaptionSlice } from "./captionSlice";
|
||||
import { createDuplicateSlice, DuplicateSlice } from "./duplicateSlice";
|
||||
import { createExploreSlice, ExploreSlice } from "./exploreSlice";
|
||||
import { createGallerySlice, GallerySlice } from "./gallerySlice";
|
||||
import { createLibrarySlice, LibrarySlice } from "./librarySlice";
|
||||
import { createSearchSlice, SearchSlice } from "./searchSlice";
|
||||
import { createSettingsSlice, SettingsSlice } from "./settingsSlice";
|
||||
import { createTaggerSlice, TaggerSlice } from "./taggerSlice";
|
||||
import { subscribeToProgress } from "./events";
|
||||
|
||||
export * from "./types";
|
||||
export { parseSearchValue, searchModeLabel, tileSizeForZoom } from "./helpers";
|
||||
|
||||
export type GalleryStore = LibrarySlice &
|
||||
GallerySlice &
|
||||
SearchSlice &
|
||||
ExploreSlice &
|
||||
AlbumSlice &
|
||||
DuplicateSlice &
|
||||
TaggerSlice &
|
||||
CaptionSlice &
|
||||
SettingsSlice &
|
||||
AppSlice & {
|
||||
subscribeToProgress: () => Promise<UnlistenFn>;
|
||||
};
|
||||
|
||||
export const useGalleryStore = create<GalleryStore>()((set, get, ...rest) => ({
|
||||
...createLibrarySlice(set, get, ...rest),
|
||||
...createGallerySlice(set, get, ...rest),
|
||||
...createSearchSlice(set, get, ...rest),
|
||||
...createExploreSlice(set, get, ...rest),
|
||||
...createAlbumSlice(set, get, ...rest),
|
||||
...createDuplicateSlice(set, get, ...rest),
|
||||
...createTaggerSlice(set, get, ...rest),
|
||||
...createCaptionSlice(set, get, ...rest),
|
||||
...createSettingsSlice(set, get, ...rest),
|
||||
...createAppSlice(set, get, ...rest),
|
||||
|
||||
subscribeToProgress: () => subscribeToProgress(set, get),
|
||||
}));
|
||||
|
||||
appDataDir().then(async (dir) => {
|
||||
useGalleryStore.getState().setCacheDir(await join(dir, "thumbnails"));
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { GalleryStore } from "./index";
|
||||
import type { Folder, FolderAddResult, FolderJobProgress, IndexProgress } from "./types";
|
||||
|
||||
export interface LibrarySlice {
|
||||
folders: Folder[];
|
||||
selectedFolderId: number | null;
|
||||
indexingProgress: Record<number, IndexProgress>;
|
||||
mediaJobProgress: Record<number, FolderJobProgress>;
|
||||
|
||||
loadFolders: () => Promise<void>;
|
||||
loadBackgroundJobProgress: () => Promise<void>;
|
||||
addFolder: (path: string) => Promise<void>;
|
||||
addFolders: (paths: string[]) => Promise<FolderAddResult[]>;
|
||||
listDirectories: (path: string | null) => Promise<import("./types").DirListing>;
|
||||
removeFolder: (folderId: number) => Promise<void>;
|
||||
reindexFolder: (folderId: number) => Promise<void>;
|
||||
renameFolder: (folderId: number, newName: string) => Promise<void>;
|
||||
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
|
||||
reorderFolders: (folderIds: number[]) => Promise<void>;
|
||||
selectFolder: (folderId: number | null) => void;
|
||||
setViewFolderScope: (folderId: number | null) => void;
|
||||
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const createLibrarySlice: StateCreator<GalleryStore, [], [], LibrarySlice> = (set, get) => ({
|
||||
folders: [],
|
||||
selectedFolderId: null,
|
||||
indexingProgress: {},
|
||||
mediaJobProgress: {},
|
||||
|
||||
loadFolders: async () => {
|
||||
const folders = await invoke<Folder[]>("get_folders");
|
||||
set((state) => {
|
||||
const folderIds = new Set(folders.map((folder) => folder.id));
|
||||
const nextSelected = state.taggingQueueFolderIds.filter((folderId) => folderIds.has(folderId));
|
||||
return {
|
||||
folders,
|
||||
taggingQueueFolderIds: nextSelected,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
loadBackgroundJobProgress: async () => {
|
||||
const progress = await invoke<FolderJobProgress[]>("get_background_job_progress");
|
||||
set(() => ({
|
||||
mediaJobProgress: Object.fromEntries(progress.map((entry) => [entry.folder_id, entry])),
|
||||
}));
|
||||
},
|
||||
|
||||
addFolder: async (path) => {
|
||||
const { loadFolders, loadBackgroundJobProgress } = get();
|
||||
await invoke("add_folder", { path });
|
||||
await loadFolders();
|
||||
await loadBackgroundJobProgress();
|
||||
},
|
||||
|
||||
addFolders: async (paths) => {
|
||||
const { loadFolders, loadBackgroundJobProgress } = get();
|
||||
const results = await invoke<FolderAddResult[]>("add_folders", { paths });
|
||||
await loadFolders();
|
||||
await loadBackgroundJobProgress();
|
||||
return results;
|
||||
},
|
||||
|
||||
listDirectories: (path) => invoke("list_directories", { path }),
|
||||
|
||||
removeFolder: async (folderId) => {
|
||||
const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get();
|
||||
// Optimistically drop it from the sidebar for instant feedback (the backend
|
||||
// delete of its images/thumbnails can take a moment), clearing the active
|
||||
// selection if it was this folder.
|
||||
set((state) => {
|
||||
const folders = state.folders.filter((folder) => folder.id !== folderId);
|
||||
return selectedFolderId === folderId ? { folders, selectedFolderId: null } : { folders };
|
||||
});
|
||||
try {
|
||||
await invoke("remove_folder", { folderId });
|
||||
} catch (error) {
|
||||
// Removal failed — resync the authoritative list and surface the error.
|
||||
await loadFolders();
|
||||
throw error;
|
||||
}
|
||||
await loadFolders();
|
||||
await loadBackgroundJobProgress();
|
||||
// Invalidate tag cloud and explore-tags cache since library content changed.
|
||||
set({ visualClusterFolderId: undefined, visualClusterEntries: [], exploreTagsFolderId: undefined });
|
||||
// Always refresh the gallery: the removed folder's images may be on screen
|
||||
// (e.g. in All Media), not only when that folder was the active selection.
|
||||
await loadImages(true);
|
||||
},
|
||||
|
||||
reindexFolder: async (folderId) => {
|
||||
const { loadFolders, loadBackgroundJobProgress } = get();
|
||||
await invoke("reindex_folder", { folderId });
|
||||
await loadFolders();
|
||||
// Invalidate tag cloud cache since embeddings will be regenerated
|
||||
set({ visualClusterFolderId: undefined, visualClusterEntries: [] });
|
||||
await loadBackgroundJobProgress();
|
||||
},
|
||||
|
||||
renameFolder: async (folderId, newName) => {
|
||||
await invoke("rename_folder", { folderId, newName });
|
||||
await get().loadFolders();
|
||||
},
|
||||
|
||||
updateFolderPath: async (folderId, newPath) => {
|
||||
const { loadFolders, loadBackgroundJobProgress } = get();
|
||||
await invoke("update_folder_path", { folderId, newPath });
|
||||
await loadFolders();
|
||||
await loadBackgroundJobProgress();
|
||||
},
|
||||
|
||||
reorderFolders: async (folderIds) => {
|
||||
const previous = get().folders;
|
||||
const byId = new Map(previous.map((folder) => [folder.id, folder]));
|
||||
const folders = folderIds
|
||||
.map((id, index) => {
|
||||
const folder = byId.get(id);
|
||||
return folder ? { ...folder, sort_order: index + 1 } : null;
|
||||
})
|
||||
.filter((folder): folder is Folder => folder !== null);
|
||||
set({ folders });
|
||||
try {
|
||||
await invoke("reorder_folders", { params: { folder_ids: folderIds } });
|
||||
} catch (error) {
|
||||
set({ folders: previous });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
selectFolder: (folderId) => {
|
||||
// Leaving any album: drop the album-origin scope so the Folder/All pills
|
||||
// highlight correctly again.
|
||||
const similarScope = get().similarScope === "current_album" ? "all_media" : get().similarScope;
|
||||
set({ selectedFolderId: folderId, selectedAlbumId: null, similarSourceAlbumId: null, similarScope, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
// Change folder scope from inside a feature view (Timeline/Explore/Duplicates)
|
||||
// without leaving it — unlike selectFolder, activeView is preserved.
|
||||
setViewFolderScope: (folderId) => {
|
||||
const { activeView, selectedFolderId } = get();
|
||||
if (folderId === selectedFolderId) return;
|
||||
|
||||
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
|
||||
if (activeView === "duplicates") {
|
||||
const { duplicateScanFolderId } = get();
|
||||
if (duplicateScanFolderId !== folderId) {
|
||||
set({
|
||||
duplicateGroups: [],
|
||||
duplicateLastScanned: null,
|
||||
duplicateScanFolderId: undefined,
|
||||
duplicateScanWarning: null,
|
||||
});
|
||||
void get().loadDuplicateScanCache(folderId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Explore reloads itself via ExploreView's useEffect on selectedFolderId.
|
||||
if (activeView === "explore") return;
|
||||
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
retryFailedEmbeddings: async (folderId) => {
|
||||
await invoke("retry_failed_embeddings", { params: { folder_id: folderId } });
|
||||
await get().loadBackgroundJobProgress();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { StateCreator } from "zustand";
|
||||
import { PAGE_SIZE, SIMILAR_DISTANCE_THRESHOLD, isCurrentGalleryRequest, nextGalleryRequestToken } from "./helpers";
|
||||
import type { GalleryStore } from "./index";
|
||||
import type { SearchMode, SimilarImagesPage, SimilarScope } from "./types";
|
||||
|
||||
export interface SearchSlice {
|
||||
search: string;
|
||||
searchMode: SearchMode;
|
||||
similarSourceImageId: number | null;
|
||||
similarSourceFolderId: number | null;
|
||||
similarSourceAlbumId: number | null; // album a similar search was launched from (enables "Similar: Album")
|
||||
similarHasMore: boolean;
|
||||
similarScope: SimilarScope;
|
||||
similarFolderId: number | null;
|
||||
similarCrop: { x: number; y: number; w: number; h: number } | null;
|
||||
|
||||
setSearch: (search: string) => void;
|
||||
clearSearch: () => void;
|
||||
resetSearch: () => void;
|
||||
setSearchMode: (mode: SearchMode) => void;
|
||||
searchForTag: (tag: string) => void;
|
||||
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>;
|
||||
loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>;
|
||||
// Entry points that decide scope (album when launched from an album, else folder/all per similarScope).
|
||||
findSimilar: (imageId: number, sourceFolderId: number | null) => Promise<void>;
|
||||
findSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, sourceFolderId: number | null) => Promise<void>;
|
||||
setSimilarScope: (scope: SimilarScope) => void;
|
||||
}
|
||||
|
||||
export const createSearchSlice: StateCreator<GalleryStore, [], [], SearchSlice> = (set, get) => ({
|
||||
search: "",
|
||||
searchMode: "filename",
|
||||
similarSourceImageId: null,
|
||||
similarSourceFolderId: null,
|
||||
similarSourceAlbumId: null,
|
||||
similarHasMore: false,
|
||||
similarScope: "all_media",
|
||||
similarFolderId: null,
|
||||
similarCrop: null,
|
||||
|
||||
setSearch: (search) => {
|
||||
set({ search, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
clearSearch: () => {
|
||||
set({ search: "", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
resetSearch: () => {
|
||||
set({ search: "", searchMode: "filename", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setSearchMode: (searchMode) => {
|
||||
set({ searchMode, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
searchForTag: (tag) => {
|
||||
set({ activeView: "gallery", search: `/t ${tag}`, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, similarFolderId: null, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true, sourceFolderId = folderId ?? null, albumId = null) => {
|
||||
const requestToken = nextGalleryRequestToken();
|
||||
const offset = reset ? 0 : get().loadedCount;
|
||||
const similarScope: SimilarScope = albumId !== null ? "current_album" : folderId === null ? "all_media" : "current_folder";
|
||||
// Album scope drives results off album membership, so the folder query is null.
|
||||
const queryFolderId = albumId !== null ? null : folderId ?? null;
|
||||
set((state) => ({
|
||||
images: reset ? [] : state.images,
|
||||
loadedCount: reset ? 0 : state.loadedCount,
|
||||
loadingImages: true,
|
||||
collectionTitle: "Similar Images",
|
||||
imageLoadError: null,
|
||||
similarSourceImageId: imageId,
|
||||
similarSourceFolderId: sourceFolderId,
|
||||
similarFolderId: queryFolderId,
|
||||
similarScope,
|
||||
// Force the gallery grid so results (and the bulk bar) render regardless
|
||||
// of which view the search was launched from.
|
||||
activeView: "gallery",
|
||||
gallerySelectedIds: reset ? new Set<number>() : state.gallerySelectedIds,
|
||||
selectedAlbumId: null,
|
||||
galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey,
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await invoke<SimilarImagesPage>("find_similar_images", {
|
||||
params: {
|
||||
image_id: imageId,
|
||||
folder_id: queryFolderId,
|
||||
album_id: albumId,
|
||||
offset,
|
||||
limit: PAGE_SIZE,
|
||||
threshold: SIMILAR_DISTANCE_THRESHOLD,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
|
||||
set((state) => {
|
||||
const nextImages = reset ? result.images : [...state.images, ...result.images];
|
||||
const nextLoadedCount = nextImages.length;
|
||||
return {
|
||||
images: nextImages,
|
||||
totalImages: result.has_more ? nextLoadedCount + 1 : nextLoadedCount,
|
||||
loadedCount: nextLoadedCount,
|
||||
loadingImages: false,
|
||||
imageLoadError: null,
|
||||
collectionTitle: "Similar Images",
|
||||
similarSourceImageId: imageId,
|
||||
similarSourceFolderId: sourceFolderId,
|
||||
similarHasMore: result.has_more,
|
||||
similarFolderId: queryFolderId,
|
||||
similarScope,
|
||||
selectedImage: reset ? null : state.selectedImage,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
console.error("Failed to load similar images:", error);
|
||||
set({
|
||||
images: [],
|
||||
totalImages: 0,
|
||||
loadedCount: 0,
|
||||
loadingImages: false,
|
||||
imageLoadError: String(error),
|
||||
collectionTitle: "Similar Images",
|
||||
similarSourceImageId: imageId,
|
||||
similarSourceFolderId: sourceFolderId,
|
||||
similarHasMore: false,
|
||||
similarFolderId: queryFolderId,
|
||||
similarScope,
|
||||
selectedImage: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadSimilarByRegion: async (imageId, crop, folderId = get().selectedFolderId, sourceFolderId = folderId ?? null, albumId = null) => {
|
||||
const requestToken = nextGalleryRequestToken();
|
||||
const similarScope: SimilarScope = albumId !== null ? "current_album" : folderId === null ? "all_media" : "current_folder";
|
||||
const queryFolderId = albumId !== null ? null : folderId ?? null;
|
||||
set((state) => ({
|
||||
images: [],
|
||||
loadedCount: 0,
|
||||
loadingImages: true,
|
||||
collectionTitle: "Region Search Results",
|
||||
imageLoadError: null,
|
||||
similarSourceImageId: imageId,
|
||||
similarSourceFolderId: sourceFolderId,
|
||||
similarFolderId: queryFolderId,
|
||||
similarCrop: crop,
|
||||
similarScope,
|
||||
// Force the gallery grid so results (and the bulk bar) render regardless
|
||||
// of which view the search was launched from.
|
||||
activeView: "gallery",
|
||||
gallerySelectedIds: new Set<number>(),
|
||||
selectedAlbumId: null,
|
||||
galleryScrollResetKey: state.galleryScrollResetKey + 1,
|
||||
selectedImage: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await invoke<SimilarImagesPage>("find_similar_by_region", {
|
||||
params: {
|
||||
image_id: imageId,
|
||||
crop_x: crop.x,
|
||||
crop_y: crop.y,
|
||||
crop_w: crop.w,
|
||||
crop_h: crop.h,
|
||||
folder_id: queryFolderId,
|
||||
album_id: albumId,
|
||||
offset: 0,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
|
||||
set({
|
||||
images: result.images,
|
||||
totalImages: result.has_more ? result.images.length + 1 : result.images.length,
|
||||
loadedCount: result.images.length,
|
||||
loadingImages: false,
|
||||
imageLoadError: null,
|
||||
collectionTitle: "Region Search Results",
|
||||
similarSourceImageId: imageId,
|
||||
similarSourceFolderId: sourceFolderId,
|
||||
similarHasMore: result.has_more,
|
||||
similarFolderId: queryFolderId,
|
||||
similarCrop: crop,
|
||||
similarScope,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentGalleryRequest(requestToken)) return;
|
||||
console.error("Failed to load region search results:", error);
|
||||
set({
|
||||
images: [],
|
||||
totalImages: 0,
|
||||
loadedCount: 0,
|
||||
loadingImages: false,
|
||||
imageLoadError: String(error),
|
||||
collectionTitle: "Region Search Results",
|
||||
similarSourceImageId: imageId,
|
||||
similarSourceFolderId: sourceFolderId,
|
||||
similarHasMore: false,
|
||||
similarFolderId: queryFolderId,
|
||||
similarCrop: crop,
|
||||
similarScope,
|
||||
selectedImage: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Decide the scope at launch: album when triggered from an album, else the
|
||||
// current folder/all preference. Sets similarSourceAlbumId so the "Similar:
|
||||
// Album" pill and scope toggle work afterward.
|
||||
findSimilar: (imageId, sourceFolderId) => {
|
||||
const { activeView, selectedAlbumId, similarScope } = get();
|
||||
const albumOrigin = activeView === "album" ? selectedAlbumId : null;
|
||||
set({ similarSourceAlbumId: albumOrigin });
|
||||
// Respect the chosen scope; album is the default in an album view but the
|
||||
// user can override to Folder/All before searching.
|
||||
if (similarScope === "current_album" && albumOrigin !== null) {
|
||||
return get().loadSimilarImages(imageId, null, true, sourceFolderId, albumOrigin);
|
||||
}
|
||||
const folderId = similarScope === "current_folder" ? sourceFolderId : null;
|
||||
return get().loadSimilarImages(imageId, folderId, true, sourceFolderId, null);
|
||||
},
|
||||
|
||||
findSimilarByRegion: (imageId, crop, sourceFolderId) => {
|
||||
const { activeView, selectedAlbumId, similarScope } = get();
|
||||
const albumOrigin = activeView === "album" ? selectedAlbumId : null;
|
||||
set({ similarSourceAlbumId: albumOrigin });
|
||||
if (similarScope === "current_album" && albumOrigin !== null) {
|
||||
return get().loadSimilarByRegion(imageId, crop, null, sourceFolderId, albumOrigin);
|
||||
}
|
||||
const folderId = similarScope === "current_folder" ? sourceFolderId : null;
|
||||
return get().loadSimilarByRegion(imageId, crop, folderId, sourceFolderId, null);
|
||||
},
|
||||
|
||||
setSimilarScope: (similarScope) => {
|
||||
set({ similarScope });
|
||||
const { similarSourceImageId, similarSourceFolderId, similarSourceAlbumId, selectedFolderId, collectionTitle, similarCrop } = get();
|
||||
if (similarSourceImageId === null) return;
|
||||
const albumId = similarScope === "current_album" ? similarSourceAlbumId : null;
|
||||
const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null;
|
||||
if (collectionTitle === "Region Search Results" && similarCrop !== null) {
|
||||
void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId, albumId);
|
||||
} else {
|
||||
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId, albumId);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { StateCreator } from "zustand";
|
||||
import { initialBoolSetting, initialNumberSetting } from "./helpers";
|
||||
import type { GalleryStore } from "./index";
|
||||
import type {
|
||||
AppTheme,
|
||||
CleanupOrphanedThumbnailsResult,
|
||||
DatabaseInfo,
|
||||
OrphanedThumbnailsInfo,
|
||||
SlideshowOrder,
|
||||
SlideshowTransition,
|
||||
VacuumResult,
|
||||
} from "./types";
|
||||
|
||||
const THEME_KEY = "phokus-theme";
|
||||
const LIGHTBOX_AUTOPLAY_KEY = "phokus.lightboxAutoplay";
|
||||
const LIGHTBOX_AUTO_MUTE_KEY = "phokus.lightboxAutoMute";
|
||||
const SLIDESHOW_INTERVAL_KEY = "phokus.slideshowIntervalSeconds";
|
||||
const SLIDESHOW_ORDER_KEY = "phokus.slideshowOrder";
|
||||
const SLIDESHOW_TRANSITION_KEY = "phokus.slideshowTransition";
|
||||
|
||||
function initialSlideshowOrder(): SlideshowOrder {
|
||||
if (typeof window === "undefined") return "sequential";
|
||||
const stored = window.localStorage.getItem(SLIDESHOW_ORDER_KEY);
|
||||
return stored === "random" ? "random" : "sequential";
|
||||
}
|
||||
|
||||
function initialSlideshowTransition(): SlideshowTransition {
|
||||
if (typeof window === "undefined") return "soft-fade";
|
||||
const stored = window.localStorage.getItem(SLIDESHOW_TRANSITION_KEY);
|
||||
return stored === "gentle-motion" ? "gentle-motion" : "soft-fade";
|
||||
}
|
||||
|
||||
function initialTheme(): AppTheme {
|
||||
if (typeof window === "undefined") return "phokus";
|
||||
const saved = window.localStorage.getItem(THEME_KEY);
|
||||
const theme: AppTheme =
|
||||
saved === "subtle-light" || saved === "conventional-dark" ? saved : "phokus";
|
||||
document.documentElement.dataset.theme = theme;
|
||||
return theme;
|
||||
}
|
||||
|
||||
export interface SettingsSlice {
|
||||
cacheDir: string;
|
||||
settingsOpen: boolean;
|
||||
folderPickerOpen: boolean;
|
||||
mutedFolderIds: number[];
|
||||
notificationsPaused: boolean;
|
||||
theme: AppTheme;
|
||||
lightboxAutoplay: boolean;
|
||||
lightboxAutoMute: boolean;
|
||||
slideshowIntervalSeconds: number;
|
||||
slideshowOrder: SlideshowOrder;
|
||||
slideshowTransition: SlideshowTransition;
|
||||
|
||||
setCacheDir: (dir: string) => void;
|
||||
setSettingsOpen: (open: boolean) => void;
|
||||
setFolderPickerOpen: (open: boolean) => void;
|
||||
loadMutedFolderIds: () => Promise<void>;
|
||||
toggleMutedFolder: (folderId: number) => void;
|
||||
loadNotificationsPaused: () => Promise<void>;
|
||||
setNotificationsPaused: (paused: boolean) => void;
|
||||
setTheme: (theme: AppTheme) => void;
|
||||
setLightboxAutoplay: (enabled: boolean) => void;
|
||||
setLightboxAutoMute: (enabled: boolean) => void;
|
||||
setSlideshowIntervalSeconds: (seconds: number) => void;
|
||||
setSlideshowOrder: (order: SlideshowOrder) => void;
|
||||
setSlideshowTransition: (transition: SlideshowTransition) => void;
|
||||
openAppDataFolder: () => Promise<void>;
|
||||
getDatabaseInfo: () => Promise<DatabaseInfo>;
|
||||
vacuumDatabase: () => Promise<VacuumResult>;
|
||||
rebuildSemanticIndex: () => Promise<number>;
|
||||
getOrphanedThumbnailsInfo: () => Promise<OrphanedThumbnailsInfo>;
|
||||
cleanupOrphanedThumbnails: () => Promise<CleanupOrphanedThumbnailsResult>;
|
||||
}
|
||||
|
||||
export const createSettingsSlice: StateCreator<GalleryStore, [], [], SettingsSlice> = (set) => ({
|
||||
cacheDir: "",
|
||||
settingsOpen: false,
|
||||
folderPickerOpen: false,
|
||||
mutedFolderIds: [],
|
||||
notificationsPaused: false,
|
||||
theme: initialTheme(),
|
||||
lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true),
|
||||
lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false),
|
||||
slideshowIntervalSeconds: initialNumberSetting(SLIDESHOW_INTERVAL_KEY, 6, 3, 60),
|
||||
slideshowOrder: initialSlideshowOrder(),
|
||||
slideshowTransition: initialSlideshowTransition(),
|
||||
|
||||
setCacheDir: (cacheDir) => set({ cacheDir }),
|
||||
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
|
||||
setFolderPickerOpen: (folderPickerOpen) => set({ folderPickerOpen }),
|
||||
|
||||
loadMutedFolderIds: async () => {
|
||||
try {
|
||||
const folderIds = await invoke<number[]>("get_muted_folder_ids");
|
||||
set({ mutedFolderIds: folderIds });
|
||||
} catch {
|
||||
// fall back to in-memory default
|
||||
}
|
||||
},
|
||||
|
||||
toggleMutedFolder: (folderId) => {
|
||||
set((state) => {
|
||||
const next = state.mutedFolderIds.includes(folderId)
|
||||
? state.mutedFolderIds.filter((id) => id !== folderId)
|
||||
: [...state.mutedFolderIds, folderId];
|
||||
void invoke("set_muted_folder_ids", { folder_ids: next }).catch(() => {});
|
||||
return { mutedFolderIds: next };
|
||||
});
|
||||
},
|
||||
|
||||
loadNotificationsPaused: async () => {
|
||||
try {
|
||||
const paused = await invoke<boolean>("get_notifications_paused");
|
||||
set({ notificationsPaused: paused });
|
||||
} catch {
|
||||
// fall back to in-memory default
|
||||
}
|
||||
},
|
||||
|
||||
setNotificationsPaused: (paused) => {
|
||||
set({ notificationsPaused: paused });
|
||||
void invoke("set_notifications_paused", { paused }).catch(() => {});
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
window.localStorage.setItem(THEME_KEY, theme);
|
||||
document.documentElement.dataset.theme = theme;
|
||||
set({ theme });
|
||||
},
|
||||
|
||||
setLightboxAutoplay: (enabled) => {
|
||||
window.localStorage.setItem(LIGHTBOX_AUTOPLAY_KEY, String(enabled));
|
||||
set({ lightboxAutoplay: enabled });
|
||||
},
|
||||
|
||||
setLightboxAutoMute: (enabled) => {
|
||||
window.localStorage.setItem(LIGHTBOX_AUTO_MUTE_KEY, String(enabled));
|
||||
set({ lightboxAutoMute: enabled });
|
||||
},
|
||||
|
||||
setSlideshowIntervalSeconds: (seconds) => {
|
||||
const next = Math.min(60, Math.max(3, Math.round(seconds)));
|
||||
window.localStorage.setItem(SLIDESHOW_INTERVAL_KEY, String(next));
|
||||
set({ slideshowIntervalSeconds: next });
|
||||
},
|
||||
|
||||
setSlideshowOrder: (order) => {
|
||||
window.localStorage.setItem(SLIDESHOW_ORDER_KEY, order);
|
||||
set({ slideshowOrder: order });
|
||||
},
|
||||
|
||||
setSlideshowTransition: (transition) => {
|
||||
window.localStorage.setItem(SLIDESHOW_TRANSITION_KEY, transition);
|
||||
set({ slideshowTransition: transition });
|
||||
},
|
||||
|
||||
openAppDataFolder: async () => {
|
||||
await invoke("open_app_data_folder");
|
||||
},
|
||||
|
||||
getDatabaseInfo: () => invoke<DatabaseInfo>("get_database_info"),
|
||||
|
||||
vacuumDatabase: () => invoke<VacuumResult>("vacuum_database"),
|
||||
|
||||
rebuildSemanticIndex: () => invoke<number>("rebuild_semantic_index"),
|
||||
|
||||
getOrphanedThumbnailsInfo: () => invoke<OrphanedThumbnailsInfo>("get_orphaned_thumbnails_info"),
|
||||
|
||||
cleanupOrphanedThumbnails: () => invoke<CleanupOrphanedThumbnailsResult>("cleanup_orphaned_thumbnails"),
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { GalleryStore } from "./index";
|
||||
import type {
|
||||
TaggerAcceleration,
|
||||
TaggerModel,
|
||||
TaggerModelProgress,
|
||||
TaggerModelStatus,
|
||||
TaggerRuntimeProbe,
|
||||
TaggingQueueScope,
|
||||
} from "./types";
|
||||
|
||||
export interface TaggerSlice {
|
||||
taggerModelStatus: TaggerModelStatus | null;
|
||||
taggerModelPreparing: boolean;
|
||||
taggerModelError: string | null;
|
||||
taggerModelProgress: TaggerModelProgress | null;
|
||||
taggerModel: TaggerModel;
|
||||
taggerAcceleration: TaggerAcceleration;
|
||||
taggerThreshold: number;
|
||||
taggerBatchSize: number;
|
||||
taggerRuntimeProbe: TaggerRuntimeProbe | null;
|
||||
taggerRuntimeChecking: boolean;
|
||||
taggingQueueScope: TaggingQueueScope;
|
||||
taggingQueueFolderIds: number[];
|
||||
|
||||
loadTaggerModelStatus: () => Promise<void>;
|
||||
prepareTaggerModel: () => Promise<void>;
|
||||
deleteTaggerModel: () => Promise<void>;
|
||||
loadTaggerAcceleration: () => Promise<void>;
|
||||
setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise<void>;
|
||||
loadTaggerModel: () => Promise<void>;
|
||||
setTaggerModel: (model: TaggerModel) => Promise<void>;
|
||||
loadTaggerThreshold: () => Promise<void>;
|
||||
setTaggerThreshold: (threshold: number, model?: TaggerModel) => Promise<void>;
|
||||
loadTaggerBatchSize: () => Promise<void>;
|
||||
setTaggerBatchSize: (batchSize: number) => Promise<void>;
|
||||
probeTaggerRuntime: () => Promise<void>;
|
||||
queueTaggingJobs: (folderId?: number | null) => Promise<number>;
|
||||
queueTaggingJobsForFolders: (folderIds: number[]) => Promise<number>;
|
||||
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>;
|
||||
loadTaggingQueueScope: () => Promise<void>;
|
||||
setTaggingQueueScope: (scope: TaggingQueueScope) => void;
|
||||
loadTaggingQueueFolderIds: () => Promise<void>;
|
||||
toggleTaggingQueueFolder: (folderId: number) => void;
|
||||
setTaggingQueueFolderIds: (folderIds: number[]) => void;
|
||||
}
|
||||
|
||||
export const createTaggerSlice: StateCreator<GalleryStore, [], [], TaggerSlice> = (set, get) => ({
|
||||
taggerModelStatus: null,
|
||||
taggerModelPreparing: false,
|
||||
taggerModelError: null,
|
||||
taggerModelProgress: null,
|
||||
taggerModel: "wd",
|
||||
taggerAcceleration: "auto",
|
||||
taggerThreshold: 0.35,
|
||||
taggerBatchSize: 8,
|
||||
taggerRuntimeProbe: null,
|
||||
taggerRuntimeChecking: false,
|
||||
taggingQueueScope: "all",
|
||||
taggingQueueFolderIds: [],
|
||||
|
||||
loadTaggerModelStatus: async () => {
|
||||
try {
|
||||
const taggerModelStatus = await invoke<TaggerModelStatus>("get_tagger_model_status");
|
||||
set({ taggerModelStatus, taggerModelError: null });
|
||||
} catch (error) {
|
||||
set({ taggerModelError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
loadTaggerAcceleration: async () => {
|
||||
try {
|
||||
const taggerAcceleration = await invoke<TaggerAcceleration>("get_tagger_acceleration");
|
||||
set({ taggerAcceleration });
|
||||
} catch (error) {
|
||||
set({ taggerModelError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
setTaggerAcceleration: async (acceleration) => {
|
||||
const taggerAcceleration = await invoke<TaggerAcceleration>("set_tagger_acceleration", {
|
||||
params: { acceleration },
|
||||
});
|
||||
set({ taggerAcceleration, taggerRuntimeProbe: null });
|
||||
},
|
||||
|
||||
loadTaggerModel: async () => {
|
||||
try {
|
||||
const taggerModel = await invoke<TaggerModel>("get_tagger_model");
|
||||
// Never clobber the valid default with a missing/blank backend response.
|
||||
if (taggerModel) set({ taggerModel });
|
||||
} catch (error) {
|
||||
set({ taggerModelError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
setTaggerModel: async (model) => {
|
||||
const taggerModel = await invoke<TaggerModel>("set_tagger_model", {
|
||||
params: { model },
|
||||
});
|
||||
// Switching models changes both readiness and the active threshold setting,
|
||||
// so refresh them together for the selected model.
|
||||
try {
|
||||
const [taggerModelStatus, taggerThreshold] = await Promise.all([
|
||||
invoke<TaggerModelStatus>("get_tagger_model_status"),
|
||||
invoke<number>("get_tagger_threshold"),
|
||||
]);
|
||||
set({ taggerModel, taggerModelStatus, taggerThreshold, taggerModelError: null, taggerRuntimeProbe: null });
|
||||
} catch (error) {
|
||||
set({ taggerModel, taggerRuntimeProbe: null, taggerModelError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
loadTaggerThreshold: async () => {
|
||||
try {
|
||||
const taggerThreshold = await invoke<number>("get_tagger_threshold");
|
||||
set({ taggerThreshold });
|
||||
} catch (error) {
|
||||
set({ taggerModelError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
setTaggerThreshold: async (threshold, model) => {
|
||||
const taggerThreshold = await invoke<number>("set_tagger_threshold", {
|
||||
params: { threshold, model },
|
||||
});
|
||||
if (!model || get().taggerModel === model) {
|
||||
set({ taggerThreshold });
|
||||
}
|
||||
},
|
||||
|
||||
loadTaggerBatchSize: async () => {
|
||||
try {
|
||||
const taggerBatchSize = await invoke<number>("get_tagger_batch_size");
|
||||
set({ taggerBatchSize });
|
||||
} catch (error) {
|
||||
set({ taggerModelError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
setTaggerBatchSize: async (batchSize) => {
|
||||
const taggerBatchSize = await invoke<number>("set_tagger_batch_size", {
|
||||
params: { batch_size: batchSize },
|
||||
});
|
||||
set({ taggerBatchSize });
|
||||
},
|
||||
|
||||
prepareTaggerModel: async () => {
|
||||
set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null });
|
||||
try {
|
||||
const taggerModelStatus = await invoke<TaggerModelStatus>("prepare_tagger_model");
|
||||
set({ taggerModelStatus, taggerModelPreparing: false, taggerModelError: null, taggerModelProgress: null });
|
||||
} catch (error) {
|
||||
set({ taggerModelPreparing: false, taggerModelError: String(error), taggerModelProgress: null });
|
||||
}
|
||||
},
|
||||
|
||||
deleteTaggerModel: async () => {
|
||||
set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null });
|
||||
try {
|
||||
const taggerModelStatus = await invoke<TaggerModelStatus>("delete_tagger_model");
|
||||
set({ taggerModelStatus, taggerModelPreparing: false, taggerModelError: null, taggerModelProgress: null, taggerRuntimeProbe: null });
|
||||
} catch (error) {
|
||||
set({ taggerModelPreparing: false, taggerModelError: String(error), taggerModelProgress: null });
|
||||
}
|
||||
},
|
||||
|
||||
probeTaggerRuntime: async () => {
|
||||
set({ taggerRuntimeChecking: true, taggerModelError: null });
|
||||
try {
|
||||
const taggerRuntimeProbe = await invoke<TaggerRuntimeProbe>("probe_tagger_runtime");
|
||||
set({ taggerRuntimeProbe, taggerRuntimeChecking: false, taggerModelError: null });
|
||||
} catch (error) {
|
||||
set({ taggerRuntimeChecking: false, taggerModelError: String(error), taggerRuntimeProbe: null });
|
||||
}
|
||||
},
|
||||
|
||||
queueTaggingJobs: async (folderId = get().selectedFolderId) => {
|
||||
const queued = await invoke<number>("queue_tagging_jobs", {
|
||||
params: { folder_id: folderId ?? null, image_id: null },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
return queued;
|
||||
},
|
||||
|
||||
queueTaggingJobsForFolders: async (folderIds) => {
|
||||
const queued = await invoke<number>("queue_tagging_jobs", {
|
||||
params: { folder_id: null, folder_ids: folderIds, image_id: null },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
return queued;
|
||||
},
|
||||
|
||||
queueTaggingForImage: async (imageId) => {
|
||||
const queued = await invoke<number>("queue_tagging_jobs", {
|
||||
params: { folder_id: null, image_id: imageId },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
return queued;
|
||||
},
|
||||
|
||||
clearTaggingJobs: async (folderId = get().selectedFolderId) => {
|
||||
const cleared = await invoke<number>("clear_tagging_jobs", {
|
||||
params: { folder_id: folderId ?? null },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
return cleared;
|
||||
},
|
||||
|
||||
clearTaggingJobsForFolders: async (folderIds) => {
|
||||
const cleared = await invoke<number>("clear_tagging_jobs", {
|
||||
params: { folder_id: null, folder_ids: folderIds },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
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;
|
||||
},
|
||||
|
||||
loadTaggingQueueScope: async () => {
|
||||
try {
|
||||
const scope = await invoke<TaggingQueueScope>("get_tagging_queue_scope");
|
||||
set({ taggingQueueScope: scope });
|
||||
} catch {
|
||||
// silently fall back to in-memory default
|
||||
}
|
||||
},
|
||||
|
||||
setTaggingQueueScope: (taggingQueueScope) => {
|
||||
set((state) => ({
|
||||
taggingQueueScope,
|
||||
taggingQueueFolderIds: state.taggingQueueFolderIds,
|
||||
}));
|
||||
void invoke("set_tagging_queue_scope", { scope: taggingQueueScope }).catch(() => {});
|
||||
},
|
||||
|
||||
loadTaggingQueueFolderIds: async () => {
|
||||
try {
|
||||
const folderIds = await invoke<number[]>("get_tagging_queue_folder_ids");
|
||||
set({ taggingQueueFolderIds: folderIds });
|
||||
} catch {
|
||||
// silently fall back to in-memory default
|
||||
}
|
||||
},
|
||||
|
||||
toggleTaggingQueueFolder: (folderId) => {
|
||||
set((state) => {
|
||||
const next = state.taggingQueueFolderIds.includes(folderId)
|
||||
? state.taggingQueueFolderIds.filter((id) => id !== folderId)
|
||||
: [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b);
|
||||
void invoke("set_tagging_queue_folder_ids", { folder_ids: next }).catch(() => {});
|
||||
return { taggingQueueFolderIds: next };
|
||||
});
|
||||
},
|
||||
|
||||
setTaggingQueueFolderIds: (taggingQueueFolderIds) => {
|
||||
set({ taggingQueueFolderIds });
|
||||
void invoke("set_tagging_queue_folder_ids", { folder_ids: taggingQueueFolderIds }).catch(() => {});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
export interface Folder {
|
||||
id: number;
|
||||
path: string;
|
||||
name: string;
|
||||
image_count: number;
|
||||
indexed_at: string | null;
|
||||
scan_error: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
has_children: boolean;
|
||||
}
|
||||
|
||||
export interface DirListing {
|
||||
current: string | null;
|
||||
parent: string | null;
|
||||
entries: DirEntry[];
|
||||
}
|
||||
|
||||
export type FolderAddResult =
|
||||
| { status: "added"; data: Folder }
|
||||
| { status: "skipped"; data: string }
|
||||
| { status: "error"; data: string };
|
||||
|
||||
export type MediaKind = "image" | "video";
|
||||
export type MediaFilter = "all" | MediaKind;
|
||||
export type ZoomPreset = "compact" | "comfortable" | "detail";
|
||||
export type SearchMode = "filename" | "semantic";
|
||||
export type SearchCommand = "filename" | "semantic" | "tag";
|
||||
export type CaptionAcceleration = "auto" | "cpu" | "directml";
|
||||
export type CaptionDetail = "short" | "detailed" | "paragraph";
|
||||
export type TaggerAcceleration = "auto" | "cpu" | "directml";
|
||||
export type TaggerModel = "wd" | "joytag";
|
||||
export type AiRating = "general" | "sensitive" | "questionable" | "explicit";
|
||||
export type TaggingQueueScope = "all" | "selected";
|
||||
export type SimilarScope = "all_media" | "current_folder" | "current_album";
|
||||
export type ExploreMode = "visual" | "tags";
|
||||
export type AppTheme = "phokus" | "subtle-light" | "conventional-dark";
|
||||
export type SlideshowOrder = "sequential" | "random";
|
||||
export type SlideshowTransition = "soft-fade" | "gentle-motion";
|
||||
|
||||
export interface ImageRecord {
|
||||
id: number;
|
||||
folder_id: number;
|
||||
path: string;
|
||||
filename: string;
|
||||
thumbnail_path: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
file_size: number;
|
||||
created_at: string | null;
|
||||
modified_at: string | null;
|
||||
taken_at: string | null;
|
||||
mime_type: string;
|
||||
media_kind: MediaKind;
|
||||
duration_ms: number | null;
|
||||
video_codec: string | null;
|
||||
audio_codec: string | null;
|
||||
metadata_updated_at: string | null;
|
||||
metadata_error: string | null;
|
||||
favorite: boolean;
|
||||
rating: number;
|
||||
embedding_status: string;
|
||||
embedding_model: string | null;
|
||||
embedding_updated_at: string | null;
|
||||
embedding_error: string | null;
|
||||
generated_caption: string | null;
|
||||
caption_model: string | null;
|
||||
caption_updated_at: string | null;
|
||||
caption_error: string | null;
|
||||
ai_rating: AiRating | null;
|
||||
ai_tagger_model: string | null;
|
||||
ai_tagged_at: string | null;
|
||||
ai_tagger_error: string | null;
|
||||
}
|
||||
|
||||
export interface ImageTag {
|
||||
id: number;
|
||||
image_id: number;
|
||||
tag: string;
|
||||
source: "user" | "ai";
|
||||
ai_model: string | null;
|
||||
confidence: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DatabaseInfo {
|
||||
size_mb: number;
|
||||
reclaimable_mb: number;
|
||||
}
|
||||
|
||||
export interface VacuumResult {
|
||||
before_mb: number;
|
||||
after_mb: number;
|
||||
freed_mb: number;
|
||||
}
|
||||
|
||||
export interface OrphanedThumbnailsInfo {
|
||||
count: number;
|
||||
size_mb: number;
|
||||
}
|
||||
|
||||
export interface CleanupOrphanedThumbnailsResult {
|
||||
deleted_count: number;
|
||||
freed_mb: number;
|
||||
}
|
||||
|
||||
export interface TaggerModelStatus {
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
local_dir: string;
|
||||
ready: boolean;
|
||||
missing_files: string[];
|
||||
}
|
||||
|
||||
export interface TaggerModelProgress {
|
||||
total_files: number;
|
||||
completed_files: number;
|
||||
current_file: string | null;
|
||||
downloaded_bytes: number | null;
|
||||
total_bytes: number | null;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface IndexProgress {
|
||||
folder_id: number;
|
||||
total: number;
|
||||
indexed: number;
|
||||
current_file: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface FolderJobProgress {
|
||||
folder_id: number;
|
||||
thumbnail_pending: number;
|
||||
metadata_pending: number;
|
||||
embedding_pending: number;
|
||||
embedding_ready: number;
|
||||
embedding_failed: number;
|
||||
caption_pending: number;
|
||||
caption_ready: number;
|
||||
caption_failed: number;
|
||||
tagging_pending: number;
|
||||
tagging_ready: number;
|
||||
tagging_failed: number;
|
||||
}
|
||||
|
||||
export interface MediaJobProgressEvent {
|
||||
progress: FolderJobProgress[];
|
||||
}
|
||||
|
||||
export interface IndexedImagesBatch {
|
||||
folder_id: number;
|
||||
images: ImageRecord[];
|
||||
}
|
||||
|
||||
export interface ThumbnailBatch {
|
||||
images: ImageRecord[];
|
||||
}
|
||||
|
||||
export type ActiveView = "gallery" | "explore" | "duplicates" | "timeline" | "album";
|
||||
|
||||
export interface Album {
|
||||
id: number;
|
||||
name: string;
|
||||
cover_image_id: number | null;
|
||||
cover_thumbnail_path: string | null;
|
||||
image_count: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ImageExif {
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
lens: string | null;
|
||||
iso: string | null;
|
||||
f_number: string | null;
|
||||
exposure_time: string | null;
|
||||
focal_length: string | null;
|
||||
datetime_original: string | null;
|
||||
gps_lat: number | null;
|
||||
gps_lon: number | null;
|
||||
}
|
||||
|
||||
export interface VisualClusterEntry {
|
||||
count: number;
|
||||
representative_image_id: number;
|
||||
thumbnail_path: string | null;
|
||||
image_ids: number[];
|
||||
}
|
||||
|
||||
export interface ExploreTagEntry {
|
||||
tag: string;
|
||||
count: number;
|
||||
representative_image_id: number;
|
||||
thumbnail_path: string | null;
|
||||
has_ai_source: boolean;
|
||||
has_user_source: boolean;
|
||||
}
|
||||
|
||||
export interface RelatedTagEntry {
|
||||
tag: string;
|
||||
shared_count: number;
|
||||
}
|
||||
|
||||
export interface DuplicateGroup {
|
||||
file_hash: string;
|
||||
file_size: number;
|
||||
images: ImageRecord[];
|
||||
}
|
||||
|
||||
export interface DuplicateScanProgress {
|
||||
phase: "checking" | "hashing" | "confirming";
|
||||
processed: number;
|
||||
total: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export interface DuplicateScanResult {
|
||||
groups: DuplicateGroup[];
|
||||
scanned_files: number;
|
||||
candidate_files: number;
|
||||
skipped_files: number;
|
||||
}
|
||||
|
||||
export interface SimilarImagesPage {
|
||||
images: ImageRecord[];
|
||||
offset: number;
|
||||
limit: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface CaptionModelStatus {
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
local_dir: string;
|
||||
ready: boolean;
|
||||
missing_files: string[];
|
||||
}
|
||||
|
||||
export interface CaptionModelProgress {
|
||||
total_files: number;
|
||||
completed_files: number;
|
||||
current_file: string | null;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface CaptionRuntimeSessionProbe {
|
||||
file: string;
|
||||
inputs: string[];
|
||||
outputs: string[];
|
||||
}
|
||||
|
||||
export interface CaptionRuntimeProbe {
|
||||
ready: boolean;
|
||||
acceleration: CaptionAcceleration;
|
||||
detail: CaptionDetail;
|
||||
tokenizer_vocab_size: number;
|
||||
sessions: CaptionRuntimeSessionProbe[];
|
||||
}
|
||||
|
||||
export interface CaptionVisionProbe {
|
||||
input_shape: number[];
|
||||
output_shape: number[];
|
||||
output_values: number;
|
||||
acceleration: CaptionAcceleration;
|
||||
}
|
||||
|
||||
export interface TaggerRuntimeSessionProbe {
|
||||
file: string;
|
||||
inputs: string[];
|
||||
outputs: string[];
|
||||
}
|
||||
|
||||
export interface TaggerRuntimeProbe {
|
||||
ready: boolean;
|
||||
acceleration: TaggerAcceleration;
|
||||
session: TaggerRuntimeSessionProbe;
|
||||
}
|
||||
|
||||
export interface ParsedSearch {
|
||||
mode: SearchCommand;
|
||||
query: string;
|
||||
prefix: string | null;
|
||||
}
|
||||
|
||||
export type SortOrder =
|
||||
| "date_desc"
|
||||
| "date_asc"
|
||||
| "name_asc"
|
||||
| "name_desc"
|
||||
| "size_desc"
|
||||
| "size_asc"
|
||||
| "rating_desc"
|
||||
| "rating_asc"
|
||||
| "duration_desc"
|
||||
| "duration_asc"
|
||||
| "taken_desc"
|
||||
| "taken_asc";
|
||||
|
||||
export type UpdateStatus = "idle" | "checking" | "upToDate" | "available" | "downloading" | "installing" | "error";
|
||||
|
||||
export type WorkerKey = "thumbnail" | "metadata" | "embedding" | "tagging";
|
||||
|
||||
export const WORKER_KEYS: WorkerKey[] = ["thumbnail", "metadata", "embedding", "tagging"];
|
||||
|
||||
export interface FolderWorkerStates {
|
||||
folder_id: number;
|
||||
thumbnail_paused: boolean;
|
||||
metadata_paused: boolean;
|
||||
embedding_paused: boolean;
|
||||
tagging_paused: boolean;
|
||||
}
|
||||
|
||||
export type FfmpegStatus = "unknown" | "starting" | "downloading" | "unpacking" | "installed" | "error";
|
||||
|
||||
export interface FfmpegProgressEvent {
|
||||
phase: string;
|
||||
downloaded_bytes: number | null;
|
||||
total_bytes: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user