From 69e53ed62ae3cc82a95a1c38e0df354597c5a9ff Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 12 Jun 2026 18:20:27 +0100 Subject: [PATCH 01/26] chore(release): add MIT license, bundle metadata, and single NSIS target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the 0.1.0 release prep: - LICENSE (MIT) plus license fields in Cargo.toml, package.json, and bundle config - tauri.conf.json: publisher/copyright/descriptions/homepage so the NSIS installer no longer derives 'jezz' from the identifier as manufacturer - version: null in tauri.conf.json — Cargo.toml is now the version source - bundle.targets narrowed from 'all' to nsis (updater-friendly, faster builds) - track pnpm-workspace.yaml (esbuild build-script approval, needed by CI) --- LICENSE | 21 +++++++++++++++++++++ package.json | 1 + pnpm-workspace.yaml | 2 ++ src-tauri/Cargo.toml | 3 ++- src-tauri/tauri.conf.json | 10 ++++++++-- 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 LICENSE create mode 100644 pnpm-workspace.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9e0202 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JezzWTF + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index 542a05d..231fddc 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "phokus", "private": true, "version": "0.1.0", + "license": "MIT", "type": "module", "scripts": { "build:app": "tauri build", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 84a8156..dffcbd7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,8 +1,9 @@ [package] name = "phokus" version = "0.1.0" -description = "A performant image gallery application" +description = "Local-first desktop media library" authors = ["JezzWTF"] +license = "MIT" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 51aa343..5ec02ac 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Phokus", - "version": "0.1.0", + "version": null, "identifier": "wtf.jezz.phokus", "build": { "beforeDevCommand": "pnpm dev:vite", @@ -33,7 +33,13 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": ["nsis"], + "publisher": "JezzWTF", + "license": "MIT", + "copyright": "Copyright © 2026 JezzWTF", + "shortDescription": "Local-first desktop media library", + "longDescription": "A local-first desktop media library for browsing, filtering, and curating image and video folders, with AI tagging, semantic search, and duplicate detection — all processed on-device.", + "homepage": "https://git.jezz.wtf/jezzwtf/phokus", "icon": [ "icons/32x32.png", "icons/128x128.png", From 1d782a6d57f4af886405550178bb40533da3e88d Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 12 Jun 2026 18:36:31 +0100 Subject: [PATCH 02/26] style(backend): apply rustfmt across the backend Mechanical cargo fmt pass so the CI rustfmt gate starts green; no semantic changes (verified token-level + cargo check). --- src-tauri/build.rs | 8 +- src-tauri/src/commands.rs | 248 +++++++++++++++++++++++-------------- src-tauri/src/db.rs | 48 ++++--- src-tauri/src/indexer.rs | 59 +++++++-- src-tauri/src/tagger.rs | 6 +- src-tauri/src/thumbnail.rs | 4 +- 6 files changed, 242 insertions(+), 131 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index f6be0ab..48bbd5b 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -15,13 +15,17 @@ fn main() { if let Ok(path) = &cuda_path { println!("cargo:warning=CUDA Toolkit found at {path} — GPU acceleration enabled."); } else { - println!("cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled."); + println!( + "cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled." + ); } } else { println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("cargo:warning= candle-cuda feature is enabled but no CUDA Toolkit found."); println!("cargo:warning= Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads"); - println!("cargo:warning= Or build without GPU support: cargo build --no-default-features"); + println!( + "cargo:warning= Or build without GPU support: cargo build --no-default-features" + ); println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3451f92..ba33ad0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -445,7 +445,7 @@ pub async fn find_similar_images( offset, limit + 1, ) - .map_err(|e| e.to_string())?; + .map_err(|e| e.to_string())?; let has_more = matches.len() > limit; let image_ids = matches .into_iter() @@ -499,8 +499,9 @@ pub async fn find_similar_by_region( None => { // Fetch one extra candidate to compensate for the source image that // will be removed, so has_more is accurate and results span multiple pages. - let mut ids = vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2) - .map_err(|e| e.to_string())?; + let mut ids = + vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2) + .map_err(|e| e.to_string())?; ids.retain(|&id| id != params.image_id); ids } @@ -639,7 +640,12 @@ pub async fn search_images_by_tag( offset, ) .map_err(|e| e.to_string())?; - Ok(TagSearchPage { images, total, offset, limit }) + Ok(TagSearchPage { + images, + total, + offset, + limit, + }) } #[tauri::command] @@ -835,7 +841,6 @@ pub struct TagCloudEntry { pub image_ids: Vec, } - /// Clusters the library's image embeddings with k-means and returns one representative /// image per cluster — the member whose embedding is closest to its cluster centroid. /// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated @@ -957,7 +962,8 @@ pub async fn get_explore_tags( params: GetExploreTagsParams, ) -> Result, String> { let conn = db.get().map_err(|e| e.to_string())?; - db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)).map_err(|e| e.to_string()) + db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -969,8 +975,13 @@ pub async fn search_tags_autocomplete( return Ok(vec![]); } let conn = db.get().map_err(|e| e.to_string())?; - db::search_tags_autocomplete(&conn, ¶ms.query, params.folder_id, params.limit.unwrap_or(10)) - .map_err(|e| e.to_string()) + db::search_tags_autocomplete( + &conn, + ¶ms.query, + params.folder_id, + params.limit.unwrap_or(10), + ) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -985,12 +996,15 @@ pub async fn find_duplicates( }; let total = records.len(); - let _ = app.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "checking".to_string(), - processed: 0, - total, - skipped: 0, - }); + let _ = app.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "checking".to_string(), + processed: 0, + total, + skipped: 0, + }, + ); // Three-phase detection. // @@ -1007,8 +1021,11 @@ pub async fn find_duplicates( // For sample-hash groups that contain files > 64 KB, compute a full-file // hash to confirm the match before presenting them as deletable duplicates. let app_hash = app.clone(); - let (pairs, skipped_before_confirm, candidate_total): - (Vec<(u64, u64, i64, String)>, usize, usize) = tokio::task::spawn_blocking(move || { + let (pairs, skipped_before_confirm, candidate_total): ( + Vec<(u64, u64, i64, String)>, + usize, + usize, + ) = tokio::task::spawn_blocking(move || { use memmap2::Mmap; use rayon::prelude::*; use std::collections::HashMap; @@ -1016,7 +1033,7 @@ pub async fn find_duplicates( use xxhash_rust::xxh3::{xxh3_64, Xxh3}; const WINDOW: usize = 16 * 1024; // 16 KB per sample window - const N: usize = 4; // windows at 0%, 33%, 66%, 100% + const N: usize = 4; // windows at 0%, 33%, 66%, 100% const COVERED: usize = WINDOW * N; // 64 KB total; also full-coverage threshold // Hash four evenly-spaced 16 KB windows. For files ≤ 64 KB this is @@ -1048,12 +1065,15 @@ pub async fn find_duplicates( }; let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1; if done % 100 == 0 || done == total { - let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "checking".to_string(), - processed: done, - total, - skipped: stat_skipped.load(Ordering::Relaxed), - }); + let _ = app_hash.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "checking".to_string(), + processed: done, + total, + skipped: stat_skipped.load(Ordering::Relaxed), + }, + ); } result }) @@ -1076,12 +1096,15 @@ pub async fn find_duplicates( // ── Phase 2: sample hash ────────────────────────────────────────── let c_total = candidates.len(); - let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "hashing".to_string(), - processed: 0, - total: c_total, - skipped: stat_skipped, - }); + let _ = app_hash.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "hashing".to_string(), + processed: 0, + total: c_total, + skipped: stat_skipped, + }, + ); let counter = AtomicUsize::new(0); let hash_skipped = AtomicUsize::new(0); @@ -1103,12 +1126,15 @@ pub async fn find_duplicates( } let done = counter.fetch_add(1, Ordering::Relaxed) + 1; if done % 100 == 0 || done == c_total { - let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "hashing".to_string(), - processed: done, - total: c_total, - skipped: stat_skipped + hash_skipped.load(Ordering::Relaxed), - }); + let _ = app_hash.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "hashing".to_string(), + processed: done, + total: c_total, + skipped: stat_skipped + hash_skipped.load(Ordering::Relaxed), + }, + ); } result }) @@ -1126,7 +1152,10 @@ pub async fn find_duplicates( let mut size_hash_map: std::collections::HashMap<(u64, u64), Vec<(i64, String)>> = std::collections::HashMap::new(); for (hash, file_size, id, path) in pairs { - size_hash_map.entry((hash, file_size)).or_default().push((id, path)); + size_hash_map + .entry((hash, file_size)) + .or_default() + .push((id, path)); } // Phase 3: for large-file groups (> 64 KB) the sample hash is not exact; @@ -1140,12 +1169,15 @@ pub async fn find_duplicates( .map(|(_, entries)| entries.len()) .sum(); if confirming_total > 0 { - let _ = app.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "confirming".to_string(), - processed: 0, - total: confirming_total, - skipped: skipped_before_confirm, - }); + let _ = app.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "confirming".to_string(), + processed: 0, + total: confirming_total, + skipped: skipped_before_confirm, + }, + ); } let app_confirm = app.clone(); let (confirmed, skipped_files): (Vec<(u64, u64, Vec)>, usize) = @@ -1163,7 +1195,11 @@ pub async fn find_duplicates( .flat_map(|((sample_hash, file_size), entries)| { if file_size <= COVERED { // Sample was already a full hash — one group, no re-read needed. - return vec![(sample_hash, file_size, entries.into_iter().map(|(id, _)| id).collect())]; + return vec![( + sample_hash, + file_size, + entries.into_iter().map(|(id, _)| id).collect(), + )]; } // Full-file hash pass. Group by full hash so that two sets of // files that collide only in the sample produce separate groups. @@ -1179,13 +1215,16 @@ pub async fn find_duplicates( } let done = counter.fetch_add(1, Ordering::Relaxed) + 1; if done % 100 == 0 || done == confirming_total { - let _ = app_confirm.emit("duplicate_scan_progress", DuplicateScanProgress { - phase: "confirming".to_string(), - processed: done, - total: confirming_total, - skipped: skipped_before_confirm - + confirm_skipped.load(Ordering::Relaxed), - }); + let _ = app_confirm.emit( + "duplicate_scan_progress", + DuplicateScanProgress { + phase: "confirming".to_string(), + processed: done, + total: confirming_total, + skipped: skipped_before_confirm + + confirm_skipped.load(Ordering::Relaxed), + }, + ); } (*id, hash) }) @@ -1470,16 +1509,17 @@ pub async fn get_worker_states(folder_ids: Vec) -> Result Result { let conn = db.get().map_err(|e| e.to_string())?; let requested_folder_ids = params.folder_ids.unwrap_or_default(); - let (total, folder_ids) = match (params.folder_id, params.image_id, requested_folder_ids.is_empty()) { + let (total, folder_ids) = match ( + params.folder_id, + params.image_id, + requested_folder_ids.is_empty(), + ) { (_, Some(image_id), _) => { db::enqueue_tagging_job(&conn, image_id).map_err(|e| e.to_string())?; // Look up just this image's folder_id rather than fetching all folders @@ -1675,27 +1719,29 @@ pub async fn clear_tagging_jobs( ) -> Result { let conn = db.get().map_err(|e| e.to_string())?; let requested_folder_ids = params.folder_ids.unwrap_or_default(); - let (n, folder_ids): (usize, Vec) = match (params.folder_id, requested_folder_ids.is_empty()) { - (Some(id), _) => ( - db::clear_tagging_jobs(&conn, Some(id)).map_err(|e| e.to_string())?, - vec![id], - ), - (None, false) => { - let mut total = 0usize; - for &folder_id in &requested_folder_ids { - total += db::clear_tagging_jobs(&conn, Some(folder_id)).map_err(|e| e.to_string())?; + let (n, folder_ids): (usize, Vec) = + match (params.folder_id, requested_folder_ids.is_empty()) { + (Some(id), _) => ( + db::clear_tagging_jobs(&conn, Some(id)).map_err(|e| e.to_string())?, + vec![id], + ), + (None, false) => { + let mut total = 0usize; + for &folder_id in &requested_folder_ids { + total += db::clear_tagging_jobs(&conn, Some(folder_id)) + .map_err(|e| e.to_string())?; + } + (total, requested_folder_ids) } - (total, requested_folder_ids) - } - (None, true) => ( - db::clear_tagging_jobs(&conn, None).map_err(|e| e.to_string())?, - db::get_folders(&conn) - .map_err(|e| e.to_string())? - .into_iter() - .map(|f| f.id) - .collect(), - ), - }; + (None, true) => ( + db::clear_tagging_jobs(&conn, None).map_err(|e| e.to_string())?, + db::get_folders(&conn) + .map_err(|e| e.to_string())? + .into_iter() + .map(|f| f.id) + .collect(), + ), + }; drop(conn); indexer::emit_folder_job_progress(&app, db.inner(), &folder_ids, true); Ok(n) @@ -1747,7 +1793,11 @@ pub async fn get_tagging_queue_scope(app: AppHandle) -> Result { let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let path = app_dir.join(TAGGING_QUEUE_SCOPE_FILE); let value = std::fs::read_to_string(path).unwrap_or_default(); - Ok(if value.trim() == "selected" { "selected".to_string() } else { "all".to_string() }) + Ok(if value.trim() == "selected" { + "selected".to_string() + } else { + "all".to_string() + }) } #[tauri::command] @@ -1760,7 +1810,11 @@ pub async fn set_tagging_queue_scope( if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - let value = if params.scope == "selected" { "selected" } else { "all" }; + let value = if params.scope == "selected" { + "selected" + } else { + "all" + }; std::fs::write(path, value).map_err(|e| e.to_string())?; Ok(value.to_string()) } @@ -1825,14 +1879,21 @@ pub struct VacuumResult { } #[tauri::command] -pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result { +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())?; + 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, @@ -1841,13 +1902,17 @@ pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result } #[tauri::command] -pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result { +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())?; + 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); @@ -1870,7 +1935,9 @@ pub struct CleanupOrphanedThumbnailsResult { pub freed_mb: f64, } -fn collect_db_thumbnail_filenames(conn: &rusqlite::Connection) -> Result, String> { +fn collect_db_thumbnail_filenames( + conn: &rusqlite::Connection, +) -> Result, String> { let mut stmt = conn .prepare("SELECT thumbnail_path FROM images WHERE thumbnail_path IS NOT NULL") .map_err(|e| e.to_string())?; @@ -1993,8 +2060,7 @@ pub async fn set_muted_folder_ids( .map(|id| id.to_string()) .collect::>() .join(","); - std::fs::write(settings_dir.join("muted_folder_ids.txt"), content) - .map_err(|e| e.to_string()) + std::fs::write(settings_dir.join("muted_folder_ids.txt"), content).map_err(|e| e.to_string()) } #[tauri::command] diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 3542e5d..51f5c57 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -318,9 +318,7 @@ pub fn migrate(conn: &Connection) -> Result<()> { // Index must be created after ensure_column adds the column; it cannot live // in the execute_batch above because that batch runs before the column exists // on databases that predate Phase 1. - conn.execute_batch( - "CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);", - )?; + conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);")?; ensure_column(conn, "folders", "scan_error", "TEXT")?; vector::migrate(conn)?; @@ -1410,7 +1408,10 @@ pub fn update_image_details( /// Look up the lightweight indexed-media entry for a single path. /// Used by the filesystem watcher to run change-detection before upserting. -pub fn get_indexed_entry_by_path(conn: &Connection, path: &str) -> Result> { +pub fn get_indexed_entry_by_path( + conn: &Connection, + path: &str, +) -> Result> { let result = conn.query_row( "SELECT id, path, modified_at, file_size, media_kind FROM images WHERE path = ?1", [path], @@ -1434,11 +1435,9 @@ pub fn get_indexed_entry_by_path(conn: &Connection, path: &str) -> Result