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
|
an album can now stay inside that album, jump back to the source folder, or
|
||||||
search everything.
|
search everything.
|
||||||
- **Tag manager** — Explore's Tag Cloud now has a Manage mode for renaming,
|
- **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
|
merging, and deleting tags across the whole library.
|
||||||
manager" shortcut in Settings -> AI Workspace when you want to get straight to
|
|
||||||
the cleanup.
|
|
||||||
- **Camera info in the lightbox** — the info panel now shows EXIF details like
|
- **Camera info in the lightbox** — the info panel now shows EXIF details like
|
||||||
camera, lens, aperture, shutter speed, ISO, and focal length. Geotagged photos
|
camera, lens, aperture, shutter speed, ISO, and focal length. Geotagged photos
|
||||||
also get a browser link for their GPS coordinates, and already-indexed images
|
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
|
- **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
|
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).
|
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
|
- **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.
|
that most often appear with it, complete with connection lines and image counts.
|
||||||
Handy for finding little clusters you did not know were there.
|
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
|
- **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.
|
paste a path directly while still using breadcrumbs for quick jumps.
|
||||||
- **Slideshow mode** — turn the lightbox into a fullscreen, image-only slideshow
|
- **Slideshow mode** — turn the lightbox into a fullscreen, image-only slideshow
|
||||||
from whatever collection you are already browsing. Videos are skipped, controls
|
from whatever collection you are already browsing.
|
||||||
tuck themselves away after a few seconds, and Settings lets you pick the pace
|
|
||||||
and whether playback follows the current order or goes random.
|
|
||||||
- **Add to album from the right-click menu** — right-click any image in the
|
- **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
|
Gallery or Timeline and file it straight into an album from the new "Add to
|
||||||
Album" submenu. One image, one click, zero ceremony.
|
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.
|
cleaned up on startup. Your manually-added tags are left alone.
|
||||||
- **Selected Folders starts empty** — choosing "Selected Folders" for AI tagging
|
- **Selected Folders starts empty** — choosing "Selected Folders" for AI tagging
|
||||||
no longer pre-selects the first folder. You decide exactly what gets queued.
|
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
|
## [0.1.1] — 2026-06-23
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ There are no test suites configured.
|
|||||||
|
|
||||||
### Frontend (`src/`)
|
### 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).
|
- **`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/`** — 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.
|
- **`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
|
### 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)
|
- No prefix / `f:` — filename search (paginated, DB-backed)
|
||||||
- `/s <query>` or `s: <query>` — semantic (embedding) search
|
- `/s <query>` or `s: <query>` — semantic (embedding) search
|
||||||
- `/t <tag>` or `t: <tag>` — tag 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
|
### 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
|
## 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