feat: expand media exploration and tagging controls

This commit is contained in:
2026-04-12 12:18:47 +01:00
parent b2826d1143
commit ff4a568b57
23 changed files with 3206 additions and 798 deletions
+479 -38
View File
@@ -15,10 +15,14 @@ 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 interface ImageRecord {
id: number;
@@ -115,12 +119,33 @@ export interface ThumbnailBatch {
images: ImageRecord[];
}
export type ActiveView = "gallery" | "explore";
export type ActiveView = "gallery" | "explore" | "duplicates";
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 SimilarImagesPage {
images: ImageRecord[];
offset: number;
limit: number;
has_more: boolean;
}
export interface CaptionModelStatus {
@@ -171,6 +196,12 @@ export interface TaggerRuntimeProbe {
session: TaggerRuntimeSessionProbe;
}
export interface ParsedSearch {
mode: SearchCommand;
query: string;
prefix: string | null;
}
export type SortOrder =
| "date_desc"
| "date_asc"
@@ -178,6 +209,8 @@ export type SortOrder =
| "name_desc"
| "size_desc"
| "size_asc"
| "rating_desc"
| "rating_asc"
| "duration_desc"
| "duration_asc";
@@ -194,17 +227,25 @@ interface GalleryState {
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;
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;
@@ -218,6 +259,8 @@ interface GalleryState {
captionDetail: CaptionDetail;
aiCaptionsEnabled: boolean;
settingsOpen: boolean;
taggingQueueScope: TaggingQueueScope;
taggingQueueFolderIds: number[];
taggerModelStatus: TaggerModelStatus | null;
taggerModelPreparing: boolean;
@@ -225,9 +268,16 @@ interface GalleryState {
taggerModelProgress: TaggerModelProgress | null;
taggerAcceleration: TaggerAcceleration;
taggerThreshold: number;
taggerBatchSize: number;
taggerRuntimeProbe: TaggerRuntimeProbe | null;
taggerRuntimeChecking: boolean;
duplicateGroups: DuplicateGroup[];
duplicateScanning: boolean;
duplicateScanProgress: { scanned: number; total: number } | null;
duplicateSelectedIds: Set<number>;
duplicateLastScanned: number | null; // Unix timestamp (seconds)
loadFolders: () => Promise<void>;
loadBackgroundJobProgress: () => Promise<void>;
addFolder: (path: string) => Promise<void>;
@@ -243,14 +293,19 @@ interface GalleryState {
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>;
searchByTag: (imageId: number) => void;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean) => 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>;
setSimilarScope: (scope: SimilarScope) => void;
suggestImageTags: (imageId: number) => Promise<string[]>;
loadCaptionModelStatus: () => Promise<void>;
prepareCaptionModel: () => Promise<void>;
@@ -268,6 +323,9 @@ interface GalleryState {
setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
setAiCaptionsEnabled: (enabled: boolean) => void;
setSettingsOpen: (open: boolean) => void;
setTaggingQueueScope: (scope: TaggingQueueScope) => void;
toggleTaggingQueueFolder: (folderId: number) => void;
setTaggingQueueFolderIds: (folderIds: number[]) => void;
retryFailedEmbeddings: (folderId: number) => Promise<void>;
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
setCacheDir: (dir: string) => void;
@@ -280,10 +338,21 @@ interface GalleryState {
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>;
@@ -291,6 +360,12 @@ interface GalleryState {
const PAGE_SIZE = 200;
const AI_CAPTIONS_ENABLED_KEY = "phokus.aiCaptionsEnabled";
const SIMILAR_DISTANCE_THRESHOLD = 0.24;
let galleryRequestToken = 0;
let similarRequestToken = 0;
let tagCloudRequestToken = 0;
let exploreTagRequestToken = 0;
function initialAiCaptionsEnabled(): boolean {
if (typeof window === "undefined") return false;
@@ -312,19 +387,71 @@ function matchesSearch(image: ImageRecord, search: string): boolean {
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 && matchesFailedEmbedding && matchesSearch(image, search);
return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesSearch(image, search);
}
function compareNullableNumber(a: number | null, b: number | null): number {
@@ -349,6 +476,10 @@ function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number
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":
@@ -424,17 +555,25 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
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,
galleryScrollResetKey: 0,
activeView: "gallery",
exploreMode: "visual",
tagCloudEntries: [],
tagCloudLoading: false,
tagCloudFolderId: undefined,
exploreTagEntries: [],
exploreTagLoading: false,
exploreTagsFolderId: undefined,
indexingProgress: {},
mediaJobProgress: {},
cacheDir: "",
@@ -448,6 +587,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
captionDetail: "paragraph",
aiCaptionsEnabled: initialAiCaptionsEnabled(),
settingsOpen: false,
taggingQueueScope: "all",
taggingQueueFolderIds: [],
taggerModelStatus: null,
taggerModelPreparing: false,
@@ -455,14 +596,33 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
taggerModelProgress: null,
taggerAcceleration: "auto",
taggerThreshold: 0.35,
taggerBatchSize: 8,
taggerRuntimeProbe: null,
taggerRuntimeChecking: false,
duplicateGroups: [],
duplicateScanning: false,
duplicateScanProgress: null,
duplicateSelectedIds: new Set(),
duplicateLastScanned: null,
setCacheDir: (cacheDir) => set({ cacheDir }),
loadFolders: async () => {
const folders = await invoke<Folder[]>("get_folders");
set({ 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 () => {
@@ -507,29 +667,64 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
loadImages: async (reset = false) => {
const { selectedFolderId, search, searchMode, sort, loadedCount, mediaFilter, favoritesOnly, failedEmbeddingsOnly } = get();
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
const parsedSearch = parseSearchValue(search);
const requestToken = ++galleryRequestToken;
set({ loadingImages: true, imageLoadError: null });
try {
if (searchMode === "semantic" && search.trim()) {
if (parsedSearch.mode === "semantic" && parsedSearch.query) {
const images = await invoke<ImageRecord[]>("semantic_search_images", {
params: {
query: search,
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: ${search}`,
collectionTitle: `Semantic search: ${parsedSearch.query}`,
selectedFolderId,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
});
return;
}
if (parsedSearch.mode === "tag" && parsedSearch.query) {
const images = await invoke<ImageRecord[]>("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,
},
});
if (requestToken !== galleryRequestToken) return;
set({
images,
totalImages: images.length,
loadedCount: images.length,
loadingImages: false,
collectionTitle: `Tag search: ${parsedSearch.query}`,
selectedFolderId,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
});
return;
}
@@ -543,9 +738,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}>("get_images", {
params: {
folder_id: selectedFolderId,
search: search || null,
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,
@@ -553,6 +749,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
});
if (requestToken !== galleryRequestToken) return;
set((state) => ({
images: reset ? result.images : [...state.images, ...result.images],
totalImages: result.total,
@@ -560,20 +757,24 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
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, selectedFolderId } = get();
const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, similarFolderId } = get();
if (loadingImages || loadedCount >= totalImages) return;
if (collectionTitle === "Explore Cluster") return;
if (collectionTitle === "Similar Images" && similarSourceImageId !== null) {
if (!similarHasMore) return;
await get().loadSimilarImages(similarSourceImageId, selectedFolderId, false);
await get().loadSimilarImages(similarSourceImageId, similarFolderId, false);
return;
}
await get().loadImages(false);
@@ -614,6 +815,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
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);
@@ -626,59 +832,148 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setView: (activeView) => 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 });
}
},
searchByTag: (imageId) => {
const { selectedFolderId } = get();
set((state) => ({ activeView: "gallery", images: [], loadedCount: 0, loadingImages: true, collectionTitle: "Similar Images", imageLoadError: null, galleryScrollResetKey: state.galleryScrollResetKey + 1 }));
void get().loadSimilarImages(imageId, selectedFolderId);
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 });
}
},
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true) => {
const requestedLimit = reset ? PAGE_SIZE : get().loadedCount + PAGE_SIZE;
showVisualCluster: async (imageIds) => {
const requestToken = ++similarRequestToken;
set((state) => ({
images: reset ? [] : get().images,
loadedCount: reset ? 0 : get().loadedCount,
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 !== similarRequestToken) return;
set({
images,
totalImages: images.length,
loadedCount: images.length,
loadingImages: false,
imageLoadError: null,
collectionTitle: "Explore Cluster",
});
} catch (error) {
if (requestToken !== similarRequestToken) 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 = ++similarRequestToken;
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 images = await invoke<ImageRecord[]>("find_similar_images", {
params: { image_id: imageId, folder_id: folderId ?? null, limit: requestedLimit },
});
const hasMore = images.length >= requestedLimit;
set({
images,
totalImages: hasMore ? images.length + PAGE_SIZE : images.length,
loadedCount: images.length,
loadingImages: false,
imageLoadError: null,
collectionTitle: "Similar Images",
similarSourceImageId: imageId,
similarHasMore: hasMore,
selectedFolderId: folderId ?? null,
selectedImage: reset ? null : get().selectedImage,
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 !== similarRequestToken) 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 !== similarRequestToken) return;
console.error("Failed to load similar images:", error);
set({
images: [],
@@ -688,13 +983,23 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
imageLoadError: String(error),
collectionTitle: "Similar Images",
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: false,
selectedFolderId: folderId ?? null,
similarFolderId: folderId ?? null,
similarScope,
selectedImage: null,
});
}
},
setSimilarScope: (similarScope) => {
set({ similarScope });
const { similarSourceImageId, similarSourceFolderId, selectedFolderId } = get();
if (similarSourceImageId === null) return;
const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null;
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId);
},
suggestImageTags: async (imageId) => {
return invoke<string[]>("suggest_image_tags", {
params: { image_id: imageId, limit: 2 },
@@ -833,6 +1138,27 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
setTaggingQueueScope: (taggingQueueScope) => {
set((state) => ({
taggingQueueScope,
taggingQueueFolderIds:
taggingQueueScope === "selected" && state.taggingQueueFolderIds.length === 0 && state.folders.length > 0
? [state.folders[0].id]
: state.taggingQueueFolderIds,
}));
},
toggleTaggingQueueFolder: (folderId) => {
set((state) => {
const next = state.taggingQueueFolderIds.includes(folderId)
? state.taggingQueueFolderIds.filter((id) => id !== folderId)
: [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b);
return { taggingQueueFolderIds: next };
});
},
setTaggingQueueFolderIds: (taggingQueueFolderIds) => set({ taggingQueueFolderIds }),
loadTaggerModelStatus: async () => {
try {
const taggerModelStatus = await invoke<TaggerModelStatus>("get_tagger_model_status");
@@ -874,6 +1200,22 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
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 {
@@ -912,6 +1254,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
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 },
@@ -928,6 +1278,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
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 },
@@ -946,6 +1304,73 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
});
},
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 });
}
},
scanDuplicates: async (folderId = null) => {
const { listen } = await import("@tauri-apps/api/event");
set({ duplicateScanning: true, duplicateGroups: [], duplicateScanProgress: null, duplicateSelectedIds: new Set() });
const unlisten = await listen<[number, number]>("duplicate_scan_progress", (event) => {
const [scanned, total] = event.payload;
set({ duplicateScanProgress: { scanned, total } });
});
try {
const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null });
set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000) });
} 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 } = get();
const ids = Array.from(duplicateSelectedIds);
if (ids.length === 0) return 0;
const deleted = await invoke<number>("delete_images_from_disk", { params: { image_ids: ids } });
// Remove deleted images from groups and drop now-trivial groups
set((state) => ({
duplicateSelectedIds: new Set(),
duplicateGroups: state.duplicateGroups
.map((g) => ({ ...g, images: g.images.filter((img) => !duplicateSelectedIds.has(img.id)) }))
.filter((g) => g.images.length > 1),
}));
return deleted;
},
retryFailedEmbeddings: async (folderId) => {
await invoke("retry_failed_embeddings", { params: { folder_id: folderId } });
await get().loadBackgroundJobProgress();
@@ -979,7 +1404,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
if (progress.done) {
void get().loadFolders();
void get().loadBackgroundJobProgress();
void get().loadImages(true);
if (get().activeView !== "explore" && !isDerivedCollectionTitle(get().collectionTitle)) {
void get().loadImages(true);
}
setTimeout(() => {
set((state) => {
@@ -1019,12 +1446,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
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,
),
@@ -1049,12 +1481,21 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
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,
),