From ff4a568b574a96b01f2824eb26c2a2bba42beff1 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 12 Apr 2026 12:18:47 +0100 Subject: [PATCH] feat: expand media exploration and tagging controls --- package.json | 10 +- pnpm-lock.yaml | 39 ++ src-tauri/.cargo/config.toml | 12 + src-tauri/Cargo.lock | 308 ++++++++++++ src-tauri/Cargo.toml | 50 ++ src-tauri/src/commands.rs | 413 ++++++++++++++- src-tauri/src/db.rs | 200 +++++++- src-tauri/src/hnsw_index.rs | 129 +++++ src-tauri/src/indexer.rs | 6 +- src-tauri/src/lib.rs | 10 + src-tauri/src/tagger.rs | 23 + src-tauri/src/vector.rs | 139 ++++- src-tauri/tauri.conf.json | 10 +- src/App.tsx | 8 + src/components/BackgroundTasks.tsx | 82 ++- src/components/DuplicateFinder.tsx | 274 ++++++++++ src/components/Gallery.tsx | 174 ++++--- src/components/Lightbox.tsx | 3 +- src/components/SettingsModal.tsx | 780 ++++++++++++++++------------- src/components/Sidebar.tsx | 17 + src/components/TagCloud.tsx | 512 ++++++++++++------- src/components/Toolbar.tsx | 288 ++++++++--- src/store.ts | 517 +++++++++++++++++-- 23 files changed, 3206 insertions(+), 798 deletions(-) create mode 100644 src-tauri/.cargo/config.toml create mode 100644 src-tauri/src/hnsw_index.rs create mode 100644 src/components/DuplicateFinder.tsx diff --git a/package.json b/package.json index c90f202..5d541c0 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,11 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", + "build:app": "tauri build", + "build:vite": "tsc && vite build", + "dev:app": "tauri dev", + "dev:vite": "vite", +"preview": "vite preview", "tauri": "tauri" }, "dependencies": { @@ -15,6 +17,7 @@ "@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-opener": "^2", + "d3-force": "^3.0.0", "framer-motion": "^12.38.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -23,6 +26,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.2.2", "@tauri-apps/cli": "^2", + "@types/d3-force": "^3.0.10", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fd524e..b2dd733 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.3 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 framer-motion: specifier: ^12.38.0 version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -42,6 +45,9 @@ importers: '@tauri-apps/cli': specifier: ^2 version: 2.10.1 + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/react': specifier: ^19.1.8 version: 19.2.14 @@ -662,6 +668,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -698,6 +707,22 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1448,6 +1473,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/d3-force@3.0.10': {} + '@types/estree@1.0.8': {} '@types/react-dom@19.2.3(@types/react@19.2.14)': @@ -1486,6 +1513,18 @@ snapshots: csstype@3.2.3: {} + d3-dispatch@3.0.1: {} + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-quadtree@3.0.1: {} + + d3-timer@3.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml new file mode 100644 index 0000000..22422f2 --- /dev/null +++ b/src-tauri/.cargo/config.toml @@ -0,0 +1,12 @@ +[target.x86_64-pc-windows-msvc] +rustflags = [ + # Disable MSVC incremental linking — avoids .ilk file corruption + # and removes one source of link failure on restart. + "-C", "link-arg=/INCREMENTAL:NO", + # Skip PDB generation entirely in dev builds. + # The .pdb file is the most common reason the linker fails after + # an unclean shutdown: the previous phokus.exe process holds the + # file open, so link.exe cannot write a new one → LNK error. + # Rust backtraces still work without MSVC PDBs. + "-C", "link-arg=/DEBUG:NONE", +] diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0a27e7a..195eddf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -61,6 +61,73 @@ dependencies = [ "libc", ] +[[package]] +name = "anndists" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8396b473aa0bceed68fb32462505387ea39fa47c7029417e0a49f10592b036" +dependencies = [ + "anyhow", + "cfg-if", + "cpu-time", + "env_logger", + "lazy_static", + "log", + "num-traits", + "num_cpus", + "rayon", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -266,6 +333,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -595,6 +671,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.10.0" @@ -626,6 +708,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -757,6 +845,16 @@ dependencies = [ "libc", ] +[[package]] +name = "cpu-time" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1352,12 +1450,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_home" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2340,6 +2461,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -2416,6 +2539,31 @@ version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" +[[package]] +name = "hnsw_rs" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a5258f079b97bf2e8311ff9579e903c899dcbac0d9a138d62e9a066778bd07" +dependencies = [ + "anndists", + "anyhow", + "bincode", + "cfg-if", + "cpu-time", + "env_logger", + "hashbrown 0.15.5", + "indexmap 2.13.1", + "lazy_static", + "log", + "mmap-rs", + "num-traits", + "num_cpus", + "parking_lot", + "rand 0.9.2", + "rayon", + "serde", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2819,6 +2967,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -2857,6 +3011,30 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "jni" version = "0.21.1" @@ -2958,6 +3136,12 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3109,6 +3293,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -3235,6 +3428,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mmap-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ecce9d566cb9234ae3db9e249c8b55665feaaf32b0859ff1e27e310d2beb3d8" +dependencies = [ + "bitflags 2.11.0", + "combine", + "libc", + "mach2", + "nix", + "sysctl", + "thiserror 2.0.18", + "widestring", + "windows 0.48.0", +] + [[package]] name = "monostate" version = "0.1.18" @@ -3356,6 +3566,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3634,6 +3856,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "onig" version = "6.5.1" @@ -4034,8 +4262,10 @@ dependencies = [ "fast_image_resize", "ffmpeg-sidecar", "hf-hub", + "hnsw_rs", "image", "log", + "memmap2", "ort", "r2d2", "r2d2_sqlite", @@ -6575,6 +6805,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.0" @@ -6938,6 +7174,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -6984,6 +7226,15 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows" version = "0.61.3" @@ -7236,6 +7487,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -7302,6 +7568,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7320,6 +7592,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7338,6 +7616,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7368,6 +7652,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7386,6 +7676,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7404,6 +7700,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7422,6 +7724,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 38356e2..2b2919e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ rusqlite = { version = "0.32", features = ["bundled"] } r2d2 = "0.8" r2d2_sqlite = "0.25" sqlite-vec = "=0.1.9" +hnsw_rs = "0.3.4" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff"] } fast_image_resize = { version = "6.0.0", features = ["image"] } walkdir = "2" @@ -38,6 +39,7 @@ anyhow = "1" log = "0.4" ffmpeg-sidecar = "2.5.0" xxhash-rust = { version = "0.8", features = ["xxh3"] } +memmap2 = "0.9" sysinfo = "0.38.4" candle-core = { version = "0.10.2", features = ["cuda"] } candle-nn = { version = "0.10.2", features = ["cuda"] } @@ -48,3 +50,51 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "n ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] } zip = { version = "4.6.1", default-features = false, features = ["deflate"] } csv = "1" + +# ── Dev-mode performance ──────────────────────────────────────────────────── +# opt-level=1 on the main crate keeps incremental compile short. +# Only the packages that are genuine hot-path bottlenecks in dev get opt-level=3 +# (using "*" caused cargo to recheck all dependency fingerprints too aggressively, +# which caused spurious full rebuilds and linker conflicts). +[profile.dev] +opt-level = 1 + +# ML inference — without opt these run 20-50× slower than release +[profile.dev.package.candle-core] +opt-level = 3 +[profile.dev.package.candle-nn] +opt-level = 3 +[profile.dev.package.candle-transformers] +opt-level = 3 + +# ONNX runtime (WD tagger) +[profile.dev.package.ort] +opt-level = 3 +[profile.dev.package.ort-sys] +opt-level = 3 + +# Image decode/resize workers +[profile.dev.package.image] +opt-level = 3 +[profile.dev.package.fast_image_resize] +opt-level = 3 + +# Parallel work scheduler +[profile.dev.package.rayon] +opt-level = 3 +[profile.dev.package.rayon-core] +opt-level = 3 + +# Tokenisation (embedding model pre-processing) +[profile.dev.package.tokenizers] +opt-level = 3 + +# Hashing (duplicate finder) +[profile.dev.package.xxhash-rust] +opt-level = 3 + +# SQLite (frequent db calls in workers) +[profile.dev.package.rusqlite] +opt-level = 3 +[profile.dev.package.libsqlite3-sys] +opt-level = 3 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6c2a47a..d23eeb4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,8 +2,9 @@ use crate::captioner::{ self, CaptionAcceleration, CaptionDetail, CaptionModelStatus, CaptionRuntimeProbe, CaptionVisionProbe, }; -use crate::db::{self, DbPool, Folder, FolderJobProgress, ImageRecord, ImageTag}; +use crate::db::{self, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag}; use crate::embedder; +use crate::hnsw_index; use crate::indexer; use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe}; use crate::vector; @@ -21,12 +22,21 @@ pub struct ImagesPage { pub limit: i64, } +#[derive(Serialize)] +pub struct SimilarImagesPage { + pub images: Vec, + pub offset: usize, + pub limit: usize, + pub has_more: bool, +} + #[derive(Deserialize)] pub struct GetImagesParams { pub folder_id: Option, pub search: Option, pub media_kind: Option, pub favorites_only: Option, + pub rating_min: Option, pub embedding_failed_only: Option, pub sort: Option, pub offset: Option, @@ -44,7 +54,9 @@ pub struct UpdateImageDetailsParams { pub struct FindSimilarImagesParams { pub image_id: i64, pub folder_id: Option, + pub offset: Option, pub limit: Option, + pub threshold: Option, } #[derive(Deserialize)] @@ -114,9 +126,56 @@ pub struct SemanticSearchParams { pub folder_id: Option, pub media_kind: Option, pub favorites_only: Option, + pub rating_min: Option, pub limit: Option, } +#[derive(Deserialize)] +pub struct TagSearchParams { + pub query: String, + pub folder_id: Option, + pub media_kind: Option, + pub favorites_only: Option, + pub rating_min: Option, + pub limit: Option, +} + +#[derive(Deserialize)] +pub struct GetExploreTagsParams { + pub folder_id: Option, + pub limit: Option, +} + +#[derive(Deserialize)] +pub struct SearchTagsAutocompleteParams { + pub query: String, + pub folder_id: Option, + pub limit: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct DuplicateGroup { + pub file_hash: String, + pub file_size: u64, + pub images: Vec, +} + +#[derive(Serialize)] +pub struct DuplicateScanCache { + pub groups: Vec, + pub scanned_at: i64, +} + +#[derive(Deserialize)] +pub struct DeleteImagesFromDiskParams { + pub image_ids: Vec, +} + +#[derive(Deserialize)] +pub struct GetImagesByIdsParams { + pub image_ids: Vec, +} + #[tauri::command] pub async fn add_folder( app: AppHandle, @@ -187,6 +246,7 @@ pub async fn get_images( let search = params.search.as_deref(); let media_kind = params.media_kind.as_deref(); let favorites_only = params.favorites_only.unwrap_or(false); + let rating_min = params.rating_min.unwrap_or(0); let embedding_failed_only = params.embedding_failed_only.unwrap_or(false); let total = db::count_images( @@ -195,6 +255,7 @@ pub async fn get_images( search, media_kind, favorites_only, + rating_min, embedding_failed_only, ) .map_err(|e| e.to_string())?; @@ -205,6 +266,7 @@ pub async fn get_images( search, media_kind, favorites_only, + rating_min, embedding_failed_only, sort, offset, @@ -254,16 +316,42 @@ pub async fn reindex_folder( pub async fn find_similar_images( db: State<'_, DbState>, params: FindSimilarImagesParams, -) -> Result, String> { +) -> Result { let conn = db.get().map_err(|e| e.to_string())?; let limit = params.limit.unwrap_or(32); + let offset = params.offset.unwrap_or(0); + let threshold = params.threshold.unwrap_or(0.24); if !vector::has_image_vector(&conn, params.image_id).map_err(|e| e.to_string())? { db::repair_embedding_consistency(&conn).map_err(|e| e.to_string())?; - return Ok(Vec::new()); + return Ok(SimilarImagesPage { + images: Vec::new(), + offset, + limit, + has_more: false, + }); } - let image_ids = vector::find_similar_image_ids(&conn, params.image_id, limit, params.folder_id) + let matches = hnsw_index::find_similar_image_matches( + &conn, + params.image_id, + params.folder_id, + threshold, + offset, + limit + 1, + ) .map_err(|e| e.to_string())?; - db::get_images_by_ids(&conn, &image_ids).map_err(|e| e.to_string()) + let has_more = matches.len() > limit; + let image_ids = matches + .into_iter() + .take(limit) + .map(|(image_id, _)| image_id) + .collect::>(); + let images = db::get_images_by_ids(&conn, &image_ids).map_err(|e| e.to_string())?; + Ok(SimilarImagesPage { + images, + offset, + limit, + has_more, + }) } #[derive(Serialize)] @@ -328,10 +416,31 @@ pub async fn semantic_search_images( if params.favorites_only.unwrap_or(false) { images.retain(|image| image.favorite); } + if let Some(rating_min) = params.rating_min { + images.retain(|image| image.rating >= rating_min); + } Ok(images) } +#[tauri::command] +pub async fn search_images_by_tag( + db: State<'_, DbState>, + params: TagSearchParams, +) -> Result, String> { + let conn = db.get().map_err(|e| e.to_string())?; + db::search_images_by_tag( + &conn, + ¶ms.query, + params.folder_id, + params.media_kind.as_deref(), + params.favorites_only.unwrap_or(false), + params.rating_min.unwrap_or(0), + params.limit.unwrap_or(64), + ) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn set_generated_caption( db: State<'_, DbState>, @@ -521,6 +630,8 @@ pub struct TagCloudEntry { pub count: usize, pub representative_image_id: i64, pub thumbnail_path: Option, + #[serde(default)] + pub image_ids: Vec, } fn fnv_hash_ids(ids: &[i64]) -> u64 { @@ -569,7 +680,11 @@ pub async fn get_tag_cloud( .map_err(|e| e.to_string())? { if let Ok(entries) = serde_json::from_str::>(&json) { - return Ok(entries); + // Reject cache entries written before image_ids were tracked — they all + // have empty image_ids which causes "No media found" when a cluster is opened. + if entries.iter().all(|e| !e.image_ids.is_empty()) { + return Ok(entries); + } } } } @@ -597,6 +712,13 @@ pub async fn get_tag_cloud( } let centroid = ¢roids[ci]; + let cluster_ids = points + .iter() + .enumerate() + .filter(|(i, _)| assignments[*i] == ci) + .map(|(i, _)| ids[i]) + .collect::>(); + let best_id = points .iter() .enumerate() @@ -614,6 +736,7 @@ pub async fn get_tag_cloud( count, representative_image_id: best_id, thumbnail_path, + image_ids: cluster_ids, }); } @@ -625,6 +748,224 @@ pub async fn get_tag_cloud( Ok(entries) } +#[tauri::command] +pub async fn get_explore_tags( + db: State<'_, DbState>, + 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()) +} + +#[tauri::command] +pub async fn search_tags_autocomplete( + db: State<'_, DbState>, + params: SearchTagsAutocompleteParams, +) -> Result, String> { + if params.query.trim().is_empty() { + 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()) +} + +#[tauri::command] +pub async fn find_duplicates( + app: AppHandle, + db: State<'_, DbState>, + folder_id: Option, +) -> Result, String> { + let records = { + let conn = db.get().map_err(|e| e.to_string())?; + db::get_all_image_paths(&conn, folder_id).map_err(|e| e.to_string())? + }; + + let total = records.len(); + let _ = app.emit("duplicate_scan_progress", (0usize, total)); + + // Two-phase detection. No full-file read at any point. + // + // Phase 1 — stat: + // Read only file metadata (size). Files with a unique size cannot be + // duplicates; discard them immediately. Zero file content read. + // + // Phase 2 — sample hash: + // For each size-matched candidate, read four evenly-spaced 16 KB windows + // (head, 33%, 66%, tail) and hash them together. Total I/O per file is + // capped at 64 KB regardless of how large the file is. + // + // For small files (≤ 64 KB) the windows cover the whole file, so the + // hash is exact. For large files, matching all four windows at different + // offsets is effectively impossible for natural photo/video content unless + // the files are genuinely identical — eliminating the need for a full read. + let app_hash = app.clone(); + let pairs: Vec<(u64, u64, i64)> = tokio::task::spawn_blocking(move || { + use memmap2::Mmap; + use rayon::prelude::*; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + 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 COVERED: usize = WINDOW * N; // 64 KB total; also full-coverage threshold + + // Hash four evenly-spaced 16 KB windows. For files ≤ 64 KB this is + // equivalent to hashing the entire file. + fn sample(mmap: &[u8]) -> u64 { + if mmap.len() <= COVERED { + return xxh3_64(mmap); + } + let mut h = Xxh3::new(); + for i in 0..N { + let pos = (mmap.len() - WINDOW) * i / (N - 1); + h.update(&mmap[pos..pos + WINDOW]); + } + h.digest() + } + + // ── Phase 1: stat ───────────────────────────────────────────────── + let sized: Vec<(i64, String, u64)> = records + .par_iter() + .filter_map(|r| { + let size = std::fs::metadata(&r.path).ok()?.len(); + if size == 0 { return None; } + Some((r.id, r.path.clone(), size)) + }) + .collect(); + + let mut by_size: HashMap> = HashMap::new(); + for (i, (_, _, size)) in sized.iter().enumerate() { + by_size.entry(*size).or_default().push(i); + } + let candidates: Vec = by_size + .into_values() + .filter(|g| g.len() > 1) + .flatten() + .collect(); + + if candidates.is_empty() { + return vec![]; + } + + // ── Phase 2: sample hash ────────────────────────────────────────── + let c_total = candidates.len(); + let _ = app_hash.emit("duplicate_scan_progress", (0usize, c_total)); + let counter = AtomicUsize::new(0); + + candidates + .par_iter() + .filter_map(|&idx| { + let (id, path, size) = &sized[idx]; + let file = std::fs::File::open(path).ok()?; + // SAFETY: read-only; no external truncation expected during scan. + let mmap = unsafe { Mmap::map(&file).ok()? }; + let hash = sample(&mmap); + let done = counter.fetch_add(1, Ordering::Relaxed) + 1; + if done % 100 == 0 || done == c_total { + let _ = app_hash.emit("duplicate_scan_progress", (done, c_total)); + } + Some((hash, *size, *id)) + }) + .collect() + }) + .await + .map_err(|e| e.to_string())?; + + let mut size_hash_map: std::collections::HashMap<(u64, u64), Vec> = std::collections::HashMap::new(); + for (hash, file_size, id) in pairs { + size_hash_map.entry((hash, file_size)).or_default().push(id); + } + + // Resolve image records for each duplicate group + let conn = db.get().map_err(|e| e.to_string())?; + let mut groups: Vec = size_hash_map + .into_iter() + .filter(|(_, ids)| ids.len() > 1) + .filter_map(|((hash, file_size), ids)| { + let images = db::get_images_by_ids(&conn, &ids).ok()?; + Some(DuplicateGroup { + file_hash: format!("{:016x}", hash), + file_size, + images, + }) + }) + .collect(); + + // Largest duplicates first — wastes the most space + groups.sort_by(|a, b| b.file_size.cmp(&a.file_size)); + + // Persist results so they survive restart — best-effort, ignore errors. + let folder_scope = match folder_id { + Some(id) => format!("folder:{}", id), + None => "all".to_string(), + }; + if let Ok(json) = serde_json::to_string(&groups) { + let _ = db::set_duplicate_scan_cache(&conn, &folder_scope, &json); + } + + Ok(groups) +} + +#[tauri::command] +pub async fn load_duplicate_scan_cache( + db: State<'_, DbState>, + folder_id: Option, +) -> Result, String> { + let folder_scope = match folder_id { + Some(id) => format!("folder:{}", id), + None => "all".to_string(), + }; + let conn = db.get().map_err(|e| e.to_string())?; + match db::get_duplicate_scan_cache(&conn, &folder_scope).map_err(|e| e.to_string())? { + Some((json, scanned_at)) => { + let groups: Vec = + serde_json::from_str(&json).map_err(|e| e.to_string())?; + Ok(Some(DuplicateScanCache { groups, scanned_at })) + } + None => Ok(None), + } +} + +#[tauri::command] +pub async fn delete_images_from_disk( + db: State<'_, DbState>, + params: DeleteImagesFromDiskParams, +) -> Result { + if params.image_ids.is_empty() { + return Ok(0); + } + let conn = db.get().map_err(|e| e.to_string())?; + // Collect paths before deleting DB rows + let records = db::get_all_image_paths(&conn, None).map_err(|e| e.to_string())?; + let id_set: std::collections::HashSet = params.image_ids.iter().copied().collect(); + let paths: Vec = records + .into_iter() + .filter(|r| id_set.contains(&r.id)) + .map(|r| r.path) + .collect(); + + db::delete_images_by_ids(&conn, ¶ms.image_ids).map_err(|e| e.to_string())?; + + let mut deleted = 0usize; + for path in &paths { + if std::fs::remove_file(path).is_ok() { + deleted += 1; + } + } + Ok(deleted) +} + +#[tauri::command] +pub async fn get_images_by_ids( + db: State<'_, DbState>, + params: GetImagesByIdsParams, +) -> Result, String> { + let conn = db.get().map_err(|e| e.to_string())?; + db::get_images_by_ids(&conn, ¶ms.image_ids).map_err(|e| e.to_string()) +} + // ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ── fn dot(a: &[f32], b: &[f32]) -> f32 { @@ -811,15 +1152,22 @@ pub struct SetTaggerThresholdParams { pub threshold: f32, } +#[derive(Deserialize)] +pub struct SetTaggerBatchSizeParams { + pub batch_size: usize, +} + #[derive(Deserialize)] pub struct QueueTaggingJobsParams { pub folder_id: Option, + pub folder_ids: Option>, pub image_id: Option, } #[derive(Deserialize)] pub struct ClearTaggingJobsParams { pub folder_id: Option, + pub folder_ids: Option>, } #[derive(Deserialize)] @@ -883,6 +1231,21 @@ pub async fn set_tagger_threshold( tagger::set_tagger_threshold(&app_dir, params.threshold).map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn get_tagger_batch_size(app: AppHandle) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + Ok(tagger::tagger_batch_size(&app_dir)) +} + +#[tauri::command] +pub async fn set_tagger_batch_size( + app: AppHandle, + params: SetTaggerBatchSizeParams, +) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + tagger::set_tagger_batch_size(&app_dir, params.batch_size).map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn prepare_tagger_model(app: AppHandle) -> Result { let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; @@ -913,19 +1276,28 @@ pub async fn queue_tagging_jobs( params: QueueTaggingJobsParams, ) -> Result { let conn = db.get().map_err(|e| e.to_string())?; - let (total, folder_ids) = match (params.folder_id, params.image_id) { - (_, Some(image_id)) => { + 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()) { + (_, 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 let image = db::get_image_by_id(&conn, image_id).map_err(|e| e.to_string())?; (1usize, vec![image.folder_id]) } - (Some(folder_id), None) => { + (Some(folder_id), None, _) => { let n = db::enqueue_missing_tagging_jobs_for_folder(&conn, folder_id) .map_err(|e| e.to_string())?; (n, vec![folder_id]) } - (None, None) => { + (None, None, false) => { + let mut total = 0usize; + for &folder_id in &requested_folder_ids { + total += db::enqueue_missing_tagging_jobs_for_folder(&conn, folder_id) + .map_err(|e| e.to_string())?; + } + (total, requested_folder_ids) + } + (None, None, true) => { let folders = db::get_folders(&conn).map_err(|e| e.to_string())?; let folder_ids: Vec = folders.iter().map(|f| f.id).collect(); let mut total = 0usize; @@ -948,14 +1320,27 @@ pub async fn clear_tagging_jobs( params: ClearTaggingJobsParams, ) -> Result { let conn = db.get().map_err(|e| e.to_string())?; - let n = db::clear_tagging_jobs(&conn, params.folder_id).map_err(|e| e.to_string())?; - let folder_ids: Vec = match params.folder_id { - Some(id) => vec![id], - None => db::get_folders(&conn) + 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())?; + } + (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(), + ), }; drop(conn); indexer::emit_folder_job_progress(&app, db.inner(), &folder_ids, true); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 81bd162..a11ca3b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -122,6 +122,14 @@ pub struct ImageTag { pub created_at: String, } +#[derive(Debug, Clone, Serialize)] +pub struct ExploreTagEntry { + pub tag: String, + pub count: i64, + pub representative_image_id: i64, + pub thumbnail_path: Option, +} + #[derive(Debug, Clone)] pub struct IndexedMediaEntry { pub id: i64, @@ -247,6 +255,12 @@ pub fn migrate(conn: &Connection) -> Result<()> { created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS duplicate_scan_cache ( + folder_scope TEXT PRIMARY KEY, + scanned_at INTEGER NOT NULL, + groups_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_images_folder_id ON images(folder_id); CREATE INDEX IF NOT EXISTS idx_images_modified_at ON images(modified_at); CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status); @@ -1302,6 +1316,7 @@ pub fn get_images( search: Option<&str>, media_kind: Option<&str>, favorites_only: bool, + rating_min: i64, embedding_failed_only: bool, sort: &str, offset: i64, @@ -1314,6 +1329,8 @@ pub fn get_images( "date_desc" => "modified_at DESC NULLS LAST", "size_asc" => "file_size ASC", "size_desc" => "file_size DESC", + "rating_asc" => "rating ASC, modified_at DESC NULLS LAST", + "rating_desc" => "rating DESC, modified_at DESC NULLS LAST", "duration_asc" => "duration_ms ASC NULLS LAST", "duration_desc" => "duration_ms DESC NULLS LAST", _ => "modified_at DESC NULLS LAST", @@ -1333,9 +1350,10 @@ pub fn get_images( AND (?2 IS NULL OR filename LIKE ?2) AND (?3 IS NULL OR media_kind = ?3) AND (?4 = 0 OR favorite = 1) - AND (?5 = 0 OR embedding_status = 'failed') + AND rating >= ?5 + AND (?6 = 0 OR embedding_status = 'failed') ORDER BY {} - LIMIT ?6 OFFSET ?7", + LIMIT ?7 OFFSET ?8", order ); let mut stmt = conn.prepare(&sql)?; @@ -1345,6 +1363,7 @@ pub fn get_images( search_pattern, media_kind, favorites_flag, + rating_min, embedding_failed_flag, limit, offset @@ -1360,6 +1379,7 @@ pub fn count_images( search: Option<&str>, media_kind: Option<&str>, favorites_only: bool, + rating_min: i64, embedding_failed_only: bool, ) -> Result { let search_pattern = search.map(|value| format!("%{}%", value)); @@ -1372,12 +1392,14 @@ pub fn count_images( AND (?2 IS NULL OR filename LIKE ?2) AND (?3 IS NULL OR media_kind = ?3) AND (?4 = 0 OR favorite = 1) - AND (?5 = 0 OR embedding_status = 'failed')", + AND rating >= ?5 + AND (?6 = 0 OR embedding_status = 'failed')", params![ folder_id, search_pattern, media_kind, favorites_flag, + rating_min, embedding_failed_flag ], |row| row.get(0), @@ -1386,6 +1408,141 @@ pub fn count_images( Ok(count) } +pub fn search_images_by_tag( + conn: &Connection, + query: &str, + folder_id: Option, + media_kind: Option<&str>, + favorites_only: bool, + rating_min: i64, + limit: usize, +) -> Result> { + let normalized_query = query.trim().to_ascii_lowercase(); + if normalized_query.is_empty() { + return Ok(Vec::new()); + } + let favorites_flag = i64::from(favorites_only); + + let mut stmt = conn.prepare( + "SELECT DISTINCT i.id + FROM images i + JOIN image_tags t ON t.image_id = i.id + WHERE (?1 IS NULL OR i.folder_id = ?1) + AND (?2 IS NULL OR i.media_kind = ?2) + AND (?3 = 0 OR i.favorite = 1) + AND i.rating >= ?4 + AND LOWER(TRIM(t.tag)) = ?5 + ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC + LIMIT ?6", + )?; + + let image_ids = stmt + .query_map( + params![ + folder_id, + media_kind, + favorites_flag, + rating_min, + normalized_query, + limit as i64 + ], + |row| row.get::<_, i64>(0), + )? + .collect::>>()?; + + get_images_by_ids(conn, &image_ids) +} + +pub fn search_tags_autocomplete( + conn: &Connection, + query: &str, + folder_id: Option, + limit: usize, +) -> Result> { + let pattern = format!("%{}%", query.to_lowercase()); + let mut stmt = conn.prepare( + "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id + FROM image_tags t + JOIN images i ON i.id = t.image_id + WHERE (?1 IS NULL OR i.folder_id = ?1) + AND LOWER(t.tag) LIKE ?2 + GROUP BY t.tag + ORDER BY tag_count DESC, t.tag ASC + LIMIT ?3", + )?; + let rows = stmt + .query_map(params![folder_id, pattern, limit as i64], |row| { + Ok(ExploreTagEntry { + tag: row.get(0)?, + count: row.get(1)?, + representative_image_id: row.get(2)?, + thumbnail_path: None, // skip per-suggestion thumbnail for speed + }) + })? + .collect::>>()?; + Ok(rows) +} + +pub struct ImagePathRecord { + pub id: i64, + pub path: String, + pub thumbnail_path: Option, +} + +pub fn get_all_image_paths( + conn: &Connection, + folder_id: Option, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, path, thumbnail_path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id", + )?; + let rows = stmt + .query_map(params![folder_id], |row| { + Ok(ImagePathRecord { + id: row.get(0)?, + path: row.get(1)?, + thumbnail_path: row.get(2)?, + }) + })? + .collect::>>()?; + Ok(rows) +} + +pub fn get_explore_tags( + conn: &Connection, + folder_id: Option, + limit: usize, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id + FROM image_tags t + JOIN images i ON i.id = t.image_id + WHERE (?1 IS NULL OR i.folder_id = ?1) + GROUP BY t.tag + HAVING COUNT(DISTINCT t.image_id) >= 2 + ORDER BY tag_count DESC, t.tag ASC + LIMIT ?2", + )?; + + let rows = stmt + .query_map(params![folder_id, limit as i64], |row| { + let representative_image_id = row.get::<_, i64>(2)?; + let thumbnail_path = get_image_by_id(conn, representative_image_id) + .ok() + .and_then(|image| image.thumbnail_path); + + Ok(ExploreTagEntry { + tag: row.get(0)?, + count: row.get(1)?, + representative_image_id, + thumbnail_path, + }) + })? + .collect::>>()?; + + Ok(rows) +} + pub fn get_failed_embedding_images( conn: &Connection, folder_id: i64, @@ -1871,6 +2028,43 @@ pub fn set_tag_cloud_cache( Ok(()) } +/// Returns (groups_json, scanned_at_unix) for the given folder scope, if present. +pub fn get_duplicate_scan_cache( + conn: &Connection, + folder_scope: &str, +) -> Result> { + match conn.query_row( + "SELECT groups_json, scanned_at FROM duplicate_scan_cache WHERE folder_scope = ?1", + params![folder_scope], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) { + Ok(row) => Ok(Some(row)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// Upserts the duplicate scan cache for the given scope. +pub fn set_duplicate_scan_cache( + conn: &Connection, + folder_scope: &str, + groups_json: &str, +) -> Result<()> { + let scanned_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + conn.execute( + "INSERT INTO duplicate_scan_cache (folder_scope, scanned_at, groups_json) + VALUES (?1, ?2, ?3) + ON CONFLICT(folder_scope) DO UPDATE SET + scanned_at = excluded.scanned_at, + groups_json = excluded.groups_json", + params![folder_scope, scanned_at, groups_json], + )?; + Ok(()) +} + fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<()> { let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?; let mut rows = stmt.query([])?; diff --git a/src-tauri/src/hnsw_index.rs b/src-tauri/src/hnsw_index.rs new file mode 100644 index 0000000..b7ef6c7 --- /dev/null +++ b/src-tauri/src/hnsw_index.rs @@ -0,0 +1,129 @@ +use crate::vector; +use anyhow::Result; +use hnsw_rs::prelude::{DistCosine, Hnsw, Neighbour}; +use rusqlite::Connection; +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +const HNSW_MAX_CONNECTIONS: usize = 24; +const HNSW_EF_CONSTRUCTION: usize = 300; +const HNSW_EF_SEARCH: usize = 96; + +struct CachedHnswIndex { + revision: String, + image_ids_by_external: Vec, + external_by_image_id: HashMap, + hnsw: Hnsw<'static, f32, DistCosine>, +} + +static IMAGE_HNSW_INDEX: OnceLock>> = OnceLock::new(); + +fn cache() -> &'static RwLock> { + IMAGE_HNSW_INDEX.get_or_init(|| RwLock::new(None)) +} + +fn build_index(conn: &Connection) -> Result { + let embeddings = vector::get_all_image_embeddings_with_ids(conn, None)?; + let max_elements = embeddings.len().max(1); + let max_layer = 16.min((max_elements as f32).ln().trunc() as usize).max(1); + let mut hnsw = Hnsw::::new( + HNSW_MAX_CONNECTIONS, + max_elements, + max_layer, + HNSW_EF_CONSTRUCTION, + DistCosine {}, + ); + + let image_ids_by_external = embeddings + .iter() + .map(|(image_id, _)| *image_id) + .collect::>(); + let external_by_image_id = image_ids_by_external + .iter() + .enumerate() + .map(|(external_id, image_id)| (*image_id, external_id)) + .collect::>(); + let data_with_id = embeddings + .iter() + .enumerate() + .map(|(external_id, (_, embedding))| (embedding, external_id)) + .collect::>(); + + hnsw.parallel_insert(&data_with_id); + hnsw.set_searching_mode(true); + + Ok(CachedHnswIndex { + revision: vector::get_embedding_revision(conn)?, + image_ids_by_external, + external_by_image_id, + hnsw, + }) +} + +fn ensure_index(conn: &Connection) -> Result<()> { + let revision = vector::get_embedding_revision(conn)?; + + { + let guard = cache().read().expect("hnsw cache poisoned"); + if guard.as_ref().map(|cached| cached.revision.as_str()) == Some(revision.as_str()) { + return Ok(()); + } + } + + let next = build_index(conn)?; + let mut guard = cache().write().expect("hnsw cache poisoned"); + *guard = Some(next); + Ok(()) +} + +pub fn find_similar_image_matches( + conn: &Connection, + image_id: i64, + folder_id: Option, + threshold: f32, + offset: usize, + limit: usize, +) -> Result> { + ensure_index(conn)?; + + let query_embedding = match vector::get_image_embedding(conn, image_id)? { + Some(embedding) => embedding, + None => return Ok(Vec::new()), + }; + + let guard = cache().read().expect("hnsw cache poisoned"); + let Some(cached) = guard.as_ref() else { + return Ok(Vec::new()); + }; + + let knbn = (offset + limit).max(limit).saturating_add(32); + let neighbours: Vec = if let Some(folder_id) = folder_id { + let mut allowed_ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))? + .into_iter() + .filter_map(|(allowed_image_id, _)| { + cached.external_by_image_id.get(&allowed_image_id).copied() + }) + .collect::>(); + allowed_ids.sort_unstable(); + cached + .hnsw + .search_filter(&query_embedding, knbn, HNSW_EF_SEARCH, Some(&allowed_ids)) + } else { + cached.hnsw.search(&query_embedding, knbn, HNSW_EF_SEARCH) + }; + + let matches = neighbours + .into_iter() + .filter_map(|neighbour| { + let image_id_match = cached.image_ids_by_external.get(neighbour.d_id).copied()?; + if image_id_match == image_id || neighbour.distance > threshold { + return None; + } + Some((image_id_match, neighbour.distance)) + }) + .skip(offset) + .take(limit) + .collect::>(); + + Ok(matches) +} diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index f509d1f..cffc605 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -331,6 +331,7 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) if !missing_ids.is_empty() { db::delete_images_by_ids(&conn, &missing_ids)?; } + let _ = db::backfill_embedding_jobs(&conn)?; db::update_folder_count(&conn, folder_id)?; } @@ -864,8 +865,6 @@ fn process_caption_batch( Ok(()) } -const TAGGING_BATCH_SIZE: usize = 1; - fn process_tagging_batch( app: &AppHandle, pool: &DbPool, @@ -877,9 +876,10 @@ fn process_tagging_batch( } let paused_folders = paused_folder_ids("tagging"); + let batch_size = crate::tagger::tagger_batch_size(app_data_dir); let jobs = with_db_write_lock(|| { let mut conn = pool.get()?; - db::claim_tagging_jobs(&mut conn, &paused_folders, TAGGING_BATCH_SIZE) + db::claim_tagging_jobs(&mut conn, &paused_folders, batch_size) })?; if jobs.is_empty() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 217eabf..39c9922 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod captioner; mod commands; mod db; mod embedder; +mod hnsw_index; mod indexer; mod media; mod storage; @@ -89,6 +90,7 @@ pub fn run() { commands::debug_similar_images, commands::retry_failed_embeddings, commands::semantic_search_images, + commands::search_images_by_tag, commands::get_caption_model_status, commands::get_caption_acceleration, commands::set_caption_acceleration, @@ -107,6 +109,8 @@ pub fn run() { commands::set_worker_paused, commands::get_worker_states, commands::get_tag_cloud, + commands::get_explore_tags, + commands::get_images_by_ids, commands::get_failed_embedding_images, commands::get_tagger_model_status, commands::get_tagger_acceleration, @@ -114,6 +118,8 @@ pub fn run() { commands::probe_tagger_runtime, commands::get_tagger_threshold, commands::set_tagger_threshold, + commands::get_tagger_batch_size, + commands::set_tagger_batch_size, commands::prepare_tagger_model, commands::delete_tagger_model, commands::queue_tagging_jobs, @@ -121,6 +127,10 @@ pub fn run() { commands::get_image_tags, commands::add_user_tag, commands::remove_tag, + commands::search_tags_autocomplete, + commands::find_duplicates, + commands::load_duplicate_scan_cache, + commands::delete_images_from_disk, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/tagger.rs b/src-tauri/src/tagger.rs index d0cd179..defc066 100644 --- a/src-tauri/src/tagger.rs +++ b/src-tauri/src/tagger.rs @@ -14,6 +14,7 @@ pub const WD_TAGGER_MODEL_NAME: &str = "wd-swinv2-tagger-v3"; const TAGGER_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt"; const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.txt"; +const TAGGER_BATCH_SIZE_FILE: &str = "settings/tagger_batch_size.txt"; // Files required on disk before the tagger can run. The ONNX runtime DLLs // are shared with the captioner and live in the same `onnxruntime/` directory. @@ -193,6 +194,28 @@ pub fn set_tagger_threshold(app_data_dir: &Path, threshold: f32) -> Result Ok(clamped) } +pub fn tagger_batch_size(app_data_dir: &Path) -> usize { + let path = app_data_dir.join(TAGGER_BATCH_SIZE_FILE); + let Ok(value) = std::fs::read_to_string(path) else { + return 8; + }; + value + .trim() + .parse::() + .unwrap_or(8) + .clamp(1, 100) +} + +pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result { + let clamped = batch_size.clamp(1, 100); + let path = app_data_dir.join(TAGGER_BATCH_SIZE_FILE); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, clamped.to_string())?; + Ok(clamped) +} + // --------------------------------------------------------------------------- // Model status / download // --------------------------------------------------------------------------- diff --git a/src-tauri/src/vector.rs b/src-tauri/src/vector.rs index 3b5d942..0fa753c 100644 --- a/src-tauri/src/vector.rs +++ b/src-tauri/src/vector.rs @@ -96,15 +96,25 @@ pub fn find_similar_image_ids( Err(error) => return Err(error.into()), }; - let allowed_folder_ids = match folder_id { - Some(folder_id) => Some(image_ids_for_folder(conn, folder_id)?), - None => None, - }; - let search_limit = if allowed_folder_ids.is_some() { - count_image_vectors(conn)?.max(1) as usize - } else { - limit + 1 - }; + if let Some(folder_id) = folder_id { + // Brute-force cosine scan scoped to the folder — avoids the KNN k=4096 limit + // and returns exact nearest neighbours within the folder. + let mut stmt = conn.prepare( + "SELECT v.image_id + FROM image_vec v + JOIN images i ON i.id = v.image_id + WHERE i.folder_id = ?2 + AND v.image_id != ?3 + ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC + LIMIT ?4", + )?; + let rows = stmt.query_map((&embedding, folder_id, image_id, limit as i64), |row| { + row.get::<_, i64>(0) + })?; + return Ok(rows.collect::>>()?); + } + + // Global KNN search (no folder filter) — use the ANN index. let mut stmt = conn.prepare( "SELECT image_id FROM image_vec @@ -112,20 +122,12 @@ pub fn find_similar_image_ids( AND k = ?2", )?; let rows = stmt - .query_map((&embedding, search_limit as i64), |row| { - row.get::<_, i64>(0) - })? + .query_map((&embedding, (limit + 1) as i64), |row| row.get::<_, i64>(0))? .collect::>>()?; let mut ids = Vec::new(); for row in rows { if row != image_id { - if allowed_folder_ids - .as_ref() - .is_some_and(|folder_ids| !folder_ids.contains(&row)) - { - continue; - } ids.push(row); } if ids.len() >= limit { @@ -135,15 +137,102 @@ pub fn find_similar_image_ids( Ok(ids) } -fn image_ids_for_folder( - conn: &Connection, - folder_id: i64, -) -> Result> { - let mut stmt = conn.prepare("SELECT id FROM images WHERE folder_id = ?1")?; - let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?; - Ok(rows.collect::>>()?) +// pub fn find_similar_image_matches( +// conn: &Connection, +// image_id: i64, +// folder_id: Option, +// threshold: f32, +// offset: usize, +// limit: usize, +// ) -> Result> { +// let embedding: Vec = match conn.query_row( +// "SELECT embedding FROM image_vec WHERE image_id = ?1", +// [image_id], +// |row| row.get(0), +// ) { +// Ok(embedding) => embedding, +// Err(SqliteError::QueryReturnedNoRows) => return Ok(Vec::new()), +// Err(error) => return Err(error.into()), +// }; + +// let query = match folder_id { +// Some(_) => { +// "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance +// FROM image_vec v +// JOIN images i ON i.id = v.image_id +// WHERE i.folder_id = ?2 +// AND v.image_id != ?3 +// AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?4 +// ORDER BY distance ASC +// LIMIT ?5 OFFSET ?6" +// } +// None => { +// "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance +// FROM image_vec v +// WHERE v.image_id != ?2 +// AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?3 +// ORDER BY distance ASC +// LIMIT ?4 OFFSET ?5" +// } +// }; + +// let mut stmt = conn.prepare(query)?; +// match folder_id { +// Some(folder_id) => Ok(stmt +// .query_map( +// ( +// &embedding, +// folder_id, +// image_id, +// threshold, +// limit as i64, +// offset as i64, +// ), +// |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)), +// )? +// .collect::>>()?), +// None => Ok(stmt +// .query_map( +// (&embedding, image_id, threshold, limit as i64, offset as i64), +// |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)), +// )? +// .collect::>>()?), +// } +// } + +pub fn get_image_embedding(conn: &Connection, image_id: i64) -> Result>> { + let embedding: Result, rusqlite::Error> = conn.query_row( + "SELECT embedding FROM image_vec WHERE image_id = ?1", + [image_id], + |row| row.get(0), + ); + + match embedding { + Ok(bytes) => Ok(Some(unpack_f32(&bytes))), + Err(SqliteError::QueryReturnedNoRows) => Ok(None), + Err(error) => Err(error.into()), + } } +pub fn get_embedding_revision(conn: &Connection) -> Result { + let count: i64 = conn.query_row("SELECT COUNT(*) FROM image_vec", [], |row| row.get(0))?; + let max_updated_at: Option = conn.query_row( + "SELECT MAX(embedding_updated_at) FROM images WHERE embedding_status = 'ready'", + [], + |row| row.get(0), + )?; + Ok(format!("{}:{}", count, max_updated_at.unwrap_or_default())) +} + +// fn image_ids_for_folder( +// conn: &Connection, +// folder_id: i64, +// ) -> Result> { +// let mut stmt = conn.prepare("SELECT id FROM images WHERE folder_id = ?1")?; +// let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?; +// Ok(rows.collect::>>()?) +// } + /// Returns all stored image embeddings with their image IDs, optionally filtered to one folder. /// Each entry is `(image_id, normalized_f32_embedding)`. pub fn get_all_image_embeddings_with_ids( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ea720ac..51aa343 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,9 +4,9 @@ "version": "0.1.0", "identifier": "wtf.jezz.phokus", "build": { - "beforeDevCommand": "pnpm dev", + "beforeDevCommand": "pnpm dev:vite", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "pnpm build", + "beforeBuildCommand": "pnpm build:vite", "frontendDist": "../dist" }, "app": { @@ -25,7 +25,9 @@ "csp": null, "assetProtocol": { "enable": true, - "scope": ["**"] + "scope": [ + "**" + ] } } }, @@ -40,4 +42,4 @@ "icons/icon.ico" ] } -} +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 4b6039b..2486ba9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { Toolbar } from "./components/Toolbar"; import { Gallery } from "./components/Gallery"; import { Lightbox } from "./components/Lightbox"; import { TagCloud } from "./components/TagCloud"; +import { DuplicateFinder } from "./components/DuplicateFinder"; import { TitleBar } from "./components/TitleBar"; import { SettingsModal } from "./components/SettingsModal"; @@ -14,6 +15,7 @@ export default function App() { const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress); const loadImages = useGalleryStore((state) => state.loadImages); const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus); + const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache); const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const activeView = useGalleryStore((state) => state.activeView); @@ -21,6 +23,7 @@ export default function App() { loadFolders().then(() => { void loadBackgroundJobProgress(); void loadCaptionModelStatus(); + void loadDuplicateScanCache(); return loadImages(true); }); let unlisten: (() => void) | undefined; @@ -46,6 +49,11 @@ export default function App() { + ) : activeView === "duplicates" ? ( + <> + + + ) : ( <> diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index bc007a5..4515215 100644 --- a/src/components/BackgroundTasks.tsx +++ b/src/components/BackgroundTasks.tsx @@ -58,6 +58,8 @@ export function BackgroundTasks() { const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); + const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); + const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const [expanded, setExpanded] = useState(false); const [dismissed, setDismissed] = useState>({}); const [paused, setPaused] = useState>>({}); @@ -127,6 +129,7 @@ export function BackgroundTasks() { }; const dismissTask = (id: number, snapshot: string) => { + if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed void clearTaggingJobs(id); setDismissed((prev) => ({ ...prev, [id]: snapshot })); setExpanded(false); @@ -244,10 +247,35 @@ export function BackgroundTasks() { .filter((t) => dismissed[t.id] !== t.snapshot); }, [folders, indexingProgress, mediaJobProgress, dismissed]); - if (tasks.length === 0) return null; + // Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed + const duplicateScanTask: Task | null = duplicateScanning ? { + id: -1, + name: "Duplicate Scan", + stages: [{ + label: "Hashing", + detail: duplicateScanProgress + ? `${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}` + : "Starting…", + progress: duplicateScanProgress && duplicateScanProgress.total > 0 + ? (duplicateScanProgress.scanned / duplicateScanProgress.total) * 100 + : null, + failed: false, + }], + hasFailedEmbeddings: false, + hasFailedTagging: false, + pendingMediaWork: 1, + embeddingProcessed: 0, + embeddingTotal: 0, + currentFile: null, + snapshot: "", + } : null; - const primary = tasks[0]; - const extraCount = tasks.length - 1; + const allTasks = duplicateScanTask ? [duplicateScanTask, ...tasks] : tasks; + + if (allTasks.length === 0) return null; + + const primary = allTasks[0]; + const extraCount = allTasks.length - 1; const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging) && t.pendingMediaWork === 0); // Best progress bar value: use embedding progress if available (most informative), @@ -348,8 +376,8 @@ export function BackgroundTasks() { )} - {/* Expand chevron (only when multiple folders) */} - {tasks.length > 1 && ( + {/* Expand chevron (only when multiple tasks) */} + {allTasks.length > 1 && ( )} - {/* Dismiss */} - + {/* Dismiss — hidden for system tasks like duplicate scan */} + {primary.id >= 0 && ( + + )} {/* Expanded panel — one row per folder */} {expanded && (
- {tasks.map((task) => { + {allTasks.map((task) => { const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings"); const taskTaggingStage = task.stages.find((s) => s.label === "Tags"); const taskScanningStage = task.stages.find((s) => s.label === "Scanning"); @@ -453,15 +483,17 @@ export function BackgroundTasks() { )} - + {task.id >= 0 && ( + + )}
{task.currentFile && ( diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx new file mode 100644 index 0000000..d01f05d --- /dev/null +++ b/src/components/DuplicateFinder.tsx @@ -0,0 +1,274 @@ +import { useState } from "react"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { DuplicateGroup, useGalleryStore } from "../store"; + +function formatBytes(bytes: number): string { + if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; + if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${bytes} B`; +} + +function DuplicateGroupCard({ group }: { group: DuplicateGroup }) { + const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds); + const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected); + const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates); + const groupSelectedCount = group.images.filter((img) => selectedIds.has(img.id)).length; + const noneSelected = groupSelectedCount === 0; + + // "Keep all but the first" — a common quick action + const handleKeepFirst = () => { + const toDelete = group.images.slice(1).map((img) => img.id); + // Clear any selection for this group first, then add the ones to delete + for (const img of group.images) { + if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id); + } + selectAllDuplicates(toDelete); + }; + + return ( +
+ {/* Group header */} +
+
+ + {group.images.length} copies + + {formatBytes(group.file_size)} each + + {formatBytes(group.file_size * (group.images.length - 1))} wasted + +
+
+ {noneSelected ? ( + + ) : ( + + )} +
+
+ + {/* Image grid */} +
+ {group.images.map((image) => { + const isSelected = selectedIds.has(image.id); + const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null; + return ( + + ); + })} +
+
+ ); +} + +function formatRelativeTime(unixSecs: number): string { + const diff = Math.floor(Date.now() / 1000) - unixSecs; + if (diff < 60) return "just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +export function DuplicateFinder() { + const duplicateGroups = useGalleryStore((state) => state.duplicateGroups); + const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); + const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); + const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds); + const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned); + const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); + const scanDuplicates = useGalleryStore((state) => state.scanDuplicates); + const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection); + const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups); + const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates); + + const [deleting, setDeleting] = useState(false); + const [deleteResult, setDeleteResult] = useState(null); + + const selectedCount = duplicateSelectedIds.size; + const hasResults = duplicateGroups.length > 0; + const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null); + const totalWasted = duplicateGroups.reduce( + (sum, g) => sum + g.file_size * (g.images.length - 1), + 0, + ); + const totalDuplicateImages = duplicateGroups.reduce((sum, g) => sum + g.images.length - 1, 0); + + const handleDelete = async () => { + setDeleting(true); + setDeleteResult(null); + try { + const deleted = await deleteSelectedDuplicates(); + setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`); + } catch (e) { + setDeleteResult(String(e)); + } finally { + setDeleting(false); + } + }; + + const progressPercent = + duplicateScanProgress && duplicateScanProgress.total > 0 + ? Math.round((duplicateScanProgress.scanned / duplicateScanProgress.total) * 100) + : 0; + + return ( +
+ {/* Header */} +
+
+
+

Duplicate Finder

+

+ {duplicateScanning + ? duplicateScanProgress + ? `Scanning… ${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}` + : "Starting scan…" + : hasResults + ? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable` + : duplicateLastScanned !== null + ? "No duplicates found" + : "Scan your library for identical files"} +

+ {!duplicateScanning && duplicateLastScanned !== null && ( +

+ Last scanned {formatRelativeTime(duplicateLastScanned)} +

+ )} +
+
+ {/* Batch select — only shown when there are groups and nothing is selected yet */} + {hasResults && selectedCount === 0 && !deleting && ( + + )} + {selectedCount > 0 ? ( + <> + {selectedCount} marked for deletion + + + + ) : null} + +
+
+ + {/* Progress bar */} + {duplicateScanning && duplicateScanProgress ? ( +
+
+
+ ) : null} + + {deleteResult ? ( +

{deleteResult}

+ ) : null} +
+ + {/* Body */} + {duplicateScanning && !hasResults ? ( +
+
+ Hashing files… +
+ ) : !hasScanned ? ( +
+
+ + + +

+ Finds files with identical content regardless of filename or location. + Click Scan for duplicates to begin. +

+

+ Large libraries may take a minute — files are hashed from disk. +

+
+
+ ) : duplicateGroups.length === 0 ? ( +
+

No duplicate files found.

+
+ ) : ( +
+
+ {duplicateGroups.map((group) => ( + + ))} +
+
+ )} +
+ ); +} + diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index ee0e6ea..0f820d4 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { convertFileSrc } from "@tauri-apps/api/core"; -import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; +import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; const GAP = 6; @@ -30,6 +30,7 @@ function ContextMenu({ const openImage = useGalleryStore((state) => state.openImage); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); + const similarScope = useGalleryStore((state) => state.similarScope); const canFindSimilar = image.embedding_status === "ready"; return ( @@ -59,7 +60,7 @@ function ContextMenu({ }`} onClick={async () => { if (!canFindSimilar) return; - await loadSimilarImages(image.id); + await loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id); onClose(); }} disabled={!canFindSimilar} @@ -115,6 +116,7 @@ function ImageTile({ const [loaded, setLoaded] = useState(false); const [errored, setErrored] = useState(false); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); + const similarScope = useGalleryStore((state) => state.similarScope); const canFindSimilar = image.embedding_status === "ready"; const src = image.thumbnail_path @@ -181,6 +183,15 @@ function ImageTile({
)} + {image.rating > 0 && ( +
+ {Array.from({ length: image.rating }, (_, index) => ( + + + + ))} +
+ )} {image.media_kind === "video" && image.duration_ms && (
{formatDuration(image.duration_ms)} @@ -230,7 +241,7 @@ function ImageTile({ onClick={(event) => { event.stopPropagation(); if (!canFindSimilar) return; - void loadSimilarImages(image.id); + void loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id); }} disabled={!canFindSimilar} > @@ -250,11 +261,11 @@ export function Gallery() { const loadingImages = useGalleryStore((state) => state.loadingImages); const zoomPreset = useGalleryStore((state) => state.zoomPreset); const search = useGalleryStore((state) => state.search); - const searchMode = useGalleryStore((state) => state.searchMode); const collectionTitle = useGalleryStore((state) => state.collectionTitle); const imageLoadError = useGalleryStore((state) => state.imageLoadError); const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey); const isSimilarResults = collectionTitle === "Similar Images"; + const parsedSearch = parseSearchValue(search); const parentRef = useRef(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null); @@ -262,6 +273,7 @@ export function Gallery() { const handleScroll = useCallback(() => { const element = parentRef.current; if (!element) return; + if (element.scrollTop < 24) return; const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600; if (nearBottom && !loadingImages && images.length < totalImages) { void loadMoreImages(); @@ -295,85 +307,87 @@ export function Gallery() { }; }, []); - if (images.length === 0 && loadingImages) { - return ( -
-
-
-

- {isSimilarResults - ? "Finding similar images" - : searchMode === "semantic" && search.trim().length > 0 - ? `Searching for matches to "${search}"` - : "Loading media"} -

-

- {isSimilarResults - ? "Comparing visual embeddings" - : searchMode === "semantic" && search.trim().length > 0 - ? "Semantic search can take a little longer than filename search" - : "Fetching results"} -

-
-
- ); - } - - if (images.length === 0 && !loadingImages) { - return ( -
-
- - - -

- {imageLoadError - ? "Could not load results" - : isSimilarResults - ? "No similar images found" - : searchMode === "semantic" && search.trim().length > 0 - ? "No semantic matches found" - : "No media found"} -

-

- {imageLoadError - ? imageLoadError - : isSimilarResults - ? "This item may be visually isolated, or more embeddings may need to finish processing" - : searchMode === "semantic" && search.trim().length > 0 - ? "Try a broader phrase, or wait for more embeddings to finish processing" - : "Try adjusting your filters or add a new folder"} -

-
-
- ); - } - return (
-
- {images.map((image) => ( - openImage(image)} - onContextMenu={(event) => { - event.preventDefault(); - setContextMenu({ x: event.clientX, y: event.clientY, image }); - }} - /> - ))} -
+ {images.length === 0 && loadingImages ? ( +
+
+
+

+ {isSimilarResults + ? "Finding similar images" + : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 + ? `Searching for matches to "${parsedSearch.query}"` + : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 + ? `Searching tags for "${parsedSearch.query}"` + : "Loading media"} +

+

+ {isSimilarResults + ? "Comparing visual embeddings" + : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 + ? "Semantic search can take a little longer than filename search" + : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 + ? "Matching against AI and user tags" + : "Fetching results"} +

+
+
+ ) : images.length === 0 && !loadingImages ? ( +
+
+ + + +

+ {imageLoadError + ? "Could not load results" + : isSimilarResults + ? "No similar images found" + : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 + ? "No semantic matches found" + : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 + ? "No tag matches found" + : "No media found"} +

+

+ {imageLoadError + ? imageLoadError + : isSimilarResults + ? "This item may be visually isolated, or more embeddings may need to finish processing" + : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 + ? "Try a broader phrase, or wait for more embeddings to finish processing" + : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 + ? "Try a shorter tag, or wait for more tagging jobs to finish" + : "Try adjusting your filters or add a new folder"} +

+
+
+ ) : ( +
+ {images.map((image) => ( + openImage(image)} + onContextMenu={(event) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, image }); + }} + /> + ))} +
+ )} - {loadingImages ? ( + {images.length > 0 && loadingImages ? (
diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 2af2a0b..b357d0f 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -64,6 +64,7 @@ export function Lightbox() { const images = useGalleryStore((state) => state.images); const openImage = useGalleryStore((state) => state.openImage); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); + const similarScope = useGalleryStore((state) => state.similarScope); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const getImageTags = useGalleryStore((state) => state.getImageTags); const addUserTag = useGalleryStore((state) => state.addUserTag); @@ -246,7 +247,7 @@ export function Lightbox() { }`} onClick={() => { if (!canFindSimilar) return; - void loadSimilarImages(selectedImage.id); + void loadSimilarImages(selectedImage.id, similarScope === "current_folder" ? selectedImage.folder_id : null, true, selectedImage.folder_id); }} disabled={!canFindSimilar} > diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index cdf9ddd..d328ac6 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,37 +1,87 @@ -import { useEffect, useState } from "react"; -import { TaggerAcceleration, useGalleryStore } from "../store"; +import { useEffect, useMemo, useState } from "react"; +import { TaggerAcceleration, TaggingQueueScope, useGalleryStore } from "../store"; -type SettingsSection = "tagging" | "library" | "display" | "storage"; +type SettingsSection = "workspace" | "workers"; const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [ - { id: "tagging", label: "AI Tagging", detail: "WD tagger model" }, - { id: "library", label: "Library", detail: "Indexing and scanning" }, - { id: "display", label: "Display", detail: "Gallery preferences" }, - { id: "storage", label: "Storage", detail: "Cache and model files" }, + { id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" }, + { id: "workers", label: "Workers", detail: "Queue activity and background processing" }, ]; - function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) { const className = tone === "ready" ? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" : tone === "busy" - ? "border-sky-400/25 bg-sky-500/10 text-sky-300" - : "border-white/10 bg-white/[0.04] text-gray-500"; + ? "border-sky-400/25 bg-sky-500/10 text-sky-300" + : "border-white/10 bg-white/[0.04] text-gray-500"; + return {children}; +} + +function SectionShell({ eyebrow, title, description, children }: { + eyebrow: string; + title: string; + description?: string; + children: React.ReactNode; +}) { return ( - - {children} - +
+

{eyebrow}

+

{title}

+ {description ?

{description}

: null} +
{children}
+
); } -function TaggerAccelerationButton({ - acceleration, - current, - onSelect, - children, -}: { +function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+
{children}
+
+ ); +} + +function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +} + +function ScopeButton({ scope, current, onSelect, children }: { + scope: TaggingQueueScope; + current: TaggingQueueScope; + onSelect: (scope: TaggingQueueScope) => void; + children: React.ReactNode; +}) { + const active = scope === current; + return ( + + ); +} + +function TaggerAccelerationButton({ acceleration, current, onSelect, children }: { acceleration: TaggerAcceleration; current: TaggerAcceleration; onSelect: (acceleration: TaggerAcceleration) => void; @@ -53,63 +103,32 @@ function TaggerAccelerationButton({ ); } - -function SettingsRow({ - title, - description, - children, -}: { - title: string; - description: string; - children: React.ReactNode; -}) { - return ( -
-
-

{title}

-

{description}

-
-
{children}
-
- ); -} - -function SectionShell({ - eyebrow, - title, - children, -}: { - eyebrow: string; - title: string; - children: React.ReactNode; -}) { - return ( -
-

{eyebrow}

-

{title}

-
{children}
-
- ); -} - export function SettingsModal() { - const [activeSection, setActiveSection] = useState("tagging"); + const [activeSection, setActiveSection] = useState("workspace"); const [taggerQueueStatus, setTaggerQueueStatus] = useState(null); const [taggerQueueing, setTaggerQueueing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); const [taggerThresholdDraft, setTaggerThresholdDraft] = useState(null); const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false); + const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState(null); + const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false); const settingsOpen = useGalleryStore((state) => state.settingsOpen); const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen); - const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const folders = useGalleryStore((state) => state.folders); + const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); + const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope); + const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds); + const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope); + const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder); + const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds); const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing); const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress); const taggerModelError = useGalleryStore((state) => state.taggerModelError); const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration); const taggerThreshold = useGalleryStore((state) => state.taggerThreshold); + const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize); const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe); const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking); const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus); @@ -119,49 +138,123 @@ export function SettingsModal() { const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration); const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold); const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold); + const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize); + const setTaggerBatchSize = useGalleryStore((state) => state.setTaggerBatchSize); const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime); const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); + const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders); const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); + const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders); useEffect(() => { if (!settingsOpen) return; void loadTaggerModelStatus(); void loadTaggerAcceleration(); void loadTaggerThreshold(); + void loadTaggerBatchSize(); + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setSettingsOpen(false); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, setSettingsOpen]); + }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, setSettingsOpen]); + + const selectedFolders = useMemo( + () => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)), + [folders, taggingQueueFolderIds], + ); + + const totalQueuedJobs = useMemo( + () => Object.values(mediaJobProgress).reduce((sum, progress) => sum + (progress?.tagging_pending ?? 0), 0), + [mediaJobProgress], + ); if (!settingsOpen) return null; - const selectedFolder = folders.find((folder) => folder.id === selectedFolderId); - const scopeLabel = selectedFolder ? selectedFolder.name : "all libraries"; + const taggerReady = taggerModelStatus?.ready ?? false; + const queueScopeLabel = + taggingQueueScope === "all" + ? "all media" + : selectedFolders.length > 0 + ? `${selectedFolders.length} selected folder${selectedFolders.length === 1 ? "" : "s"}` + : "no folders selected"; + const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold); + const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize); + const taggerDownloadLabel = taggerModelProgress + ? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}` + : taggerModelPreparing + ? "Preparing WD Tagger..." + : taggerReady + ? "Installed" + : "Install model"; + const taggerDownloadPercent = taggerModelProgress + ? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100) + : 0; + + const runQueueAction = (action: "queue" | "clear") => { + const selectedIds = taggingQueueFolderIds; + const perform = + taggingQueueScope === "all" + ? action === "queue" + ? queueTaggingJobs(null) + : clearTaggingJobs(null) + : selectedIds.length > 0 + ? action === "queue" + ? queueTaggingJobsForFolders(selectedIds) + : clearTaggingJobsForFolders(selectedIds) + : Promise.resolve(0); + + if (action === "queue") { + setTaggerQueueing(true); + } else { + setTaggerClearing(true); + } + setTaggerQueueStatus(null); + + void perform + .then((count) => { + if (taggingQueueScope === "selected" && selectedIds.length === 0) { + setTaggerQueueStatus("Choose at least one folder before running tagging jobs."); + return; + } + setTaggerQueueStatus( + count === 0 + ? action === "queue" + ? "No missing tags found for the current target." + : "No queued tagging jobs to clear for the current target." + : action === "queue" + ? `Queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for tagging.` + : `Cleared ${count.toLocaleString()} queued tagging job${count === 1 ? "" : "s"}.`, + ); + }) + .catch((error) => setTaggerQueueStatus(String(error))) + .finally(() => { + if (action === "queue") { + setTaggerQueueing(false); + } else { + setTaggerClearing(false); + } + }); + }; return ( -
setSettingsOpen(false)} - > +
setSettingsOpen(false)}>
event.stopPropagation()} > -
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4a4a33f..042b769 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -138,6 +138,23 @@ export function Sidebar() { Explore
+ +
setView("duplicates")} + > + + + + + Duplicates + +
{/* Section label */} diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index 7c6d3f6..43d2fcc 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -1,243 +1,375 @@ -import { useEffect } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { motion } from "framer-motion"; import { convertFileSrc } from "@tauri-apps/api/core"; -import { useGalleryStore, TagCloudEntry } from "../store"; +import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; -// Accent glow colours for the hover ring — cycled by index -const GLOWS: string[] = [ - "rgba(59,130,246,0.5)", - "rgba(168,85,247,0.5)", - "rgba(16,185,129,0.5)", - "rgba(245,158,11,0.5)", - "rgba(236,72,153,0.5)", - "rgba(6,182,212,0.5)", - "rgba(249,115,22,0.5)", - "rgba(34,197,94,0.5)", +const ACCENTS = [ + "#60a5fa", + "#c084fc", + "#4ade80", + "#fbbf24", + "#f472b4", + "#2dd4bf", + "#fb923c", + "#a78bfa", + "#34d399", + "#f87171", ]; -function pseudoRandom(seed: number): number { - const x = Math.sin(seed + 1) * 10000; +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); + +function seeded(n: number): number { + const x = Math.sin(n * 9301 + 49297) * 233280; return x - Math.floor(x); } -// Map cluster size to a tile size bucket (px) -function getTileSize(count: number, maxCount: number): number { - if (maxCount === 0) return 72; - const ratio = count / maxCount; - if (ratio > 0.75) return 160; - if (ratio > 0.45) return 128; - if (ratio > 0.22) return 104; - if (ratio > 0.08) return 88; - return 72; -} - -function TagButton({ - entry, - index, - maxCount, - onSearch, -}: { +interface PlacedNode { entry: TagCloudEntry; index: number; - maxCount: number; - onSearch: (imageId: number) => void; -}) { - const size = getTileSize(entry.count, maxCount); - const glow = GLOWS[index % GLOWS.length]; + x: number; + y: number; + w: number; + h: number; + accent: string; + driftX: number; + driftY: number; + driftDuration: number; + rotateSeed: number; +} - // Small random rotation for organic feel — larger tiles stay flatter - const maxRot = size >= 128 ? 0 : size >= 104 ? 3 : size >= 88 ? 6 : 10; - const rotation = (pseudoRandom(index * 7) - 0.5) * 2 * maxRot; +function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: number): PlacedNode[] { + if (!entries.length || containerW <= 0 || containerH <= 0) return []; - const mt = Math.floor(pseudoRandom(index * 3) * 10) + 4; - const mr = Math.floor(pseudoRandom(index * 5) * 12) + 4; - const mb = Math.floor(pseudoRandom(index * 11) * 10) + 4; - const ml = Math.floor(pseudoRandom(index * 13) * 12) + 4; + const maxCount = Math.max(...entries.map((e) => e.count)); + const cx = containerW / 2; + const cy = containerH / 2; + // Spread ellipse shrinks slightly to leave room for card half-widths at the edges + const spreadX = containerW * 0.42; + const spreadY = containerH * 0.36; + const n = entries.length; - const src = entry.thumbnail_path ? convertFileSrc(entry.thumbnail_path) : null; + // 1. Build initial positions using phyllotaxis spiral + const nodes: PlacedNode[] = entries.map((entry, i) => { + const ratio = Math.max(entry.count / maxCount, 0.08); + // Cards scale from 110px to 230px wide; height is 3/4 of width + const w = 110 + Math.sqrt(ratio) * 120; + const h = w * 0.75; + const radialRatio = Math.sqrt((i + 0.5) / n); + const angle = i * GOLDEN_ANGLE; + + return { + entry, + index: i, + x: cx + Math.cos(angle) * radialRatio * spreadX, + y: cy + Math.sin(angle) * radialRatio * spreadY, + w, + h, + accent: ACCENTS[i % ACCENTS.length], + driftX: (seeded(i + 11) - 0.5) * 18, + driftY: (seeded(i + 17) - 0.5) * 14, + driftDuration: 8 + seeded(i + 23) * 7, + rotateSeed: (seeded(i + 31) - 0.5) * 4, + }; + }); + + // 2. Iterative overlap resolution — no physics, just push apart + const PAD = 24; + for (let iter = 0; iter < 80; iter++) { + for (let a = 0; a < nodes.length; a++) { + const na = nodes[a]; + for (let b = a + 1; b < nodes.length; b++) { + const nb = nodes[b]; + const dx = nb.x - na.x; + const dy = nb.y - na.y; + const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx); + const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy); + if (overlapX <= 0 || overlapY <= 0) continue; + // Push along the smaller overlap axis + if (overlapX < overlapY) { + const push = overlapX * 0.5 * (dx >= 0 ? 1 : -1); + nb.x += push; + na.x -= push; + } else { + const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1); + nb.y += push; + na.y -= push; + } + } + // Pull gently back toward anchor to prevent runaway drift + na.x += (cx + Math.cos(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadX - na.x) * 0.05; + na.y += (cy + Math.sin(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadY - na.y) * 0.05; + } + } + + // 3. Clamp so cards never poke outside the container + return nodes.map((node) => ({ + ...node, + x: Math.min(Math.max(node.x, node.w / 2 + 16), containerW - node.w / 2 - 16), + y: Math.min(Math.max(node.y, node.h / 2 + 16), containerH - node.h / 2 - 16), + })); +} + +function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: number[]) => void }) { + const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null; + const { w, h, accent } = node; return ( onSearch(entry.representative_image_id)} - title={`${entry.count} similar ${entry.count === 1 ? "photo" : "photos"}`} - style={{ - width: size, - height: size, - margin: `${mt}px ${mr}px ${mb}px ${ml}px`, - borderRadius: 12, - border: "2px solid rgba(255,255,255,0.08)", - background: "rgba(255,255,255,0.04)", - cursor: "pointer", - padding: 0, - overflow: "hidden", - position: "relative", - flexShrink: 0, - boxShadow: "none", - transition: "border-color 0.15s, box-shadow 0.15s", - }} - onMouseEnter={(e) => { - const el = e.currentTarget; - el.style.borderColor = glow; - el.style.boxShadow = `0 0 16px ${glow}, 0 0 32px ${glow.replace("0.5", "0.25")}`; - }} - onMouseLeave={(e) => { - const el = e.currentTarget; - el.style.borderColor = "rgba(255,255,255,0.08)"; - el.style.boxShadow = "none"; + opacity: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) }, + scale: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) }, + x: { duration: node.driftDuration, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 41) * 3 }, + y: { duration: node.driftDuration + 1.6, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 51) * 3 }, + rotate: { duration: node.driftDuration + 0.9, repeat: Infinity, ease: "easeInOut" }, }} + whileHover={{ scale: 1.06, rotate: 0, transition: { duration: 0.18 } }} + onClick={() => onOpen(node.entry.image_ids)} + title={`Open cluster — ${node.entry.count.toLocaleString()} images`} > {src ? ( ) : ( - // Fallback placeholder when no thumbnail exists yet -
+
)} - - {/* Count badge — bottom-right corner */} +
+ {/* Accent glow on hover */}
- {entry.count} + className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" + style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }} + /> +
+
+
+
+

Cluster

+

{node.entry.count.toLocaleString()}

+
+ + Open + +
); } +// Actual tag cloud — word size driven by log-scaled frequency +function TagWord({ + entry, + index, + logMin, + logRange, + onSearch, +}: { + entry: ExploreTagEntry; + index: number; + logMin: number; + logRange: number; + onSearch: (tag: string) => void; +}) { + const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5; + const fontSize = 11 + ratio * 28; // 11px – 39px + const accent = ACCENTS[index % ACCENTS.length]; + const tilt = (seeded(index + 5) - 0.5) * 7; + + return ( + onSearch(entry.tag)} + title={`${entry.tag} — ${entry.count.toLocaleString()} images`} + > + 0.55 ? accent : "rgba(255,255,255,0.82)" }} + > + {entry.tag} + + + {entry.count.toLocaleString()} + + + ); +} + +function Spinner() { + return ( + + ); +} + +// Separate component so its useLayoutEffect fires when the canvas is actually +// mounted — not at TagCloud mount time when the container may still be hidden +// behind a loading state. +function ClusterCloud({ + entries, + onOpen, +}: { + entries: TagCloudEntry[]; + onOpen: (imageIds: number[]) => void; +}) { + const canvasRef = useRef(null); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + + useLayoutEffect(() => { + const el = canvasRef.current; + if (!el) return; + const update = () => { + const r = el.getBoundingClientRect(); + setCanvasSize({ w: r.width, h: r.height }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const nodes = useMemo( + () => buildCloud(entries, canvasSize.w, canvasSize.h), + [entries, canvasSize.w, canvasSize.h], + ); + + return ( +
+
+ {nodes.map((node) => ( + + ))} +
+ ); +} + export function TagCloud() { + const exploreMode = useGalleryStore((state) => state.exploreMode); + const setExploreMode = useGalleryStore((state) => state.setExploreMode); const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries); const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading); const loadTagCloud = useGalleryStore((state) => state.loadTagCloud); - const searchByTag = useGalleryStore((state) => state.searchByTag); + const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries); + const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading); + const loadExploreTags = useGalleryStore((state) => state.loadExploreTags); + const showVisualCluster = useGalleryStore((state) => state.showVisualCluster); + const searchForTag = useGalleryStore((state) => state.searchForTag); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); useEffect(() => { - void loadTagCloud(); - }, [selectedFolderId]); + if (exploreMode === "visual") void loadTagCloud(); + else void loadExploreTags(); + }, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]); - const maxCount = - tagCloudEntries.length > 0 - ? Math.max(...tagCloudEntries.map((e) => e.count)) - : 1; + const { logMin, logRange } = useMemo(() => { + if (!exploreTagEntries.length) return { logMin: 0, logRange: 1 }; + const logs = exploreTagEntries.map((e) => Math.log(Math.max(e.count, 1))); + const lo = Math.min(...logs); + const hi = Math.max(...logs); + return { logMin: lo, logRange: hi - lo || 1 }; + }, [exploreTagEntries]); + + const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading; + const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0; + const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length; return ( -
+
{/* Header */} - -

- Explore your library -

-

- Visual clusters from your photos — sized by how many match -

-
- - {/* Loading */} - {tagCloudLoading && ( -
- - - - -

Clustering your library…

+
+
+
+

Explore

+

+ {loading + ? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…" + : hasEntries + ? exploreMode === "visual" + ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` + : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` + : exploreMode === "visual" + ? "No clusters — images need embeddings first" + : "No tags — run the AI tagger or add tags manually"} +

+
+
+ + +
- )} +
- {/* Empty state */} - {!tagCloudLoading && tagCloudEntries.length === 0 && ( -
-

- No embeddings yet. Add a folder and wait for the embedding worker to - finish, then come back here. + {loading ? ( +

+ + {exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"} +
+ ) : !hasEntries ? ( +
+

+ {exploreMode === "visual" + ? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar." + : "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."}

- )} - - {/* Cluster grid */} - {!tagCloudLoading && tagCloudEntries.length > 0 && ( -
- {tagCloudEntries.map((entry, index) => ( - - ))} + ) : exploreMode === "visual" ? ( + + ) : ( + /* Tag cloud — words sized by log-scaled frequency, wrapped freely */ +
+
+ {exploreTagEntries.map((entry, index) => ( + + ))} +
)} - - {!tagCloudLoading && tagCloudEntries.length > 0 && ( -

- Grouped by visual similarity · CLIP ViT-B/32 -

- )}
); } diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 88cedee..b0514f4 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,11 +1,14 @@ import { useEffect, useRef, useState } from "react"; -import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchMode } from "../store"; +import { invoke } from "@tauri-apps/api/core"; +import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "date_desc", label: "Newest first" }, { value: "date_asc", label: "Oldest first" }, { value: "name_asc", label: "Name A–Z" }, { value: "name_desc", label: "Name Z–A" }, + { value: "rating_desc", label: "Highest rated" }, + { value: "rating_asc", label: "Lowest rated" }, { value: "size_desc", label: "Largest first" }, { value: "size_asc", label: "Smallest first" }, ]; @@ -116,12 +119,27 @@ function FilterPill({ ); } +function commandPrefix(command: SearchCommand | null): string | null { + switch (command) { + case "semantic": + return "/s"; + case "tag": + return "/t"; + default: + return null; + } +} + +function composeSearchValue(command: SearchCommand | null, query: string): string { + const prefix = commandPrefix(command); + if (!prefix) return query; + return query.length > 0 ? `${prefix} ${query}` : prefix; +} + export function Toolbar() { const search = useGalleryStore((state) => state.search); const setSearch = useGalleryStore((state) => state.setSearch); const clearSearch = useGalleryStore((state) => state.clearSearch); - const searchMode = useGalleryStore((state) => state.searchMode); - const setSearchMode = useGalleryStore((state) => state.setSearchMode); const sort = useGalleryStore((state) => state.sort); const setSort = useGalleryStore((state) => state.setSort); const totalImages = useGalleryStore((state) => state.totalImages); @@ -133,20 +151,26 @@ export function Toolbar() { const setMediaFilter = useGalleryStore((state) => state.setMediaFilter); const favoritesOnly = useGalleryStore((state) => state.favoritesOnly); const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly); + const minimumRating = useGalleryStore((state) => state.minimumRating); + const setMinimumRating = useGalleryStore((state) => state.setMinimumRating); const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly); const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly); + const similarScope = useGalleryStore((state) => state.similarScope); + const setSimilarScope = useGalleryStore((state) => state.setSimilarScope); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const zoomPreset = useGalleryStore((state) => state.zoomPreset); const setZoomPreset = useGalleryStore((state) => state.setZoomPreset); const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0); - const [searchValue, setSearchValue] = useState(search); + const [searchCommand, setSearchCommand] = useState(null); + const [searchQuery, setSearchQuery] = useState(search); + const [searchPanelOpen, setSearchPanelOpen] = useState(false); + const [tagSuggestions, setTagSuggestions] = useState([]); const debounceRef = useRef | null>(null); + const suggestDebounceRef = useRef | null>(null); const searchInputRef = useRef(null); - // Tracks whether the user has typed in the search box at least once. - // Prevents the debounce effect from dispatching setSearch on initial mount - // when searchValue === search (which would wipe a loadSimilarImages result). + const searchShellRef = useRef(null); const userHasTyped = useRef(false); const selectedFolder = folders.find((folder) => folder.id === selectedFolderId); @@ -154,11 +178,8 @@ export function Toolbar() { const tileSize = tileSizeForZoom(zoomPreset); const sortOptions = getSortOptions(mediaFilter); const hasActiveSearch = search.trim().length > 0; - - const searchModes: { value: SearchMode; label: string }[] = [ - { value: "filename", label: "Filename" }, - { value: "semantic", label: "Semantic" }, - ]; + const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery)); + const isSimilarResults = collectionTitle === "Similar Images"; // If current sort is video-only but we switched away from video filter, reset to date_desc useEffect(() => { @@ -170,35 +191,62 @@ export function Toolbar() { useEffect(() => { if (!userHasTyped.current) return; if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { setSearch(searchValue); }, 200); + debounceRef.current = setTimeout(() => { setSearch(composeSearchValue(searchCommand, searchQuery)); }, 200); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [searchValue, setSearch]); + }, [searchCommand, searchQuery, setSearch]); useEffect(() => { - setSearchValue(search); + const parsed = parseSearchValue(search); + setSearchCommand(parsed.prefix && parsed.mode !== "filename" ? parsed.mode : null); + setSearchQuery(parsed.prefix ? parsed.query : search); }, [search]); + // Fetch tag suggestions when in tag mode + useEffect(() => { + if (searchCommand !== "tag") { + setTagSuggestions([]); + return; + } + if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); + suggestDebounceRef.current = setTimeout(async () => { + try { + const results = await invoke("search_tags_autocomplete", { + params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 }, + }); + setTagSuggestions(results); + } catch { + setTagSuggestions([]); + } + }, 120); + return () => { if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); }; + }, [searchCommand, searchQuery, selectedFolderId]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - const isModeToggle = (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "s"; - if (isModeToggle) { - event.preventDefault(); - setSearchMode(searchMode === "semantic" ? "filename" : "semantic"); - searchInputRef.current?.focus(); - return; - } - const activeElement = document.activeElement; const searchFocused = activeElement === searchInputRef.current; if (event.key === "Escape" && (searchFocused || hasActiveSearch)) { event.preventDefault(); - setSearchValue(""); + setSearchCommand(null); + setSearchQuery(""); clearSearch(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [clearSearch, hasActiveSearch, searchMode, setSearchMode]); + }, [clearSearch, hasActiveSearch]); + + useEffect(() => { + const close = (event: PointerEvent) => { + if (searchShellRef.current?.contains(event.target as Node)) return; + setSearchPanelOpen(false); + }; + window.addEventListener("pointerdown", close); + return () => window.removeEventListener("pointerdown", close); + }, []); + + const showTagSuggestions = searchCommand === "tag" && searchPanelOpen; + const showCommandHints = !searchCommand && searchPanelOpen; return (
@@ -212,9 +260,9 @@ export function Toolbar() { ? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}` : totalImages.toLocaleString()} - {(hasActiveSearch || searchMode === "semantic") && ( + {hasActiveSearch && ( - {searchMode === "semantic" ? "Semantic Search" : "Filename Search"} + {searchModeLabel(parsedSearch.mode)} )}
@@ -222,55 +270,140 @@ export function Toolbar() {
{/* Search */} -
-
- {searchModes.map((mode) => ( - - ))} -
-
- - - - { - userHasTyped.current = true; - setSearchValue(event.target.value); - }} - placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."} - className="w-64 bg-transparent py-1.5 pl-8 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors" - /> - {searchValue.trim().length > 0 && ( - - )} + onKeyDown={(event) => { + if (event.key === "Backspace" && searchQuery.length === 0 && searchCommand !== null) { + event.preventDefault(); + setSearchCommand(null); + } + }} + onFocus={() => setSearchPanelOpen(true)} + placeholder="Search files, or use /s /t" + className={`w-64 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors ${searchCommand !== null ? "pl-16" : "pl-8"}`} + /> + {searchCommand !== null ? ( +
+ +
+ ) : null} + {searchQuery.trim().length > 0 || searchCommand !== null ? ( + + ) : null} +
+ + {/* Tag autocomplete suggestions */} + {showTagSuggestions && tagSuggestions.length > 0 ? ( +
+ {tagSuggestions.map((entry) => ( + + ))} +
+ ) : null} + + {/* Tag mode with no suggestions yet — show a brief hint */} + {showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? ( +
+

No matching tags

+
+ ) : null} + + {/* Semantic mode hint */} + {searchCommand === "semantic" && searchPanelOpen ? ( +
+

Search by meaning and visual concepts

+
+ ) : null} + + {/* Command hints — only shown when no command is active */} + {showCommandHints ? ( +
+ {( + [ + { command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search AI and user tags" }, + { command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning" }, + ] as const + ).map((option) => ( + + ))} +
+ ) : null}
{/* Sort */} @@ -302,10 +435,14 @@ export function Toolbar() { {/* Filter row */}
- { setMediaFilter("all"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> + { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} /> { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} /> + { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} /> + { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} /> + setSimilarScope("current_folder")} /> + setSimilarScope("all_media")} /> {hasAnyFailedEmbeddings ? ( setFailedEmbeddingsOnly(!failedEmbeddingsOnly)} /> ) : null} + {isSimilarResults ? Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"} : null}
); diff --git a/src/store.ts b/src/store.ts index 32ae1fc..ad02181 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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; mediaJobProgress: Record; 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; + duplicateLastScanned: number | null; // Unix timestamp (seconds) + loadFolders: () => Promise; loadBackgroundJobProgress: () => Promise; addFolder: (path: string) => Promise; @@ -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; - searchByTag: (imageId: number) => void; - loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean) => Promise; + loadExploreTags: () => Promise; + showVisualCluster: (imageIds: number[]) => Promise; + searchForTag: (tag: string) => void; + loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null) => Promise; + setSimilarScope: (scope: SimilarScope) => void; suggestImageTags: (imageId: number) => Promise; loadCaptionModelStatus: () => Promise; prepareCaptionModel: () => Promise; @@ -268,6 +323,9 @@ interface GalleryState { setCaptionDetail: (detail: CaptionDetail) => Promise; setAiCaptionsEnabled: (enabled: boolean) => void; setSettingsOpen: (open: boolean) => void; + setTaggingQueueScope: (scope: TaggingQueueScope) => void; + toggleTaggingQueueFolder: (folderId: number) => void; + setTaggingQueueFolderIds: (folderIds: number[]) => void; retryFailedEmbeddings: (folderId: number) => Promise; updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise; setCacheDir: (dir: string) => void; @@ -280,10 +338,21 @@ interface GalleryState { setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise; loadTaggerThreshold: () => Promise; setTaggerThreshold: (threshold: number) => Promise; + loadTaggerBatchSize: () => Promise; + setTaggerBatchSize: (batchSize: number) => Promise; probeTaggerRuntime: () => Promise; queueTaggingJobs: (folderId?: number | null) => Promise; + queueTaggingJobsForFolders: (folderIds: number[]) => Promise; queueTaggingForImage: (imageId: number) => Promise; clearTaggingJobs: (folderId?: number | null) => Promise; + clearTaggingJobsForFolders: (folderIds: number[]) => Promise; + loadDuplicateScanCache: (folderId?: number | null) => Promise; + scanDuplicates: (folderId?: number | null) => Promise; + toggleDuplicateSelected: (imageId: number) => void; + selectAllDuplicates: (imageIds: number[]) => void; + selectKeepFirstAllGroups: () => void; + clearDuplicateSelection: () => void; + deleteSelectedDuplicates: () => Promise; getImageTags: (imageId: number) => Promise; addUserTag: (imageId: number, tag: string) => Promise; removeTag: (tagId: number) => Promise; @@ -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((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((set, get) => ({ captionDetail: "paragraph", aiCaptionsEnabled: initialAiCaptionsEnabled(), settingsOpen: false, + taggingQueueScope: "all", + taggingQueueFolderIds: [], taggerModelStatus: null, taggerModelPreparing: false, @@ -455,14 +596,33 @@ export const useGalleryStore = create((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("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((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("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("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((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((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((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((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((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("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("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("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("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("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((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("suggest_image_tags", { params: { image_id: imageId, limit: 2 }, @@ -833,6 +1138,27 @@ export const useGalleryStore = create((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("get_tagger_model_status"); @@ -874,6 +1200,22 @@ export const useGalleryStore = create((set, get) => ({ set({ taggerThreshold }); }, + loadTaggerBatchSize: async () => { + try { + const taggerBatchSize = await invoke("get_tagger_batch_size"); + set({ taggerBatchSize }); + } catch (error) { + set({ taggerModelError: String(error) }); + } + }, + + setTaggerBatchSize: async (batchSize) => { + const taggerBatchSize = await invoke("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((set, get) => ({ return queued; }, + queueTaggingJobsForFolders: async (folderIds) => { + const queued = await invoke("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("queue_tagging_jobs", { params: { folder_id: null, image_id: imageId }, @@ -928,6 +1278,14 @@ export const useGalleryStore = create((set, get) => ({ return cleared; }, + clearTaggingJobsForFolders: async (folderIds) => { + const cleared = await invoke("clear_tagging_jobs", { + params: { folder_id: null, folder_ids: folderIds }, + }); + await get().loadBackgroundJobProgress(); + return cleared; + }, + getImageTags: async (imageId) => { return invoke("get_image_tags", { params: { image_id: imageId }, @@ -946,6 +1304,73 @@ export const useGalleryStore = create((set, get) => ({ }); }, + loadDuplicateScanCache: async (folderId = null) => { + interface CacheResult { groups: DuplicateGroup[]; scanned_at: number } + const cached = await invoke("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("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(); + 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("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((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((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((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, ),