9047c8053a
Timeline: - Add a right-edge scrubber (year labels + month dots) that jumps to any period; runs in the same direction as the scrolled content - Load the full filtered set in Timeline view so the scrubber spans the whole library instead of just the first page Gallery & UI: - Virtualise the gallery grid - Folder reordering in the sidebar (drag + persisted sort_order) and a themed dropdown component - Subtle Light theme contrast fixes for toggles, secondary buttons and the update toast Embedding workers now defer video jobs without a thumbnail at claim time and requeue any previously-failed deferred jobs on startup, so videos no longer churn through failed embeddings. QoL polish across BackgroundTasks, DuplicateFinder, Lightbox and VideoPlayer.
2333 lines
79 KiB
TypeScript
2333 lines
79 KiB
TypeScript
import { create } from "zustand";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
|
import { appDataDir, join } from "@tauri-apps/api/path";
|
|
import { getVersion } from "@tauri-apps/api/app";
|
|
import { check, Update } from "@tauri-apps/plugin-updater";
|
|
import { relaunch } from "@tauri-apps/plugin-process";
|
|
import { notifyTaskComplete } from "./notifications";
|
|
|
|
// 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;
|
|
const THEME_KEY = "phokus-theme";
|
|
|
|
export interface Folder {
|
|
id: number;
|
|
path: string;
|
|
name: string;
|
|
image_count: number;
|
|
indexed_at: string | null;
|
|
scan_error: string | null;
|
|
sort_order: number;
|
|
}
|
|
|
|
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 AiRating = "general" | "sensitive" | "questionable" | "explicit";
|
|
export type TaggingQueueScope = "all" | "selected";
|
|
export type SimilarScope = "all_media" | "current_folder";
|
|
export type ExploreMode = "visual" | "tags";
|
|
export type AppTheme = "phokus" | "subtle-light" | "conventional-dark";
|
|
|
|
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";
|
|
|
|
export interface TagCloudEntry {
|
|
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;
|
|
}
|
|
|
|
export interface DuplicateGroup {
|
|
file_hash: string;
|
|
file_size: number;
|
|
images: ImageRecord[];
|
|
}
|
|
|
|
export interface DuplicateScanProgress {
|
|
phase: "checking" | "hashing" | "confirming";
|
|
processed: number;
|
|
total: number;
|
|
skipped: number;
|
|
}
|
|
|
|
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";
|
|
|
|
const WORKER_KEYS: WorkerKey[] = ["thumbnail", "metadata", "embedding", "tagging"];
|
|
|
|
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";
|
|
|
|
interface FfmpegProgressEvent {
|
|
phase: string;
|
|
downloaded_bytes: number | null;
|
|
total_bytes: number | null;
|
|
error: string | null;
|
|
}
|
|
|
|
// 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;
|
|
|
|
interface GalleryState {
|
|
folders: Folder[];
|
|
selectedFolderId: number | null;
|
|
images: ImageRecord[];
|
|
totalImages: number;
|
|
loadedCount: number;
|
|
loadingImages: boolean;
|
|
imageLoadError: string | null;
|
|
search: string;
|
|
searchMode: SearchMode;
|
|
sort: SortOrder;
|
|
mediaFilter: MediaFilter;
|
|
favoritesOnly: boolean;
|
|
minimumRating: number;
|
|
failedEmbeddingsOnly: boolean;
|
|
zoomPreset: ZoomPreset;
|
|
selectedImage: ImageRecord | null;
|
|
collectionTitle: string | null;
|
|
similarSourceImageId: number | null;
|
|
similarSourceFolderId: number | null;
|
|
similarHasMore: boolean;
|
|
similarScope: SimilarScope;
|
|
similarFolderId: number | null;
|
|
similarCrop: { x: number; y: number; w: number; h: number } | null;
|
|
galleryScrollResetKey: number;
|
|
activeView: ActiveView;
|
|
exploreMode: ExploreMode;
|
|
tagCloudEntries: TagCloudEntry[];
|
|
tagCloudLoading: boolean;
|
|
tagCloudFolderId: number | null | undefined; // undefined = never loaded
|
|
exploreTagEntries: ExploreTagEntry[];
|
|
exploreTagLoading: boolean;
|
|
exploreTagsFolderId: number | null | undefined;
|
|
indexingProgress: Record<number, IndexProgress>;
|
|
mediaJobProgress: Record<number, FolderJobProgress>;
|
|
cacheDir: string;
|
|
captionModelStatus: CaptionModelStatus | null;
|
|
captionModelPreparing: boolean;
|
|
captionModelError: string | null;
|
|
captionModelProgress: CaptionModelProgress | null;
|
|
captionRuntimeProbe: CaptionRuntimeProbe | null;
|
|
captionRuntimeChecking: boolean;
|
|
captionAcceleration: CaptionAcceleration;
|
|
captionDetail: CaptionDetail;
|
|
aiCaptionsEnabled: boolean;
|
|
settingsOpen: boolean;
|
|
taggingQueueScope: TaggingQueueScope;
|
|
taggingQueueFolderIds: number[];
|
|
mutedFolderIds: number[];
|
|
notificationsPaused: boolean;
|
|
theme: AppTheme;
|
|
// Per-folder background-worker pause flags, shared by the BackgroundTasks
|
|
// bar and the sidebar folder context menu.
|
|
workerPaused: Record<number, Record<WorkerKey, boolean>>;
|
|
|
|
appVersion: string | null;
|
|
updateStatus: UpdateStatus;
|
|
updateVersion: string | null;
|
|
updateProgress: number | null; // 0..1 download progress, null while size unknown
|
|
updateError: string | null;
|
|
updateDismissed: boolean;
|
|
|
|
ffmpegStatus: FfmpegStatus;
|
|
ffmpegProgress: { downloaded_bytes: number; total_bytes: number } | null;
|
|
ffmpegError: string | null;
|
|
onboardingCompleted: boolean | null; // null = not loaded yet
|
|
onboardingOpen: boolean;
|
|
onboardingStep: number;
|
|
|
|
taggerModelStatus: TaggerModelStatus | null;
|
|
taggerModelPreparing: boolean;
|
|
taggerModelError: string | null;
|
|
taggerModelProgress: TaggerModelProgress | null;
|
|
taggerAcceleration: TaggerAcceleration;
|
|
taggerThreshold: number;
|
|
taggerBatchSize: number;
|
|
taggerRuntimeProbe: TaggerRuntimeProbe | null;
|
|
taggerRuntimeChecking: boolean;
|
|
|
|
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
|
|
|
|
loadFolders: () => Promise<void>;
|
|
loadBackgroundJobProgress: () => Promise<void>;
|
|
addFolder: (path: string) => Promise<void>;
|
|
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;
|
|
loadImages: (reset?: boolean) => Promise<void>;
|
|
loadMoreImages: () => Promise<void>;
|
|
setSearch: (search: string) => void;
|
|
clearSearch: () => void;
|
|
resetSearch: () => void;
|
|
setSearchMode: (mode: SearchMode) => void;
|
|
setSort: (sort: SortOrder) => void;
|
|
setMediaFilter: (filter: MediaFilter) => void;
|
|
setFavoritesOnly: (favoritesOnly: boolean) => void;
|
|
setMinimumRating: (minimumRating: number) => void;
|
|
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
|
|
setZoomPreset: (zoomPreset: ZoomPreset) => void;
|
|
openImage: (image: ImageRecord) => void;
|
|
closeImage: () => void;
|
|
setView: (view: ActiveView) => void;
|
|
setExploreMode: (mode: ExploreMode) => void;
|
|
loadTagCloud: () => Promise<void>;
|
|
loadExploreTags: () => Promise<void>;
|
|
showVisualCluster: (imageIds: number[]) => Promise<void>;
|
|
searchForTag: (tag: string) => void;
|
|
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null) => Promise<void>;
|
|
loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null) => Promise<void>;
|
|
setSimilarScope: (scope: SimilarScope) => void;
|
|
suggestImageTags: (imageId: number) => Promise<string[]>;
|
|
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;
|
|
setSettingsOpen: (open: boolean) => void;
|
|
loadTaggingQueueScope: () => Promise<void>;
|
|
setTaggingQueueScope: (scope: TaggingQueueScope) => void;
|
|
loadTaggingQueueFolderIds: () => Promise<void>;
|
|
toggleTaggingQueueFolder: (folderId: number) => void;
|
|
setTaggingQueueFolderIds: (folderIds: number[]) => void;
|
|
loadMutedFolderIds: () => Promise<void>;
|
|
toggleMutedFolder: (folderId: number) => void;
|
|
loadNotificationsPaused: () => Promise<void>;
|
|
setNotificationsPaused: (paused: boolean) => void;
|
|
setTheme: (theme: AppTheme) => void;
|
|
loadWorkerStates: () => Promise<void>;
|
|
setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void;
|
|
setAllWorkersPaused: (folderId: number, paused: boolean) => void;
|
|
loadAppVersion: () => Promise<void>;
|
|
checkForUpdates: (options?: { quiet?: boolean }) => Promise<void>;
|
|
installUpdate: () => Promise<void>;
|
|
dismissUpdate: () => void;
|
|
loadFfmpegStatus: () => Promise<void>;
|
|
retryFfmpegDownload: () => Promise<void>;
|
|
loadOnboardingCompleted: () => Promise<void>;
|
|
completeOnboarding: () => void;
|
|
openOnboarding: () => void;
|
|
setOnboardingStep: (step: number) => void;
|
|
openAppDataFolder: () => Promise<void>;
|
|
getDatabaseInfo: () => Promise<DatabaseInfo>;
|
|
vacuumDatabase: () => Promise<VacuumResult>;
|
|
getOrphanedThumbnailsInfo: () => Promise<OrphanedThumbnailsInfo>;
|
|
cleanupOrphanedThumbnails: () => Promise<CleanupOrphanedThumbnailsResult>;
|
|
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
|
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
|
|
setCacheDir: (dir: string) => void;
|
|
subscribeToProgress: () => Promise<UnlistenFn>;
|
|
|
|
loadTaggerModelStatus: () => Promise<void>;
|
|
prepareTaggerModel: () => Promise<void>;
|
|
deleteTaggerModel: () => Promise<void>;
|
|
loadTaggerAcceleration: () => Promise<void>;
|
|
setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise<void>;
|
|
loadTaggerThreshold: () => Promise<void>;
|
|
setTaggerThreshold: (threshold: number) => 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>;
|
|
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>;
|
|
getImageTags: (imageId: number) => Promise<ImageTag[]>;
|
|
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>;
|
|
removeTag: (tagId: number) => Promise<void>;
|
|
}
|
|
|
|
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.
|
|
const TIMELINE_PAGE_SIZE = 100000;
|
|
const AI_CAPTIONS_ENABLED_KEY = "phokus.aiCaptionsEnabled";
|
|
const SIMILAR_DISTANCE_THRESHOLD = 0.24;
|
|
|
|
// Single token shared by all gallery-producing requests (folder loads, searches,
|
|
// similarity, region search). Any new request increments it so a stale response
|
|
// from a previous collection type cannot overwrite newer results.
|
|
let galleryRequestToken = 0;
|
|
let tagCloudRequestToken = 0;
|
|
let exploreTagRequestToken = 0;
|
|
|
|
function initialAiCaptionsEnabled(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
return window.localStorage.getItem(AI_CAPTIONS_ENABLED_KEY) === "true";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
function matchesSearch(image: ImageRecord, search: string): boolean {
|
|
if (!search) return true;
|
|
return image.filename.toLowerCase().includes(search.toLowerCase());
|
|
}
|
|
|
|
function isDerivedCollectionTitle(collectionTitle: string | null): boolean {
|
|
return collectionTitle !== null;
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|
|
|
|
function matchesFilters(
|
|
image: ImageRecord,
|
|
selectedFolderId: number | null,
|
|
mediaFilter: MediaFilter,
|
|
favoritesOnly: boolean,
|
|
minimumRating: number,
|
|
failedEmbeddingsOnly: 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";
|
|
return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && 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);
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: SortOrder): ImageRecord[] {
|
|
return mergeImages(images, [updatedImage], sort);
|
|
}
|
|
|
|
function replaceExistingImages(
|
|
currentImages: ImageRecord[],
|
|
updatedImages: ImageRecord[],
|
|
sort: SortOrder,
|
|
): ImageRecord[] {
|
|
const updatesByPath = new Map(updatedImages.map((image) => [image.path, image]));
|
|
const nextImages = currentImages.map((image) => updatesByPath.get(image.path) ?? image);
|
|
return nextImages.sort((a, b) => compareImages(a, b, sort));
|
|
}
|
|
|
|
export function tileSizeForZoom(zoomPreset: ZoomPreset): number {
|
|
switch (zoomPreset) {
|
|
case "compact":
|
|
return 160;
|
|
case "detail":
|
|
return 280;
|
|
default:
|
|
return 220;
|
|
}
|
|
}
|
|
|
|
export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|
folders: [],
|
|
selectedFolderId: null,
|
|
images: [],
|
|
totalImages: 0,
|
|
loadedCount: 0,
|
|
loadingImages: false,
|
|
imageLoadError: null,
|
|
search: "",
|
|
searchMode: "filename",
|
|
sort: "date_desc",
|
|
mediaFilter: "all",
|
|
favoritesOnly: false,
|
|
minimumRating: 0,
|
|
failedEmbeddingsOnly: false,
|
|
zoomPreset: "comfortable",
|
|
selectedImage: null,
|
|
collectionTitle: null,
|
|
similarSourceImageId: null,
|
|
similarSourceFolderId: null,
|
|
similarHasMore: false,
|
|
similarScope: "all_media",
|
|
similarFolderId: null,
|
|
similarCrop: null,
|
|
galleryScrollResetKey: 0,
|
|
activeView: "gallery",
|
|
exploreMode: "visual",
|
|
tagCloudEntries: [],
|
|
tagCloudLoading: false,
|
|
tagCloudFolderId: undefined,
|
|
exploreTagEntries: [],
|
|
exploreTagLoading: false,
|
|
exploreTagsFolderId: undefined,
|
|
indexingProgress: {},
|
|
mediaJobProgress: {},
|
|
cacheDir: "",
|
|
captionModelStatus: null,
|
|
captionModelPreparing: false,
|
|
captionModelError: null,
|
|
captionModelProgress: null,
|
|
captionRuntimeProbe: null,
|
|
captionRuntimeChecking: false,
|
|
captionAcceleration: "auto",
|
|
captionDetail: "paragraph",
|
|
aiCaptionsEnabled: initialAiCaptionsEnabled(),
|
|
settingsOpen: false,
|
|
taggingQueueScope: "all",
|
|
taggingQueueFolderIds: [],
|
|
mutedFolderIds: [],
|
|
notificationsPaused: false,
|
|
theme: initialTheme(),
|
|
workerPaused: {},
|
|
|
|
appVersion: null,
|
|
updateStatus: "idle",
|
|
updateVersion: null,
|
|
updateProgress: null,
|
|
updateError: null,
|
|
updateDismissed: false,
|
|
|
|
ffmpegStatus: "unknown",
|
|
ffmpegProgress: null,
|
|
ffmpegError: null,
|
|
onboardingCompleted: null,
|
|
onboardingOpen: false,
|
|
onboardingStep: 0,
|
|
|
|
taggerModelStatus: null,
|
|
taggerModelPreparing: false,
|
|
taggerModelError: null,
|
|
taggerModelProgress: null,
|
|
taggerAcceleration: "auto",
|
|
taggerThreshold: 0.35,
|
|
taggerBatchSize: 8,
|
|
taggerRuntimeProbe: null,
|
|
taggerRuntimeChecking: false,
|
|
|
|
duplicateGroups: [],
|
|
duplicateScanning: false,
|
|
duplicateScanProgress: null,
|
|
duplicateScanError: null,
|
|
duplicateScanWarning: null,
|
|
duplicateSelectedIds: new Set(),
|
|
duplicateLastScanned: null,
|
|
duplicateScanFolderId: undefined,
|
|
|
|
setCacheDir: (cacheDir) => set({ cacheDir }),
|
|
|
|
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.length > 0
|
|
? nextSelected
|
|
: state.taggingQueueScope === "selected" && folders.length > 0
|
|
? [folders[0].id]
|
|
: 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();
|
|
},
|
|
|
|
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({ tagCloudFolderId: undefined, tagCloudEntries: [], 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({ tagCloudFolderId: undefined, tagCloudEntries: [] });
|
|
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) => {
|
|
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: 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 TagCloud's useEffect on selectedFolderId.
|
|
if (activeView === "explore") return;
|
|
|
|
void get().loadImages(true);
|
|
},
|
|
|
|
loadImages: async (reset = false) => {
|
|
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, activeView } = get();
|
|
const parsedSearch = parseSearchValue(search);
|
|
const requestToken = ++galleryRequestToken;
|
|
set({ loadingImages: true, imageLoadError: null });
|
|
|
|
try {
|
|
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 (requestToken !== galleryRequestToken) 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,
|
|
limit: PAGE_SIZE,
|
|
offset,
|
|
},
|
|
});
|
|
|
|
if (requestToken !== galleryRequestToken) 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,
|
|
sort,
|
|
offset,
|
|
limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE,
|
|
},
|
|
});
|
|
|
|
if (requestToken !== galleryRequestToken) 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 (requestToken !== galleryRequestToken) 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;
|
|
if (collectionTitle === "Similar Images" && similarSourceImageId !== null) {
|
|
if (!similarHasMore) return;
|
|
await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null);
|
|
return;
|
|
}
|
|
if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) {
|
|
if (!similarHasMore) return;
|
|
const requestToken = ++galleryRequestToken;
|
|
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: similarFolderId,
|
|
offset: loadedCount,
|
|
limit: PAGE_SIZE,
|
|
},
|
|
});
|
|
if (requestToken !== galleryRequestToken) 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 (requestToken !== galleryRequestToken) return;
|
|
set({ loadingImages: false });
|
|
}
|
|
return;
|
|
}
|
|
await get().loadImages(false);
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
setSort: (sort) => {
|
|
set({ sort, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
|
void get().loadImages(true);
|
|
},
|
|
|
|
setMediaFilter: (mediaFilter) => {
|
|
set({ mediaFilter, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
|
void get().loadImages(true);
|
|
},
|
|
|
|
setFavoritesOnly: (favoritesOnly) => {
|
|
set({ favoritesOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
|
void get().loadImages(true);
|
|
},
|
|
|
|
setMinimumRating: (minimumRating) => {
|
|
set({ minimumRating, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
|
void get().loadImages(true);
|
|
},
|
|
|
|
setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => {
|
|
set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
|
void get().loadImages(true);
|
|
},
|
|
|
|
setZoomPreset: (zoomPreset) => set({ zoomPreset }),
|
|
|
|
openImage: (image) => set({ selectedImage: image }),
|
|
closeImage: () => set({ selectedImage: null }),
|
|
|
|
setView: (activeView) => {
|
|
if (activeView === "timeline") {
|
|
set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, 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;
|
|
}
|
|
}
|
|
set({ activeView });
|
|
},
|
|
|
|
setExploreMode: (exploreMode) => set({ exploreMode }),
|
|
|
|
loadTagCloud: async () => {
|
|
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
|
|
// Skip if already loaded for this folder and not currently loading
|
|
if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
|
|
return;
|
|
}
|
|
const requestToken = ++tagCloudRequestToken;
|
|
set({ tagCloudLoading: true, tagCloudFolderId: selectedFolderId });
|
|
try {
|
|
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", {
|
|
folderId: selectedFolderId,
|
|
});
|
|
if (requestToken !== tagCloudRequestToken) return;
|
|
set({ tagCloudEntries: entries, tagCloudLoading: false });
|
|
} catch (error) {
|
|
if (requestToken !== tagCloudRequestToken) return;
|
|
console.error("Failed to load tag cloud:", error);
|
|
set({ tagCloudLoading: false });
|
|
}
|
|
},
|
|
|
|
loadExploreTags: async () => {
|
|
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
|
|
if (!exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
|
|
return;
|
|
}
|
|
const requestToken = ++exploreTagRequestToken;
|
|
set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId });
|
|
try {
|
|
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
|
|
params: { folder_id: selectedFolderId, limit: 48 },
|
|
});
|
|
if (requestToken !== exploreTagRequestToken) return;
|
|
set({ exploreTagEntries: entries, exploreTagLoading: false });
|
|
} catch (error) {
|
|
if (requestToken !== exploreTagRequestToken) return;
|
|
console.error("Failed to load explore tags:", error);
|
|
set({ exploreTagLoading: false });
|
|
}
|
|
},
|
|
|
|
showVisualCluster: async (imageIds) => {
|
|
const requestToken = ++galleryRequestToken;
|
|
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,
|
|
galleryScrollResetKey: state.galleryScrollResetKey + 1,
|
|
}));
|
|
|
|
try {
|
|
const images = await invoke<ImageRecord[]>("get_images_by_ids", {
|
|
params: { image_ids: imageIds },
|
|
});
|
|
if (requestToken !== galleryRequestToken) return;
|
|
set({
|
|
images,
|
|
totalImages: images.length,
|
|
loadedCount: images.length,
|
|
loadingImages: false,
|
|
imageLoadError: null,
|
|
collectionTitle: "Explore Cluster",
|
|
});
|
|
} catch (error) {
|
|
if (requestToken !== galleryRequestToken) return;
|
|
set({
|
|
images: [],
|
|
totalImages: 0,
|
|
loadedCount: 0,
|
|
loadingImages: false,
|
|
imageLoadError: String(error),
|
|
collectionTitle: "Explore Cluster",
|
|
});
|
|
}
|
|
},
|
|
|
|
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) => {
|
|
const requestToken = ++galleryRequestToken;
|
|
const offset = reset ? 0 : get().loadedCount;
|
|
const similarScope = folderId === null ? "all_media" : "current_folder";
|
|
set((state) => ({
|
|
images: reset ? [] : state.images,
|
|
loadedCount: reset ? 0 : state.loadedCount,
|
|
loadingImages: true,
|
|
collectionTitle: "Similar Images",
|
|
imageLoadError: null,
|
|
similarSourceImageId: imageId,
|
|
similarSourceFolderId: sourceFolderId,
|
|
similarFolderId: folderId ?? null,
|
|
similarScope,
|
|
galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey,
|
|
}));
|
|
|
|
try {
|
|
const result = await invoke<SimilarImagesPage>("find_similar_images", {
|
|
params: {
|
|
image_id: imageId,
|
|
folder_id: folderId ?? null,
|
|
offset,
|
|
limit: PAGE_SIZE,
|
|
threshold: SIMILAR_DISTANCE_THRESHOLD,
|
|
},
|
|
});
|
|
|
|
if (requestToken !== galleryRequestToken) 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: folderId ?? null,
|
|
similarScope,
|
|
selectedImage: reset ? null : state.selectedImage,
|
|
};
|
|
});
|
|
} catch (error) {
|
|
if (requestToken !== galleryRequestToken) 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: folderId ?? null,
|
|
similarScope,
|
|
selectedImage: null,
|
|
});
|
|
}
|
|
},
|
|
|
|
loadSimilarByRegion: async (imageId, crop, folderId = get().selectedFolderId, sourceFolderId = folderId ?? null) => {
|
|
const requestToken = ++galleryRequestToken;
|
|
const similarScope = folderId === null ? "all_media" : "current_folder";
|
|
set((state) => ({
|
|
images: [],
|
|
loadedCount: 0,
|
|
loadingImages: true,
|
|
collectionTitle: "Region Search Results",
|
|
imageLoadError: null,
|
|
similarSourceImageId: imageId,
|
|
similarSourceFolderId: sourceFolderId,
|
|
similarFolderId: folderId ?? null,
|
|
similarCrop: crop,
|
|
similarScope,
|
|
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: folderId ?? null,
|
|
offset: 0,
|
|
limit: PAGE_SIZE,
|
|
},
|
|
});
|
|
|
|
if (requestToken !== galleryRequestToken) 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: folderId ?? null,
|
|
similarCrop: crop,
|
|
similarScope,
|
|
});
|
|
} catch (error) {
|
|
if (requestToken !== galleryRequestToken) 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: folderId ?? null,
|
|
similarScope,
|
|
selectedImage: null,
|
|
});
|
|
}
|
|
},
|
|
|
|
setSimilarScope: (similarScope) => {
|
|
set({ similarScope });
|
|
const { similarSourceImageId, similarSourceFolderId, selectedFolderId, collectionTitle, similarCrop } = get();
|
|
if (similarSourceImageId === null) return;
|
|
const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null;
|
|
if (collectionTitle === "Region Search Results" && similarCrop !== null) {
|
|
void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId);
|
|
} else {
|
|
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId);
|
|
}
|
|
},
|
|
|
|
suggestImageTags: async (imageId) => {
|
|
return invoke<string[]>("suggest_image_tags", {
|
|
params: { image_id: imageId, limit: 2 },
|
|
});
|
|
},
|
|
|
|
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 });
|
|
},
|
|
|
|
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
|
|
|
|
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:
|
|
taggingQueueScope === "selected" && state.taggingQueueFolderIds.length === 0 && state.folders.length > 0
|
|
? [state.folders[0].id]
|
|
: 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(() => {});
|
|
},
|
|
|
|
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 });
|
|
},
|
|
|
|
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(() => {});
|
|
}
|
|
},
|
|
|
|
loadAppVersion: async () => {
|
|
try {
|
|
set({ appVersion: await getVersion() });
|
|
} catch {
|
|
// leave null; the UI falls back to a dash
|
|
}
|
|
},
|
|
|
|
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;
|
|
|
|
set({ updateStatus: "downloading", updateProgress: null, updateError: null });
|
|
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 }),
|
|
|
|
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 }),
|
|
|
|
openAppDataFolder: async () => {
|
|
await invoke("open_app_data_folder");
|
|
},
|
|
|
|
getDatabaseInfo: () => invoke<DatabaseInfo>("get_database_info"),
|
|
|
|
vacuumDatabase: () => invoke<VacuumResult>("vacuum_database"),
|
|
|
|
getOrphanedThumbnailsInfo: () => invoke<OrphanedThumbnailsInfo>("get_orphaned_thumbnails_info"),
|
|
|
|
cleanupOrphanedThumbnails: () => invoke<CleanupOrphanedThumbnailsResult>("cleanup_orphaned_thumbnails"),
|
|
|
|
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 });
|
|
},
|
|
|
|
loadTaggerThreshold: async () => {
|
|
try {
|
|
const taggerThreshold = await invoke<number>("get_tagger_threshold");
|
|
set({ taggerThreshold });
|
|
} catch (error) {
|
|
set({ taggerModelError: String(error) });
|
|
}
|
|
},
|
|
|
|
setTaggerThreshold: async (threshold) => {
|
|
const taggerThreshold = await invoke<number>("set_tagger_threshold", {
|
|
params: { threshold },
|
|
});
|
|
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;
|
|
},
|
|
|
|
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 });
|
|
},
|
|
|
|
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;
|
|
},
|
|
|
|
retryFailedEmbeddings: async (folderId) => {
|
|
await invoke("retry_failed_embeddings", { params: { folder_id: folderId } });
|
|
await get().loadBackgroundJobProgress();
|
|
},
|
|
|
|
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) => ({
|
|
images: replaceImage(state.images, updatedImage, state.sort),
|
|
selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage,
|
|
}));
|
|
},
|
|
|
|
subscribeToProgress: async () => {
|
|
const unlistenProgress = await listen<IndexProgress>("index-progress", (event) => {
|
|
const progress = event.payload;
|
|
const previous = get().indexingProgress[progress.folder_id];
|
|
set((state) => ({
|
|
indexingProgress: {
|
|
...state.indexingProgress,
|
|
[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);
|
|
// New tags landed — invalidate Explore tag cache.
|
|
set({ exploreTagsFolderId: undefined });
|
|
}, NOTIFICATION_DEBOUNCE_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,
|
|
});
|
|
});
|
|
|
|
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
|
|
const batch = event.payload;
|
|
|
|
set((state) => {
|
|
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
|
|
return state;
|
|
}
|
|
|
|
const visibleImages = batch.images.filter((image) =>
|
|
matchesFilters(
|
|
image,
|
|
state.selectedFolderId,
|
|
state.mediaFilter,
|
|
state.favoritesOnly,
|
|
state.minimumRating,
|
|
state.failedEmbeddingsOnly,
|
|
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;
|
|
|
|
set((state) => {
|
|
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
|
|
const selectedImage =
|
|
state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id)
|
|
? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage
|
|
: state.selectedImage;
|
|
return { selectedImage };
|
|
}
|
|
|
|
const visibleImages = batch.images.filter((image) =>
|
|
matchesFilters(
|
|
image,
|
|
state.selectedFolderId,
|
|
state.mediaFilter,
|
|
state.favoritesOnly,
|
|
state.minimumRating,
|
|
state.failedEmbeddingsOnly,
|
|
state.search,
|
|
),
|
|
);
|
|
|
|
const selectedImage =
|
|
state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id)
|
|
? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage
|
|
: state.selectedImage;
|
|
|
|
if (visibleImages.length === 0) {
|
|
return { selectedImage };
|
|
}
|
|
|
|
return {
|
|
images: replaceExistingImages(state.images, visibleImages, state.sort),
|
|
selectedImage,
|
|
};
|
|
});
|
|
});
|
|
|
|
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;
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
unlistenProgress();
|
|
unlistenMediaJobs();
|
|
unlistenCaptionModelProgress();
|
|
unlistenTaggerModelProgress();
|
|
unlistenImages();
|
|
unlistenThumbnails();
|
|
unlistenWatcherDeleted();
|
|
unlistenFolderCounts();
|
|
unlistenFfmpegProgress();
|
|
};
|
|
},
|
|
}));
|
|
|
|
appDataDir().then(async (dir) => {
|
|
useGalleryStore.getState().setCacheDir(await join(dir, "thumbnails"));
|
|
});
|