diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 81bca58..78ad8e4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1696,3 +1696,54 @@ pub async fn open_app_data_folder(app: AppHandle) -> Result<(), String> { .open_path(app_dir.to_string_lossy().as_ref(), None::<&str>) .map_err(|e| e.to_string()) } + +// --------------------------------------------------------------------------- +// Database maintenance +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct DatabaseInfo { + pub size_mb: f64, + pub reclaimable_mb: f64, +} + +#[derive(Serialize)] +pub struct VacuumResult { + pub before_mb: f64, + pub after_mb: f64, + pub freed_mb: f64, +} + +#[tauri::command] +pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let db_path = app_dir.join("gallery.db"); + let size_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + + let conn = db.get().map_err(|e| e.to_string())?; + let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0)).map_err(|e| e.to_string())?; + let freelist_count: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0)).map_err(|e| e.to_string())?; + + Ok(DatabaseInfo { + size_mb: size_bytes as f64 / 1_048_576.0, + reclaimable_mb: (freelist_count * page_size) as f64 / 1_048_576.0, + }) +} + +#[tauri::command] +pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let db_path = app_dir.join("gallery.db"); + let before_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + + let conn = db.get().map_err(|e| e.to_string())?; + conn.execute_batch("PRAGMA wal_checkpoint(FULL); VACUUM;").map_err(|e| e.to_string())?; + drop(conn); + + let after_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + Ok(VacuumResult { + before_mb: before_bytes as f64 / 1_048_576.0, + after_mb: after_bytes as f64 / 1_048_576.0, + freed_mb: before_bytes.saturating_sub(after_bytes) as f64 / 1_048_576.0, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38fdd8f..01c05b8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -144,6 +144,8 @@ pub fn run() { commands::get_tagging_queue_folder_ids, commands::set_tagging_queue_folder_ids, commands::open_app_data_folder, + commands::get_database_info, + commands::vacuum_database, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 783a9d4..9ec06a4 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { TaggerAcceleration, TaggingQueueScope, useGalleryStore } from "../store"; +import { DatabaseInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; type SettingsSection = "workspace" | "general"; @@ -117,6 +117,9 @@ export function SettingsModal() { const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false); const [taggerBatchSizeError, setTaggerBatchSizeError] = useState(null); const [openingDataFolder, setOpeningDataFolder] = useState(false); + const [dbInfo, setDbInfo] = useState(null); + const [vacuuming, setVacuuming] = useState(false); + const [vacuumResult, setVacuumResult] = useState(null); const thresholdErrorTimerRef = useRef | null>(null); const batchSizeErrorTimerRef = useRef | null>(null); @@ -156,6 +159,8 @@ export function SettingsModal() { const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders); const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); + const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); + const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); useEffect(() => { if (!settingsOpen) return; @@ -173,6 +178,12 @@ export function SettingsModal() { return () => window.removeEventListener("keydown", handleKeyDown); }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]); + useEffect(() => { + if (!settingsOpen || activeSection !== "general") return; + setVacuumResult(null); + void getDatabaseInfo().then(setDbInfo).catch(() => {}); + }, [settingsOpen, activeSection, getDatabaseInfo]); + // Clean up error timers on unmount useEffect(() => { return () => { @@ -582,6 +593,62 @@ export function SettingsModal() { + + +
+
+
+

Database size

+

+ {vacuumResult + ? `${vacuumResult.after_mb.toFixed(1)} MB` + : dbInfo + ? `${dbInfo.size_mb.toFixed(1)} MB` + : "—"} +

+
+
+

Reclaimable

+

+ {vacuumResult + ? `−${vacuumResult.freed_mb.toFixed(1)} MB freed` + : dbInfo + ? `${dbInfo.reclaimable_mb.toFixed(1)} MB` + : "—"} +

+
+
+
+

+ {vacuumResult + ? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.` + : dbInfo && dbInfo.reclaimable_mb < 0.5 + ? "Database is already compact." + : "Run this after removing folders or bulk-deleting images."} +

+ +
+
+
)} diff --git a/src/store.ts b/src/store.ts index 8c4b8a1..298655d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -71,6 +71,17 @@ export interface ImageTag { 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 TaggerModelStatus { model_id: string; model_name: string; @@ -340,6 +351,8 @@ interface GalleryState { toggleTaggingQueueFolder: (folderId: number) => void; setTaggingQueueFolderIds: (folderIds: number[]) => void; openAppDataFolder: () => Promise; + getDatabaseInfo: () => Promise; + vacuumDatabase: () => Promise; retryFailedEmbeddings: (folderId: number) => Promise; updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise; setCacheDir: (dir: string) => void; @@ -1350,6 +1363,10 @@ export const useGalleryStore = create((set, get) => ({ await invoke("open_app_data_folder"); }, + getDatabaseInfo: () => invoke("get_database_info"), + + vacuumDatabase: () => invoke("vacuum_database"), + loadTaggerModelStatus: async () => { try { const taggerModelStatus = await invoke("get_tagger_model_status");