16 Commits

Author SHA1 Message Date
LyAhn cad3b9c57d chore(release): 0.2.0
github/actions/release GitHub Actions release finished: success
github/actions/ci GitHub Actions CI finished: success
2026-07-11 19:13:58 +01:00
LyAhn e551b15aca ci: support rebuilding a release tag via workflow_dispatch
release.yml gains a required tag input so an existing tag can be rebuilt
without re-pushing it. The tag is checked out via refs/tags/, verified
against package.json and Cargo.toml versions before any toolchain setup,
and Gitea commit statuses are posted against the tag commit rather than
GITHUB_SHA (which is the dispatched branch head on workflow_dispatch).
2026-07-11 19:12:52 +01:00
LyAhn 0b4459365d ci: run unit and Rust tests, tighten triggers and permissions
Expand path triggers to all build inputs (configs, lockfile, index.html,
CHANGELOG.md which feeds the What's New modal), add pnpm test:unit and
cargo test steps, read-only permissions, a 60-minute timeout, and a
workflow-scoped concurrency group.
2026-07-11 19:12:41 +01:00
LyAhn 4aa74d535b fix(ui): fix alignment and sizing of the update pending icon
github/actions/ci GitHub Actions CI finished: success
2026-07-11 12:08:54 +01:00
LyAhn 48df4f2965 style: Format db.rs
github/actions/ci GitHub Actions CI finished: success
2026-07-06 20:53:14 +01:00
LyAhn 2bc4a98164 fix(frontend): derive THEME_OPTIONS from an exhaustive AppTheme record
github/actions/ci GitHub Actions CI finished: failure
The quick theme switcher's option list was a hand-maintained array that
could drift from the AppTheme union. Derive it from a
Record<AppTheme, string> instead, so adding a theme forces this
mapping to be updated too.
2026-07-06 20:42:06 +01:00
LyAhn 6923777345 test(frontend): cover mediaSrc, changelog, and timeline grouping
Unit tests for three previously-untested pure functions: mediaSrc's
UI Lab path handling, getChangelogForVersion's version normalization,
and groupImages' date bucketing/sorting.
2026-07-06 20:41:42 +01:00
LyAhn 96e62cb7c1 perf(frontend): invalidate duplicate-scan cache concurrently and safely
Cache invalidation across affected folders after a bulk delete ran
sequentially through awaited invoke calls, duplicated between
gallerySlice and duplicateSlice. Extract a shared
invalidateDuplicateScanCaches helper that batches the calls with
Promise.allSettled, logging failures instead of throwing — a
best-effort cache refresh should never turn an already-successful
delete into a rejected promise for the caller.
2026-07-06 20:41:16 +01:00
LyAhn 42564a93e0 fix(backend): escape LIKE wildcards and harden curl arg passing
Filename/tag search treated literal % and _ in query text as SQL
wildcards; escape them (with ESCAPE '\') before building LIKE patterns.
Also pass -- before the URL in curl invocations so a URL starting with
a dash can't be misread as an option.
2026-07-06 20:40:37 +01:00
LyAhn b8d009c973 fix(frontend): sync sidebar pause menu with per-worker background task state
github/actions/ci GitHub Actions CI finished: success
Only require a worker to be paused if it currently has pending work for
that folder, instead of demanding all four worker flags be true. The
background tasks panel only lets you toggle stages it shows, so the old
check stayed out of sync after pausing everything visible.

Also grants start-dragging and start-resize-dragging window permissions.
2026-07-06 00:47:13 +01:00
LyAhn dcc1612802 merge: add automated test coverage
github/actions/ci GitHub Actions CI finished: success
Merge the Tauri test feature branch into main.

Adds Vitest coverage for frontend formatting, path, and store helper behavior, plus Rust unit coverage for database, vector, storage, indexer, thumbnail, and AI tag filtering logic. Also wires the new unit test scripts into the package commands and documents the test layers.
2026-07-05 21:26:32 +01:00
LyAhn 2c699a5aac docs(claude): document the new unit-test layers and commands
CLAUDE.md claimed the Playwright smoke tests were the only tests; add
the Vitest and cargo-test commands and describe the three test layers,
including the shared db::test_support fixture for DB-touching Rust
tests.
2026-07-05 21:20:24 +01:00
LyAhn ca5c500e18 test(backend): cover storage, indexer, thumbnail, and tag-filter logic
storage.rs: thumbnail worker clamping across parallelism levels, the
adaptive-profile EMA transitions, and UNC-path fallback detection.
indexer.rs: supported-media extension matching plus media-kind and MIME
mapping. thumbnail.rs: fit_dimensions aspect-ratio math with extreme
ratios and is_jpeg extension checks. ai_tag_filter.rs: padding and
mixed-separator normalization edge cases.
2026-07-05 21:19:50 +01:00
LyAhn 782cf0ea08 test(backend): add in-memory SQLite test harness with db and vector tests
A shared test_support module in db.rs provides the fixture: sqlite-vec
registered via auto-extension, an in-memory connection with foreign keys
on, and both migrations applied - no refactoring of production code
needed since every query function already takes &Connection.

db.rs coverage: folder idempotency, upsert_image update semantics
(favorite/rating preserved, AI tag state invalidated), the get_images
filter matrix with pagination and count_images agreement, tag
merge/rename/delete, user-tag precedence over AI tags in update_ai_tags,
album CRUD with FK cascade, the embedding job queue (backfill, retry,
consistency repair), tag search, and delete_folder cascades.

vector.rs coverage: pack/unpack round-trip, embedding upsert/delete with
dimension validation, and find_similar_image_ids ranking on both the
global KNN and folder-scoped brute-force paths.
2026-07-05 21:19:42 +01:00
LyAhn 5004a2d01a test(frontend): add Vitest unit tests for store helpers and utilities
Wire Vitest into the existing Vite config (scoped to src/**/*.test.ts so it
never collides with the Playwright suite in tests/) and add test:unit,
test:unit:watch, and test:rust scripts.

Covers the pure logic layer: parseSearchValue prefix parsing, all twelve
sort orders through mergeImages, merge/dedup/window helpers, filter
matching, gallery request tokens, localStorage-backed initial settings,
folder-picker path utilities, and the duplicate/lightbox/gallery/video
formatters. Fixture factories live in src/test/factories.ts.
2026-07-05 21:19:31 +01:00
LyAhn 9a282dda86 docs(claude): document UI Lab, Playwright e2e tests, and tooling scripts
CLAUDE.md still claimed no test suites existed. Update it for the
current repo state:

- add pnpm test:e2e commands (full run, single file, single test) and
  note the suite runs against UI Lab mocks, not the Tauri app
- new UI Lab section: src/dev mock layer, ?scenario=/?changelog= params,
  mediaSrc rule, and the mockBackend requirement for new commands
- add dev:ui and format:all to the command list
- note website/ as a separate Vite project and document the
  changelog:add script syntax
2026-07-05 20:48:16 +01:00
33 changed files with 2017 additions and 43 deletions
+34 -1
View File
@@ -4,16 +4,39 @@ on:
push:
branches: [main]
paths:
- '.github/workflows/ci.yml'
# CHANGELOG.md is a build input: src/changelog.ts imports it ?raw for
# the What's New modal, and changelog.test.ts asserts on its content.
- 'CHANGELOG.md'
- 'index.html'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'src/**'
- 'src-tauri/**'
- 'tsconfig*.json'
- 'vite.config.ts'
pull_request:
paths:
- '.github/workflows/ci.yml'
# CHANGELOG.md is a build input: src/changelog.ts imports it ?raw for
# the What's New modal, and changelog.test.ts asserts on its content.
- 'CHANGELOG.md'
- 'index.html'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'src/**'
- 'src-tauri/**'
- 'tsconfig*.json'
- 'vite.config.ts'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
@@ -21,7 +44,9 @@ jobs:
# windows-latest to match the release target — the ML crates (ort, candle)
# and the NSIS bundle only ever ship from Windows.
runs-on: windows-latest
timeout-minutes: 60
env:
CARGO_INCREMENTAL: 0
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
steps:
- name: Report pending status to Gitea
@@ -58,10 +83,14 @@ jobs:
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Unit tests
run: pnpm test:unit
# tsc + vite build; also produces dist/ which tauri's build script expects
- name: Type-check and build frontend
run: pnpm build:vite
@@ -85,6 +114,10 @@ jobs:
working-directory: src-tauri
run: cargo clippy --all-targets --locked --no-default-features -- -D warnings
- name: Rust tests
working-directory: src-tauri
run: cargo test --locked --no-default-features
- name: Report final status to Gitea
if: always() && github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
+50 -5
View File
@@ -8,15 +8,59 @@ on:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Existing release tag to build, for example v0.1.1'
required: true
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
release:
permissions:
contents: write
runs-on: windows-latest
timeout-minutes: 120
env:
CARGO_INCREMENTAL: 0
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
steps:
# refs/tags/ qualification: a branch with a tag-like name must never win
# ref resolution. Works for tag pushes too — RELEASE_TAG is the tag name.
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ env.RELEASE_TAG }}
# On workflow_dispatch GITHUB_SHA is the dispatched branch head, not the
# tag's commit — resolve the real one for Gitea commit statuses.
- name: Resolve release commit
shell: pwsh
run: |
$sha = git rev-parse HEAD
"RELEASE_SHA=$sha" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Verify release version
shell: pwsh
run: |
if ($env:RELEASE_TAG -notmatch '^v\d+\.\d+\.\d+(-.+)?$') {
throw "Release tag '$env:RELEASE_TAG' must look like v0.1.1"
}
$packageVersion = (Get-Content package.json -Raw | ConvertFrom-Json).version
$cargoVersion = (Select-String -Path src-tauri/Cargo.toml -Pattern '^version\s*=\s*"(.+)"').Matches[0].Groups[1].Value
if ($packageVersion -ne $cargoVersion) {
throw "package.json version ($packageVersion) does not match Cargo.toml version ($cargoVersion)"
}
if ($env:RELEASE_TAG -ne "v$packageVersion") {
throw "Release tag '$env:RELEASE_TAG' does not match project version v$packageVersion"
}
- name: Report pending status to Gitea
if: env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
@@ -36,13 +80,11 @@ jobs:
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:RELEASE_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
@@ -51,6 +93,7 @@ jobs:
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -71,7 +114,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: ${{ github.ref_name }}
tagName: ${{ env.RELEASE_TAG }}
releaseName: 'Phokus v__VERSION__'
releaseBody: 'See the assets below to download and install this version.'
releaseDraft: true
@@ -105,9 +148,11 @@ jobs:
description = "GitHub Actions release finished: $env:JOB_STATUS"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
# RELEASE_SHA is unset if the job died before checkout; fall back.
$sha = if ($env:RELEASE_SHA) { $env:RELEASE_SHA } else { $env:GITHUB_SHA }
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$sha" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
+2 -1
View File
@@ -5,7 +5,7 @@ All notable changes to Phokus are documented here. The format is based on
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(0.x: anything may change between minor versions).
## [Unreleased]
## [0.2.0] — 2026-07-11
### Added
@@ -264,5 +264,6 @@ installer with a built-in updater.
Settings, with live size/reclaimable stats.
- **Window state** persistence and single-instance handling.
[0.2.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.2.0
[0.1.1]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.1
[0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0
+34 -1
View File
@@ -15,6 +15,9 @@ pnpm dev:app
# Frontend only (no Tauri window)
pnpm dev:vite
# UI Lab — browser-only frontend with mocked Tauri backend (http://127.0.0.1:1422)
pnpm dev:ui
# Production build (CPU)
pnpm build:app:cpu
@@ -23,11 +26,29 @@ pnpm build:app:cuda
# Type-check frontend
pnpm build:vite
# Frontend unit tests (Vitest; only picks up src/**/*.test.ts)
pnpm test:unit
pnpm test:unit:watch
# Rust unit tests (in-memory SQLite; --no-default-features skips CUDA)
pnpm test:rust
# E2E tests (Playwright against the UI Lab; auto-starts the server)
pnpm test:e2e
pnpm exec playwright test tests/ui-lab.spec.ts # single file
pnpm exec playwright test -g "filename search" # single test by name
# Formatting (Prettier + prettier-plugin-tailwindcss; cargo fmt for Rust)
pnpm format:all
```
Use **pnpm** — never npm.
There are no test suites configured.
Three test layers, none of which exercise the real Tauri window:
- **Vitest unit tests** — co-located `*.test.ts` next to the pure logic they cover (`src/store/helpers.ts`, formatters, path utils); fixture factories in `src/test/factories.ts`.
- **Rust unit tests** — inline `#[cfg(test)]` modules; DB tests run against in-memory SQLite via the shared `db::test_support` fixture (`test_conn()` + `test_image()`), which registers sqlite-vec and applies both migrations. Reuse it for any new DB-touching tests.
- **Playwright e2e smoke tests** in `tests/` — run against the UI Lab (browser mocks).
## Architecture
@@ -42,6 +63,16 @@ There are no test suites configured.
- Virtualized gallery grid: `@tanstack/react-virtual`.
- Animation: `framer-motion`.
### UI Lab (`src/dev/`)
`pnpm dev:ui` runs the real frontend (same `App.tsx`, store, components, CSS) in a plain browser with Tauri fully mocked — no Rust backend or Tauri window. `src/main.tsx` imports `src/dev/setupMockTauri.ts` before `App` in `ui` mode; `mockBackend.ts` implements an in-memory command backend, with fixtures in `mockFixtures.ts`/`mockScenarios.ts`. Pick a seeded state via `?scenario=` (`rich` default; also `empty`, `new-user`, `just-updated`, `busy`, `duplicates`, `album`, `errors`, `huge`) and a What's New entry via `?changelog=`. `Ctrl+Shift+D` opens the demo panel. Full guide: `docs/ui-lab.md`.
Rules that keep UI Lab working:
- Components that render thumbnails/covers/posters must use `mediaSrc(...)` from `src/lib/mediaSrc.ts`, never `convertFileSrc(...)` directly.
- New Tauri commands invoked from the frontend need a mock in `src/dev/mockBackend.ts` (unmocked commands log console errors, which fail the e2e tests).
UI Lab is for visual/layout work and agent browser inspection. Native behavior (file pickers, real thumbnails, window controls, updater) must still be validated in `pnpm dev:app`.
### Search modes
The search bar supports prefix syntax parsed by `parseSearchValue` in `src/store/helpers.ts`:
@@ -98,3 +129,5 @@ Database: SQLite with WAL mode, stored in the Tauri app data directory as `galle
- ML inference crates (`candle-*`, `ort`, `image`, `rayon`, `tokenizers`, `xxhash-rust`, `rusqlite`) use `opt-level = 3` in dev profile to keep inference performance acceptable.
- The caption worker is intentionally disabled (`lib.rs:73`) — the backend code is intact for future re-enabling.
- **Never use `any` type** in TypeScript — look up correct types.
- `website/` is a separate Vite project for the marketing site (phokus.jezz.wtf): `pnpm dev:web` / `pnpm build:web`. It is not part of the app build.
- `pnpm changelog:add -- --type fixed --message "..."` appends an entry to the `[Unreleased]` section of `CHANGELOG.md`, which also feeds the in-app What's New modal (types: added, changed, deprecated, removed, fixed, security).
+85
View File
@@ -0,0 +1,85 @@
# Phokus v0.2.0
> Draft for the GitHub Release body. Fill in the checksums and trim as needed.
**Phokus is a local-first desktop media library for Windows** — point it at
your image and video folders and it builds a fast, searchable gallery with
thumbnails, semantic search, visual discovery, AI tagging, and duplicate
cleanup. Everything is processed on your machine; nothing is uploaded.
0.2.0 is the biggest update since launch: albums, gallery multi-select with
bulk actions, colour search, a second AI tagging model (JoyTag), a full tag
manager, camera EXIF details in the lightbox, a slideshow mode, and a large
batch of performance and theme fixes. Updating from 0.1.x? The built-in
updater will fetch this for you — and afterwards, a new in-app "What's New"
tour shows you around.
## Install / update
- **Updating from 0.1.x:** the in-app updater will offer 0.2.0 on launch — one
click downloads, installs, and relaunches.
- **Fresh install:** download `Phokus_0.2.0_x64-setup.exe` below and run it.
**Windows SmartScreen will warn** that the publisher is unrecognized — this
build is **not code-signed**. Click **More info → Run anyway**.
- Requires **Windows 10/11**. WebView2 is fetched automatically if missing.
NVIDIA users wanting GPU embedding/tagging speed can use the
`Phokus_0.2.0_x64-cuda-setup.exe` variant instead (larger download; bundles the
CUDA runtime DLLs). Settings → Updates now shows which build you are running.
## Highlights
### Added
- **Albums** — cross-folder collections without moving files: their own
sidebar section with cover thumbnails, create/rename/reorder/manage, and
album-aware similar-image search.
- **Gallery multi-select** — hover a thumbnail's corner to start selecting,
then bulk tag, rate, favorite, add to an album, or delete from a floating
action bar. Works in similar-image, region, and album views too.
- **Colour search** — filter the Gallery, Timeline, or tag results by dominant
colour via toolbar swatches or a custom picker.
- **Choose your tagging model** — pick between the anime-focused WD tagger and
**JoyTag** (better for photo libraries), each with its own confidence
threshold, plus a **Reset AI tags** action that never touches manual tags.
- **Tag manager** — rename, merge, and delete tags across the library, with
live search and sorting; Explore's Tag Cloud also shows **related tags** on
hover.
- **Camera info in the lightbox** — EXIF camera, lens, aperture, shutter
speed, ISO, focal length, and a map link for geotagged photos.
- **Slideshow mode**, **What's New tour after updates**, **quick theme switch**
from the title bar, an editable path bar in the folder picker, persistent
per-folder worker pauses, and add-to-album from the right-click menu.
### Changed
- **Settings reorganised** into General, Media, Updates & Setup, Storage, and
AI Workspace pages.
- **Menus unified** — all context menus and dropdowns share one style, stay on
screen, close on Escape, and support submenus.
- **Faster Explore** — quicker cluster revisits, much faster first-time
clustering on large libraries, and a calmer Tag Cloud during active tagging.
- **Faster CPU tagging** (multi-core with headroom) and smoother GPU tagging.
- **Safer deletion** — deleting media now asks for confirmation and says
clearly that files are removed from disk.
- Better narrow-window layouts, a real update download progress bar, and a
two-column lightbox info panel.
### Fixed
- **No more self-indexing loops** — Phokus now skips its own thumbnail cache
when you add a broad folder like your user profile.
- **Ratings keep your search order** — rating an image mid-search no longer
reshuffles similar-image, region, semantic, tag, or album results.
- **AI tagging stays responsive** — big GPU tagging jobs no longer freeze the
UI, and noisy low-signal tags are filtered (older ones cleaned up on
startup).
- First launch now fits smaller screens, Explore no longer flashes the
previous folder, and a long list of Subtle Light readability fixes.
See the [changelog](https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md)
for the full list.
## Checksums
```
SHA-256 (Phokus_0.2.0_x64-setup.exe) = TBD
SHA-256 (Phokus_0.2.0_x64-cuda-setup.exe) = TBD
```
+7 -3
View File
@@ -1,7 +1,7 @@
{
"name": "phokus",
"private": true,
"version": "0.1.1",
"version": "0.2.0",
"license": "MIT",
"type": "module",
"scripts": {
@@ -23,7 +23,10 @@
"format:rust:check": "cd src-tauri && cargo fmt --check",
"preview": "vite preview",
"tauri": "tauri",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"test:rust": "cd src-tauri && cargo test --no-default-features",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.23",
@@ -53,6 +56,7 @@
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.8.3",
"vite": "^7.0.4"
"vite": "^7.0.4",
"vitest": "^4.1.9"
}
}
+242
View File
@@ -87,6 +87,9 @@ importers:
vite:
specifier: ^7.0.4
version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@26.0.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
website:
dependencies:
@@ -705,6 +708,9 @@ packages:
cpu: [x64]
os: [win32]
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tailwindcss/node@4.2.2':
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
@@ -917,9 +923,15 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -940,6 +952,39 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@4.1.9':
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
'@vitest/mocker@4.1.9':
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.9':
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
'@vitest/runner@4.1.9':
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
'@vitest/snapshot@4.1.9':
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
'@vitest/spy@4.1.9':
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
'@vitest/utils@4.1.9':
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
baseline-browser-mapping@2.10.14:
resolution: {integrity: sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==}
engines: {node: '>=6.0.0'}
@@ -953,6 +998,10 @@ packages:
caniuse-lite@1.0.30001785:
resolution: {integrity: sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -995,6 +1044,9 @@ packages:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
es-module-lexer@2.3.0:
resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
@@ -1007,6 +1059,13 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1165,6 +1224,13 @@ packages:
node-releases@2.0.37:
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
obug@2.1.3:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1280,10 +1346,19 @@ packages:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
tailwindcss@4.2.2:
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
@@ -1291,10 +1366,21 @@ packages:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
engines: {node: '>=6'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1358,6 +1444,52 @@ packages:
yaml:
optional: true
vitest@4.1.9:
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.9
'@vitest/browser-preview': 4.1.9
'@vitest/browser-webdriverio': 4.1.9
'@vitest/coverage-istanbul': 4.1.9
'@vitest/coverage-v8': 4.1.9
'@vitest/ui': 4.1.9
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1784,6 +1916,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.1':
optional: true
'@standard-schema/spec@1.1.0': {}
'@tailwindcss/node@4.2.2':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -1954,8 +2088,15 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-force@3.0.10': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@types/node@26.0.1':
@@ -1982,6 +2123,49 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@4.1.9':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.9(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies:
'@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
'@vitest/pretty-format@4.1.9':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.9':
dependencies:
'@vitest/utils': 4.1.9
pathe: 2.0.3
'@vitest/snapshot@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
'@vitest/utils': 4.1.9
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.9': {}
'@vitest/utils@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
assertion-error@2.0.1: {}
baseline-browser-mapping@2.10.14: {}
browserslist@4.28.2:
@@ -1994,6 +2178,8 @@ snapshots:
caniuse-lite@1.0.30001785: {}
chai@6.2.2: {}
convert-source-map@2.0.0: {}
csstype@3.2.3: {}
@@ -2023,6 +2209,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.2
es-module-lexer@2.3.0: {}
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
@@ -2056,6 +2244,12 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
expect-type@1.4.0: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -2158,6 +2352,10 @@ snapshots:
node-releases@2.0.37: {}
obug@2.1.3: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@@ -2259,17 +2457,29 @@ snapshots:
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
stackback@0.0.2: {}
std-env@4.1.0: {}
tailwindcss@4.2.2: {}
tapable@2.3.2: {}
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
tslib@2.8.1: {}
typescript@5.8.3: {}
@@ -2305,6 +2515,38 @@ snapshots:
jiti: 2.6.1
lightningcss: 1.32.0
vitest@4.1.9(@types/node@26.0.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
'@vitest/pretty-format': 4.1.9
'@vitest/runner': 4.1.9
'@vitest/snapshot': 4.1.9
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
es-module-lexer: 2.3.0
expect-type: 1.4.0
magic-string: 0.30.21
obug: 2.1.3
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.4
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.0.1
transitivePeerDependencies:
- msw
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
yallist@3.1.1: {}
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4):
+1 -1
View File
@@ -4595,7 +4595,7 @@ dependencies = [
[[package]]
name = "phokus"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"anyhow",
"candle-core",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "phokus"
version = "0.1.1"
version = "0.2.0"
description = "Local-first desktop media library"
authors = ["JezzWTF"]
license = "MIT"
+6 -2
View File
@@ -2,7 +2,9 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default",
@@ -19,6 +21,8 @@
"core:window:allow-minimize",
"core:window:allow-close",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized"
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging"
]
}
+9
View File
@@ -42,4 +42,13 @@ mod tests {
assert!(!is_removed_ai_tag(tag), "{tag} should be kept");
}
}
#[test]
fn removed_ai_tags_tolerate_padding_and_mixed_separators() {
assert!(is_removed_ai_tag(" 1girl "));
assert!(is_removed_ai_tag("1_-_girl"));
assert!(is_removed_ai_tag("No Humans"));
assert!(!is_removed_ai_tag(""));
assert!(!is_removed_ai_tag(" "));
}
}
+505 -6
View File
@@ -1304,6 +1304,16 @@ fn media_kind_clause(include_videos: bool) -> &'static str {
}
}
/// Escapes `%`, `_`, and `\` so a user-supplied string can be safely embedded
/// in a `LIKE` pattern (paired with `ESCAPE '\\'` in the query) without the
/// wildcards being interpreted literally.
fn escape_like_pattern(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
/// True if any claimable (pending, non-excluded) jobs exist in `job_table`.
/// Used by lower-priority workers to defer to higher-priority queues.
/// `extra_predicate` narrows the image join (e.g. videos only for metadata).
@@ -2127,7 +2137,7 @@ pub fn get_images(
_ => "modified_at DESC NULLS LAST",
};
let search_pattern = search.map(|value| format!("%{value}%"));
let search_pattern = search.map(|value| format!("%{}%", escape_like_pattern(value)));
let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_failed_only);
@@ -2143,7 +2153,7 @@ pub fn get_images(
ai_rating, ai_tagger_model, ai_tagged_at, ai_tagger_error
FROM images
WHERE (?1 IS NULL OR folder_id = ?1)
AND (?2 IS NULL OR filename LIKE ?2)
AND (?2 IS NULL OR filename LIKE ?2 ESCAPE '\\')
AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1)
AND rating >= ?5
@@ -2194,7 +2204,7 @@ pub fn count_images(
tagging_failed_only: bool,
color: Option<(u8, u8, u8)>,
) -> Result<i64> {
let search_pattern = search.map(|value| format!("%{value}%"));
let search_pattern = search.map(|value| format!("%{}%", escape_like_pattern(value)));
let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only);
@@ -2206,7 +2216,7 @@ pub fn count_images(
let count = conn.query_row(
"SELECT COUNT(*) FROM images
WHERE (?1 IS NULL OR folder_id = ?1)
AND (?2 IS NULL OR filename LIKE ?2)
AND (?2 IS NULL OR filename LIKE ?2 ESCAPE '\\')
AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1)
AND rating >= ?5
@@ -2342,7 +2352,7 @@ pub fn search_tags_autocomplete(
folder_id: Option<i64>,
limit: usize,
) -> Result<Vec<ExploreTagEntry>> {
let pattern = format!("%{}%", query.to_lowercase());
let pattern = format!("%{}%", escape_like_pattern(&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,
MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source,
@@ -2350,7 +2360,7 @@ pub fn search_tags_autocomplete(
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
AND LOWER(t.tag) LIKE ?2 ESCAPE '\\'
GROUP BY t.tag
ORDER BY tag_count DESC, t.tag ASC
LIMIT ?3",
@@ -3265,3 +3275,492 @@ fn folder_exclusion_clause(
.join(",");
format!("AND {image_alias}.folder_id NOT IN ({id_list})")
}
/// Shared fixtures for unit tests across modules (db, vector, …).
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
pub(crate) fn test_conn() -> Connection {
vector::register_sqlite_vec();
let conn = Connection::open_in_memory().unwrap();
// The r2d2 pool customizer normally sets this; mirror it so FK cascades
// (album deletion, folder deletion) behave like production.
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
migrate(&conn).unwrap();
vector::migrate(&conn).unwrap();
conn
}
pub(crate) fn test_image(folder_id: i64, path: &str) -> ImageRecord {
ImageRecord {
id: 0,
folder_id,
path: path.to_string(),
filename: path.rsplit('/').next().unwrap_or(path).to_string(),
thumbnail_path: None,
width: Some(100),
height: Some(100),
file_size: 1024,
created_at: None,
modified_at: Some("2026-01-01T00:00:00Z".into()),
taken_at: None,
mime_type: "image/jpeg".into(),
media_kind: "image".into(),
duration_ms: None,
video_codec: None,
audio_codec: None,
metadata_updated_at: None,
metadata_error: None,
favorite: false,
rating: 0,
embedding_status: "pending".into(),
embedding_model: None,
embedding_updated_at: None,
embedding_error: None,
generated_caption: None,
caption_model: None,
caption_updated_at: None,
caption_error: None,
ai_rating: None,
ai_tagger_model: None,
ai_tagged_at: None,
ai_tagger_error: None,
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::{test_conn, test_image};
use super::*;
#[test]
fn escape_like_pattern_escapes_wildcards_and_backslash() {
assert_eq!(escape_like_pattern("50%off"), "50\\%off");
assert_eq!(escape_like_pattern("a_b"), "a\\_b");
assert_eq!(escape_like_pattern(r"C:\images"), r"C:\\images");
assert_eq!(escape_like_pattern("50%_x\\"), "50\\%\\_x\\\\");
assert_eq!(escape_like_pattern("plain text"), "plain text");
}
#[test]
fn insert_folder_is_idempotent_per_path() {
let conn = test_conn();
let first = insert_folder(&conn, "C:/media", "media").unwrap();
let second = insert_folder(&conn, "C:/media", "media again").unwrap();
assert_eq!(first, second);
insert_folder(&conn, "C:/other", "other").unwrap();
let folders = get_folders(&conn).unwrap();
assert_eq!(folders.len(), 2);
assert_eq!(folders[0].name, "media");
}
#[test]
fn upsert_image_updates_in_place_and_preserves_user_state() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/media", "media").unwrap();
let mut img = test_image(folder_id, "C:/media/a.jpg");
let id = upsert_image(&conn, &img).unwrap();
// Simulate user state + AI state on the stored row.
conn.execute(
"UPDATE images SET favorite = 1, rating = 4, ai_tagger_model = 'wd' WHERE id = ?1",
[id],
)
.unwrap();
add_user_tag(&conn, id, "keeper").unwrap();
conn.execute(
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
VALUES (?1, 'cat', 'ai', 'wd', 0.9, datetime('now'))",
[id],
)
.unwrap();
img.file_size = 2048;
let same_id = upsert_image(&conn, &img).unwrap();
assert_eq!(same_id, id);
let stored = get_image_by_id(&conn, id).unwrap();
assert_eq!(stored.file_size, 2048);
// favorite/rating survive re-index; AI tag state is invalidated.
assert!(stored.favorite);
assert_eq!(stored.rating, 4);
assert_eq!(stored.ai_tagger_model, None);
let tags = get_image_tags(&conn, id).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].tag, "keeper");
assert_eq!(tags[0].source, "user");
}
#[test]
fn get_images_applies_filters_and_agrees_with_count() {
let conn = test_conn();
let folder_a = insert_folder(&conn, "C:/a", "a").unwrap();
let folder_b = insert_folder(&conn, "C:/b", "b").unwrap();
upsert_image(&conn, &test_image(folder_a, "C:/a/cat.jpg")).unwrap();
let dog_id = upsert_image(&conn, &test_image(folder_a, "C:/a/dog.jpg")).unwrap();
let mut video = test_image(folder_b, "C:/b/clip.mp4");
video.media_kind = "video".into();
video.embedding_status = "failed".into();
upsert_image(&conn, &video).unwrap();
conn.execute(
"UPDATE images SET favorite = 1, rating = 5 WHERE id = ?1",
[dog_id],
)
.unwrap();
let no_filter = get_images(
&conn, None, None, None, false, 0, false, false, None, "name_asc", 0, 100,
)
.unwrap();
assert_eq!(no_filter.len(), 3);
assert_eq!(
count_images(&conn, None, None, None, false, 0, false, false, None).unwrap(),
3
);
let by_folder = get_images(
&conn,
Some(folder_a),
None,
None,
false,
0,
false,
false,
None,
"name_asc",
0,
100,
)
.unwrap();
assert_eq!(by_folder.len(), 2);
let by_search = get_images(
&conn,
None,
Some("cat"),
None,
false,
0,
false,
false,
None,
"name_asc",
0,
100,
)
.unwrap();
assert_eq!(by_search.len(), 1);
assert_eq!(by_search[0].filename, "cat.jpg");
let videos = get_images(
&conn,
None,
None,
Some("video"),
false,
0,
false,
false,
None,
"name_asc",
0,
100,
)
.unwrap();
assert_eq!(videos.len(), 1);
let favorites = get_images(
&conn, None, None, None, true, 0, false, false, None, "name_asc", 0, 100,
)
.unwrap();
assert_eq!(favorites.len(), 1);
assert_eq!(favorites[0].filename, "dog.jpg");
let rated = get_images(
&conn, None, None, None, false, 3, false, false, None, "name_asc", 0, 100,
)
.unwrap();
assert_eq!(rated.len(), 1);
let failed = get_images(
&conn, None, None, None, false, 0, true, false, None, "name_asc", 0, 100,
)
.unwrap();
assert_eq!(failed.len(), 1);
assert_eq!(failed[0].filename, "clip.mp4");
// Pagination: page size 2 then the remaining 1.
let page_one = get_images(
&conn, None, None, None, false, 0, false, false, None, "name_asc", 0, 2,
)
.unwrap();
let page_two = get_images(
&conn, None, None, None, false, 0, false, false, None, "name_asc", 2, 2,
)
.unwrap();
assert_eq!(page_one.len(), 2);
assert_eq!(page_two.len(), 1);
assert_eq!(page_one[0].filename, "cat.jpg");
assert_eq!(page_two[0].filename, "dog.jpg");
}
#[test]
fn user_tags_upgrade_ai_tags_and_survive_tag_maintenance() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
conn.execute(
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
VALUES (?1, 'cat', 'ai', 'wd', 0.9, datetime('now'))",
[id],
)
.unwrap();
let upgraded = add_user_tag(&conn, id, "cat").unwrap();
assert_eq!(upgraded.source, "user");
assert_eq!(upgraded.ai_model, None);
assert_eq!(get_image_tags(&conn, id).unwrap().len(), 1);
let tag = add_user_tag(&conn, id, "kitty").unwrap();
remove_tag(&conn, tag.id).unwrap();
assert_eq!(get_image_tags(&conn, id).unwrap().len(), 1);
}
#[test]
fn rename_tag_merges_into_existing_tag() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let with_both = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
let with_old = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap();
add_user_tag(&conn, with_both, "cat").unwrap();
add_user_tag(&conn, with_both, "kitty").unwrap();
add_user_tag(&conn, with_old, "kitty").unwrap();
rename_tag(&conn, "kitty", "cat").unwrap();
let both_tags = get_image_tags(&conn, with_both).unwrap();
assert_eq!(both_tags.len(), 1);
assert_eq!(both_tags[0].tag, "cat");
let old_tags = get_image_tags(&conn, with_old).unwrap();
assert_eq!(old_tags.len(), 1);
assert_eq!(old_tags[0].tag, "cat");
}
#[test]
fn delete_tag_removes_it_everywhere_and_reports_count() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let first = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
let second = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap();
add_user_tag(&conn, first, "cat").unwrap();
add_user_tag(&conn, second, "cat").unwrap();
add_user_tag(&conn, second, "dog").unwrap();
assert_eq!(delete_tag(&conn, "cat").unwrap(), 2);
assert_eq!(get_image_tags(&conn, first).unwrap().len(), 0);
assert_eq!(get_image_tags(&conn, second).unwrap().len(), 1);
}
#[test]
fn album_crud_and_membership() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let first = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
let second = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap();
let album = create_album(&conn, "Holiday").unwrap();
assert_eq!(album.name, "Holiday");
assert_eq!(album.image_count, 0);
// Adding is idempotent.
assert_eq!(
add_images_to_album(&conn, album.id, &[first, second]).unwrap(),
2
);
assert_eq!(add_images_to_album(&conn, album.id, &[first]).unwrap(), 0);
assert_eq!(count_album_images(&conn, album.id).unwrap(), 2);
remove_images_from_album(&conn, album.id, &[first]).unwrap();
assert_eq!(count_album_images(&conn, album.id).unwrap(), 1);
rename_album(&conn, album.id, "Trip").unwrap();
assert_eq!(get_album(&conn, album.id).unwrap().name, "Trip");
delete_album(&conn, album.id).unwrap();
assert!(list_albums(&conn).unwrap().is_empty());
// Membership rows cascade away with the album.
let orphans: i64 = conn
.query_row("SELECT COUNT(*) FROM album_images", [], |row| row.get(0))
.unwrap();
assert_eq!(orphans, 0);
}
#[test]
fn backfill_enqueues_only_unqueued_unready_images() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let pending = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
let mut ready = test_image(folder_id, "C:/a/b.jpg");
ready.embedding_status = "ready".into();
upsert_image(&conn, &ready).unwrap();
let queued = upsert_image(&conn, &test_image(folder_id, "C:/a/c.jpg")).unwrap();
enqueue_embedding_job(&conn, queued).unwrap();
assert_eq!(backfill_embedding_jobs(&conn).unwrap(), 1);
let has_job: i64 = conn
.query_row(
"SELECT COUNT(*) FROM embedding_jobs WHERE image_id = ?1",
[pending],
|row| row.get(0),
)
.unwrap();
assert_eq!(has_job, 1);
}
#[test]
fn retry_failed_embeddings_skips_thumbnailless_videos() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let mut failed_image = test_image(folder_id, "C:/a/a.jpg");
failed_image.embedding_status = "failed".into();
let failed_image_id = upsert_image(&conn, &failed_image).unwrap();
let mut failed_video = test_image(folder_id, "C:/a/clip.mp4");
failed_video.media_kind = "video".into();
failed_video.embedding_status = "failed".into();
failed_video.thumbnail_path = None;
upsert_image(&conn, &failed_video).unwrap();
assert_eq!(retry_failed_embedding_jobs(&conn, folder_id).unwrap(), 1);
let requeued = get_image_by_id(&conn, failed_image_id).unwrap();
assert_eq!(requeued.embedding_status, "pending");
}
#[test]
fn repair_embedding_consistency_requeues_ready_images_without_vectors() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let mut ready = test_image(folder_id, "C:/a/a.jpg");
ready.embedding_status = "ready".into();
let ready_id = upsert_image(&conn, &ready).unwrap();
let (orphaned, requeued) = repair_embedding_consistency(&conn).unwrap();
assert_eq!(orphaned, 0);
assert_eq!(requeued, 1);
let repaired = get_image_by_id(&conn, ready_id).unwrap();
assert_eq!(repaired.embedding_status, "pending");
}
#[test]
fn search_images_by_tag_is_case_insensitive_and_paginates() {
let conn = test_conn();
let folder_a = insert_folder(&conn, "C:/a", "a").unwrap();
let folder_b = insert_folder(&conn, "C:/b", "b").unwrap();
let first = upsert_image(&conn, &test_image(folder_a, "C:/a/a.jpg")).unwrap();
let second = upsert_image(&conn, &test_image(folder_a, "C:/a/b.jpg")).unwrap();
let third = upsert_image(&conn, &test_image(folder_b, "C:/b/c.jpg")).unwrap();
add_user_tag(&conn, first, "Cat").unwrap();
add_user_tag(&conn, second, "cat").unwrap();
add_user_tag(&conn, third, "cat").unwrap();
add_user_tag(&conn, third, "dog").unwrap();
// Query is trimmed and matched case-insensitively against stored tags.
let (all, total) =
search_images_by_tag(&conn, " CAT ", None, None, false, 0, None, 10, 0).unwrap();
assert_eq!(total, 3);
assert_eq!(all.len(), 3);
let (scoped, scoped_total) =
search_images_by_tag(&conn, "cat", Some(folder_a), None, false, 0, None, 10, 0)
.unwrap();
assert_eq!(scoped_total, 2);
assert_eq!(scoped.len(), 2);
// Pagination: total stays the full count while the page shrinks.
let (page, page_total) =
search_images_by_tag(&conn, "cat", None, None, false, 0, None, 2, 2).unwrap();
assert_eq!(page_total, 3);
assert_eq!(page.len(), 1);
// Blank queries return nothing instead of matching everything.
let (empty, empty_total) =
search_images_by_tag(&conn, " ", None, None, false, 0, None, 10, 0).unwrap();
assert!(empty.is_empty());
assert_eq!(empty_total, 0);
}
#[test]
fn update_ai_tags_replaces_ai_state_without_downgrading_user_tags() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
add_user_tag(&conn, id, "cat").unwrap();
conn.execute(
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
VALUES (?1, 'stale', 'ai', 'wd', 0.5, datetime('now'))",
[id],
)
.unwrap();
enqueue_tagging_job(&conn, id).unwrap();
update_ai_tags(
&conn,
id,
&[("cat".into(), 0.9), ("outdoors".into(), 0.7)],
"general",
"wd",
)
.unwrap();
let tags = get_image_tags(&conn, id).unwrap();
assert_eq!(tags.len(), 2, "stale AI tag should be gone");
let cat = tags.iter().find(|tag| tag.tag == "cat").unwrap();
assert_eq!(cat.source, "user", "user tag must not be downgraded to ai");
let outdoors = tags.iter().find(|tag| tag.tag == "outdoors").unwrap();
assert_eq!(outdoors.source, "ai");
assert_eq!(outdoors.confidence, Some(0.7));
let record = get_image_by_id(&conn, id).unwrap();
assert_eq!(record.ai_rating.as_deref(), Some("general"));
assert_eq!(record.ai_tagger_model.as_deref(), Some("wd"));
assert_eq!(record.ai_tagger_error, None);
let pending_jobs: i64 = conn
.query_row(
"SELECT COUNT(*) FROM tagging_jobs WHERE image_id = ?1",
[id],
|row| row.get(0),
)
.unwrap();
assert_eq!(pending_jobs, 0, "completed job should be dequeued");
}
#[test]
fn delete_folder_cascades_images_tags_and_vectors() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
add_user_tag(&conn, id, "cat").unwrap();
vector::upsert_embedding(&conn, id, &vec![0.5f32; vector::CLIP_VECTOR_DIM]).unwrap();
delete_folder(&conn, folder_id).unwrap();
assert!(get_folders(&conn).unwrap().is_empty());
let remaining_images: i64 = conn
.query_row("SELECT COUNT(*) FROM images", [], |row| row.get(0))
.unwrap();
assert_eq!(remaining_images, 0);
let remaining_tags: i64 = conn
.query_row("SELECT COUNT(*) FROM image_tags", [], |row| row.get(0))
.unwrap();
assert_eq!(remaining_tags, 0);
assert!(!vector::has_image_vector(&conn, id).unwrap());
}
}
+2
View File
@@ -142,6 +142,7 @@ fn remote_content_length(url: &str) -> Option<u64> {
"30",
"--max-time",
"30",
"--",
url,
]);
let output = command.output().ok()?;
@@ -179,6 +180,7 @@ fn run_curl_download(
.arg("-s") // no progress meter (we watch the file instead)
.arg("-o")
.arg(dest)
.arg("--")
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
+39
View File
@@ -1995,3 +1995,42 @@ fn process_watcher_rename(
Err(e) => log::error!("Watcher rename: post-update fetch error: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn supported_media_matches_known_extensions_case_insensitively() {
for path in ["a.jpg", "b.JPEG", "c.PNG", "d.avif", "e.mp4", "f.WEBM"] {
assert!(
is_supported_media(Path::new(path)),
"{path} should be supported"
);
}
for path in ["notes.txt", "archive.zip", "no_extension", "clip.mkv"] {
assert!(
!is_supported_media(Path::new(path)),
"{path} should be skipped"
);
}
}
#[test]
fn media_kind_splits_video_from_image_extensions() {
for ext in ["mp4", "MOV", "m4v", "webm"] {
assert_eq!(media_kind_for_ext(ext), "video");
}
for ext in ["jpg", "PNG", "webp", "avif"] {
assert_eq!(media_kind_for_ext(ext), "image");
}
}
#[test]
fn mime_types_map_per_extension() {
assert_eq!(mime_for_ext("JPG"), "image/jpeg");
assert_eq!(mime_for_ext("png"), "image/png");
assert_eq!(mime_for_ext("mov"), "video/quicktime");
assert_eq!(mime_for_ext("m4v"), "video/mp4");
}
}
+50
View File
@@ -122,3 +122,53 @@ fn fallback_profile_for_path(path: &Path) -> StorageProfile {
StorageProfile::Balanced
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn thumbnail_workers_scale_with_parallelism_within_clamps() {
assert_eq!(StorageProfile::Fast.thumbnail_workers(3), 2);
assert_eq!(StorageProfile::Fast.thumbnail_workers(12), 4);
assert_eq!(StorageProfile::Fast.thumbnail_workers(64), 4);
assert_eq!(StorageProfile::Balanced.thumbnail_workers(4), 2);
assert_eq!(StorageProfile::Balanced.thumbnail_workers(12), 3);
assert_eq!(StorageProfile::Balanced.thumbnail_workers(64), 3);
assert_eq!(StorageProfile::Conservative.thumbnail_workers(64), 1);
}
#[test]
fn adaptive_profile_tracks_scan_speed() {
let mut adaptive = RuntimeAdaptiveProfile::new(StorageProfile::Balanced);
assert_eq!(adaptive.profile(), StorageProfile::Balanced);
// 1 ms/item → fast storage.
adaptive.observe_scan_batch(10, Duration::from_millis(10));
assert_eq!(adaptive.profile(), StorageProfile::Fast);
// A very slow batch drags the EMA over the conservative threshold.
adaptive.observe_scan_batch(1, Duration::from_millis(100));
assert_eq!(adaptive.profile(), StorageProfile::Conservative);
}
#[test]
fn adaptive_profile_ignores_empty_batches() {
let mut adaptive = RuntimeAdaptiveProfile::new(StorageProfile::Fast);
adaptive.observe_scan_batch(0, Duration::from_secs(10));
assert_eq!(adaptive.profile(), StorageProfile::Fast);
}
#[test]
fn fallback_profile_treats_unc_paths_as_conservative() {
assert_eq!(
fallback_profile_for_path(Path::new("\\\\server\\share\\photos")),
StorageProfile::Conservative
);
assert_eq!(
fallback_profile_for_path(Path::new("C:\\photos")),
StorageProfile::Balanced
);
}
}
+20
View File
@@ -383,6 +383,26 @@ fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) {
mod tests {
use super::*;
#[test]
fn fit_dimensions_preserves_aspect_ratio_within_max() {
// Already small enough: unchanged.
assert_eq!(fit_dimensions(100, 50, THUMB_SIZE), (100, 50));
// Landscape and portrait scale to the max on their long edge.
assert_eq!(fit_dimensions(6400, 3200, THUMB_SIZE), (320, 160));
assert_eq!(fit_dimensions(3200, 6400, THUMB_SIZE), (160, 320));
// Extreme ratios never collapse to zero.
assert_eq!(fit_dimensions(1, 100_000, 320), (1, 320));
assert_eq!(fit_dimensions(100_000, 1, 320), (320, 1));
}
#[test]
fn is_jpeg_checks_extension_only() {
assert!(is_jpeg(Path::new("photo.jpg")));
assert!(is_jpeg(Path::new("photo.JPEG")));
assert!(!is_jpeg(Path::new("photo.png")));
assert!(!is_jpeg(Path::new("photo")));
}
#[test]
fn scale_numerator_picks_smallest_sufficient() {
assert_eq!(scale_numerator(6000, THUMB_SIZE), 1);
+78
View File
@@ -562,3 +562,81 @@ fn pack_f32(values: &[f32]) -> Vec<u8> {
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::test_support::{test_conn, test_image};
#[test]
fn pack_unpack_roundtrip() {
let values = vec![0.0f32, 1.5, -2.25, f32::MIN_POSITIVE, 1e10];
assert_eq!(unpack_f32(&pack_f32(&values)), values);
assert!(unpack_f32(&pack_f32(&[])).is_empty());
}
#[test]
fn upsert_embedding_rejects_wrong_dimension() {
let conn = test_conn();
let error = upsert_embedding(&conn, 1, &[0.5f32; 3]).unwrap_err();
assert!(error.to_string().contains("dimension"));
}
#[test]
fn upsert_and_delete_embedding_roundtrip() {
let conn = test_conn();
let embedding = vec![0.25f32; CLIP_VECTOR_DIM];
upsert_embedding(&conn, 42, &embedding).unwrap();
assert!(has_image_vector(&conn, 42).unwrap());
// Upsert replaces rather than duplicates.
upsert_embedding(&conn, 42, &embedding).unwrap();
let rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM image_vec WHERE image_id = 42",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(rows, 1);
delete_embedding(&conn, 42).unwrap();
assert!(!has_image_vector(&conn, 42).unwrap());
}
#[test]
fn find_similar_image_ids_ranks_by_cosine_distance() {
let conn = test_conn();
let folder_id = crate::db::insert_folder(&conn, "C:/a", "a").unwrap();
let base_id =
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/base.jpg")).unwrap();
let close_id =
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/close.jpg")).unwrap();
let far_id =
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/far.jpg")).unwrap();
let mut base = vec![0.0f32; CLIP_VECTOR_DIM];
base[0] = 1.0;
let mut close = vec![0.0f32; CLIP_VECTOR_DIM];
close[0] = 1.0;
close[1] = 0.2;
let mut far = vec![0.0f32; CLIP_VECTOR_DIM];
far[1] = 1.0;
upsert_embedding(&conn, base_id, &base).unwrap();
upsert_embedding(&conn, close_id, &close).unwrap();
upsert_embedding(&conn, far_id, &far).unwrap();
// Global KNN path: nearest first, query image excluded.
let global = find_similar_image_ids(&conn, base_id, 2, None).unwrap();
assert_eq!(global, vec![close_id, far_id]);
// Folder-scoped brute-force path returns the same ranking.
let scoped = find_similar_image_ids(&conn, base_id, 2, Some(folder_id)).unwrap();
assert_eq!(scoped, vec![close_id, far_id]);
// Images without an embedding yield no matches instead of an error.
assert!(find_similar_image_ids(&conn, 9999, 5, None)
.unwrap()
.is_empty());
}
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { getChangelogForVersion } from './changelog'
describe('getChangelogForVersion', () => {
it('returns null for a null/undefined version', () => {
expect(getChangelogForVersion(null)).toBeNull()
expect(getChangelogForVersion(undefined)).toBeNull()
})
it('never surfaces the in-progress Unreleased section', () => {
expect(getChangelogForVersion('Unreleased')).toBeNull()
expect(getChangelogForVersion('unreleased')).toBeNull()
})
it('returns null for a version with no matching entry', () => {
expect(getChangelogForVersion('99.9.9')).toBeNull()
})
it('resolves a plain released version', () => {
const entry = getChangelogForVersion('0.1.1')
expect(entry?.version).toBe('0.1.1')
expect(entry?.date).toBe('2026-06-23')
})
it('strips a leading "v" from the version string', () => {
expect(getChangelogForVersion('v0.1.1')?.version).toBe('0.1.1')
})
it('strips dev/UI-lab build suffixes so they resolve to the base version', () => {
expect(getChangelogForVersion('0.1.1-dev')?.version).toBe('0.1.1')
expect(getChangelogForVersion('0.1.1-ui')?.version).toBe('0.1.1')
expect(getChangelogForVersion('0.1.1-beta.1')?.version).toBe('0.1.1')
})
})
+13 -6
View File
@@ -5,11 +5,15 @@ import { ContextMenu, MenuItem, MenuLabel } from './menu'
import { PhokusMark } from './PhokusMark'
import { Tooltip } from './Tooltip'
const THEME_OPTIONS: { value: AppTheme; label: string }[] = [
{ value: 'phokus', label: 'Phokus' },
{ value: 'subtle-light', label: 'Subtle Light' },
{ value: 'conventional-dark', label: 'Conventional Dark' },
]
const THEME_LABELS: Record<AppTheme, string> = {
phokus: 'Phokus',
'subtle-light': 'Subtle Light',
'conventional-dark': 'Conventional Dark',
}
const THEME_OPTIONS = (Object.keys(THEME_LABELS) as AppTheme[]).map((value) => ({
value,
label: THEME_LABELS[value],
}))
// SVG icons for window controls
function MinimizeIcon() {
@@ -110,7 +114,10 @@ export function TitleBar() {
up its focal point and the chip becomes a button that re-opens the prompt. */}
<div className="flex items-center gap-2 pr-4 pl-3">
{updatePending ? (
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<div
className="flex h-5 w-5 items-center justify-center"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{/* Instant tooltip (delay 0) — this affordance should read immediately. */}
<Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
<button
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import type { DuplicateScanProgress } from '../../store'
import {
duplicateProgressLabel,
duplicateProgressPercent,
formatBytes,
formatRelativeTime,
} from './format'
function progress(overrides: Partial<DuplicateScanProgress> = {}): DuplicateScanProgress {
return { phase: 'checking', processed: 0, total: 0, skipped: 0, ...overrides }
}
describe('formatBytes', () => {
it('formats each size tier', () => {
expect(formatBytes(0)).toBe('0 B')
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(2048)).toBe('2 KB')
expect(formatBytes(1_048_576)).toBe('1.0 MB')
expect(formatBytes(1_610_612_736)).toBe('1.5 GB')
})
})
describe('formatRelativeTime', () => {
const now = () => Math.floor(Date.now() / 1000)
it('formats each time bucket', () => {
expect(formatRelativeTime(now())).toBe('just now')
expect(formatRelativeTime(now() - 120)).toBe('2m ago')
expect(formatRelativeTime(now() - 7200)).toBe('2h ago')
expect(formatRelativeTime(now() - 172_800)).toBe('2d ago')
})
})
describe('duplicateProgressLabel', () => {
it('returns null without progress', () => {
expect(duplicateProgressLabel(null)).toBeNull()
})
it('labels each phase', () => {
expect(duplicateProgressLabel(progress({ phase: 'checking' }))).toBe('Checking file sizes')
expect(duplicateProgressLabel(progress({ phase: 'hashing' }))).toBe(
'Hashing duplicate candidates'
)
expect(duplicateProgressLabel(progress({ phase: 'confirming' }))).toBe(
'Confirming exact matches'
)
})
})
describe('duplicateProgressPercent', () => {
it('returns 0 for missing progress or zero totals', () => {
expect(duplicateProgressPercent(null)).toBe(0)
expect(duplicateProgressPercent(progress({ processed: 5, total: 0 }))).toBe(0)
})
it('rounds to whole percent', () => {
expect(duplicateProgressPercent(progress({ processed: 50, total: 200 }))).toBe(25)
expect(duplicateProgressPercent(progress({ processed: 1, total: 3 }))).toBe(33)
})
})
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import {
buildBreadcrumbs,
cleanAddressInput,
folderName,
friendlyDirectoryError,
normalizePath,
} from './pathUtils'
describe('normalizePath', () => {
it('converts backslashes, strips trailing slashes, and lowercases', () => {
expect(normalizePath('C:\\Users\\Me\\Pictures\\')).toBe('c:/users/me/pictures')
expect(normalizePath('/home/User/photos///')).toBe('/home/user/photos')
expect(normalizePath('relative/path')).toBe('relative/path')
})
})
describe('cleanAddressInput', () => {
it('strips matching surrounding quotes', () => {
expect(cleanAddressInput('"C:\\Photos"')).toBe('C:\\Photos')
expect(cleanAddressInput("'C:\\Photos'")).toBe('C:\\Photos')
})
it('leaves mismatched quotes alone', () => {
expect(cleanAddressInput('"C:\\Photos\'')).toBe('"C:\\Photos\'')
})
it('trims whitespace inside and outside quotes', () => {
expect(cleanAddressInput(' " C:\\Photos " ')).toBe('C:\\Photos')
expect(cleanAddressInput(' C:\\Photos ')).toBe('C:\\Photos')
})
})
describe('friendlyDirectoryError', () => {
it('maps not-found style errors to a friendly message', () => {
expect(friendlyDirectoryError(new Error('The system cannot find the path specified.'))).toBe(
'Folder not found. Check the path and try again.'
)
expect(friendlyDirectoryError(new Error('os error 3'))).toBe(
'Folder not found. Check the path and try again.'
)
expect(friendlyDirectoryError('No such file or directory')).toBe(
'Folder not found. Check the path and try again.'
)
})
it('passes other messages through', () => {
expect(friendlyDirectoryError(new Error('Access denied'))).toBe('Access denied')
expect(friendlyDirectoryError(42)).toBe('42')
})
})
describe('folderName', () => {
it('returns the final path component', () => {
expect(folderName('C:\\Users\\me\\Pictures')).toBe('Pictures')
expect(folderName('/home/user/photos/')).toBe('photos')
})
it('handles roots', () => {
expect(folderName('C:\\')).toBe('C:')
expect(folderName('/')).toBe('/')
})
})
describe('buildBreadcrumbs', () => {
it('returns the home crumb for null paths', () => {
expect(buildBreadcrumbs(null)).toEqual([{ label: 'This PC / Home', path: null }])
})
it('builds Windows drive breadcrumbs', () => {
expect(buildBreadcrumbs('C:\\Users\\me')).toEqual([
{ label: 'This PC', path: null },
{ label: 'C:', path: 'C:' },
{ label: 'Users', path: 'C:\\Users' },
{ label: 'me', path: 'C:\\Users\\me' },
])
})
it('builds Unix breadcrumbs', () => {
expect(buildBreadcrumbs('/home/user')).toEqual([
{ label: '/', path: null },
{ label: 'home', path: '/home' },
{ label: 'user', path: '/home/user' },
])
})
it('ignores trailing separators', () => {
const crumbs = buildBreadcrumbs('C:\\Users\\')
expect(crumbs.map((c) => c.label)).toEqual(['This PC', 'C:', 'Users'])
})
})
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { formatDuration } from './format'
describe('formatDuration', () => {
it('returns null for missing or non-positive durations', () => {
expect(formatDuration(null)).toBeNull()
expect(formatDuration(0)).toBeNull()
expect(formatDuration(-100)).toBeNull()
})
it('formats sub-hour durations as M:SS', () => {
expect(formatDuration(1000)).toBe('0:01')
expect(formatDuration(59_999)).toBe('0:59')
expect(formatDuration(65_000)).toBe('1:05')
})
it('formats hour-plus durations as H:MM:SS', () => {
expect(formatDuration(3_600_000)).toBe('1:00:00')
expect(formatDuration(3_661_000)).toBe('1:01:01')
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from './format'
describe('formatBytes', () => {
it('formats each size tier', () => {
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(2048)).toBe('2.0 KB')
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB')
})
})
describe('formatDate', () => {
it('returns Unknown for null', () => {
expect(formatDate(null)).toBe('Unknown')
})
it('formats ISO dates with a full year', () => {
// Exact output is locale-dependent; assert the stable parts.
const formatted = formatDate('2024-03-15T12:00:00Z')
expect(formatted).toContain('2024')
expect(formatted).not.toBe('Unknown')
})
})
describe('formatDuration', () => {
it('reports pending for missing or zero durations', () => {
expect(formatDuration(null)).toBe('Pending / unavailable')
expect(formatDuration(0)).toBe('Pending / unavailable')
expect(formatDuration(-5)).toBe('Pending / unavailable')
})
it('formats minutes and hours', () => {
expect(formatDuration(65_000)).toBe('1:05')
expect(formatDuration(3_661_000)).toBe('1:01:01')
})
})
describe('embeddingLabel', () => {
it('labels each status', () => {
expect(embeddingLabel('ready', 'clip-vit')).toBe('Ready (clip-vit)')
expect(embeddingLabel('ready', null)).toBe('Ready')
expect(embeddingLabel('failed', null)).toBe('Failed')
expect(embeddingLabel('processing', null)).toBe('Processing')
expect(embeddingLabel('pending', null)).toBe('Queued')
})
})
describe('ratingPill', () => {
it('maps each AI rating to a label and tone', () => {
expect(ratingPill('general').label).toBe('General')
expect(ratingPill('general').className).toContain('emerald')
expect(ratingPill('sensitive').label).toBe('Sensitive')
expect(ratingPill('sensitive').className).toContain('sky')
expect(ratingPill('questionable').label).toBe('Questionable')
expect(ratingPill('questionable').className).toContain('amber')
expect(ratingPill('explicit').label).toBe('Explicit')
expect(ratingPill('explicit').className).toContain('red')
})
})
+17 -3
View File
@@ -39,10 +39,24 @@ export function FolderItem({
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds)
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused)
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id])
const folderJobs = useGalleryStore((state) => state.mediaJobProgress[folder.id])
const isMuted = mutedFolderIds.includes(folder.id)
// "Fully paused" only when every worker for this folder is paused.
const isPausedAll =
!!folderWorkers &&
// "Fully paused" means every worker that currently has pending work is
// paused. Workers with nothing pending are ignored — the background tasks
// panel only lets you toggle the stages it actually shows, so requiring
// every worker key to be literally true left this permanently out of sync
// (menu kept offering "Pause" after the visible work was already paused).
const hasPendingWork =
(folderJobs?.thumbnail_pending ?? 0) > 0 ||
(folderJobs?.metadata_pending ?? 0) > 0 ||
(folderJobs?.embedding_pending ?? 0) > 0 ||
(folderJobs?.tagging_pending ?? 0) > 0
const isPausedAll = hasPendingWork
? ((folderJobs?.thumbnail_pending ?? 0) === 0 || !!folderWorkers?.thumbnail) &&
((folderJobs?.metadata_pending ?? 0) === 0 || !!folderWorkers?.metadata) &&
((folderJobs?.embedding_pending ?? 0) === 0 || !!folderWorkers?.embedding) &&
((folderJobs?.tagging_pending ?? 0) === 0 || !!folderWorkers?.tagging)
: !!folderWorkers &&
folderWorkers.thumbnail &&
folderWorkers.metadata &&
folderWorkers.embedding &&
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { makeImage } from '../../test/factories'
import { groupImages } from './timelineModel'
describe('groupImages', () => {
it('buckets images by year-month, preferring taken_at over modified_at', () => {
const groups = groupImages([
makeImage({ id: 1, taken_at: '2026-03-05T00:00:00Z', modified_at: '2026-01-01T00:00:00Z' }),
makeImage({ id: 2, taken_at: null, modified_at: '2026-03-20T00:00:00Z' }),
])
expect(groups).toHaveLength(1)
expect(groups[0].key).toBe('2026-03')
expect(groups[0].images.map((i) => i.id)).toEqual([1, 2])
})
it('sorts groups chronologically ascending', () => {
const groups = groupImages([
makeImage({ id: 1, taken_at: '2026-06-01T00:00:00Z' }),
makeImage({ id: 2, taken_at: '2026-01-01T00:00:00Z' }),
makeImage({ id: 3, taken_at: '2026-03-01T00:00:00Z' }),
])
expect(groups.map((g) => g.key)).toEqual(['2026-01', '2026-03', '2026-06'])
})
it('buckets images with no date under "unknown" and sorts it last', () => {
const groups = groupImages([
makeImage({ id: 1, taken_at: null, modified_at: null }),
makeImage({ id: 2, taken_at: '2026-01-01T00:00:00Z' }),
])
expect(groups.map((g) => g.key)).toEqual(['2026-01', 'unknown'])
expect(groups[1].label).toBe('Unknown Date')
})
it('returns no groups for an empty image list', () => {
expect(groupImages([])).toEqual([])
})
})
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { formatTime } from './format'
describe('formatTime', () => {
it('returns 0:00 for invalid input', () => {
expect(formatTime(Number.NaN)).toBe('0:00')
expect(formatTime(Number.POSITIVE_INFINITY)).toBe('0:00')
expect(formatTime(-5)).toBe('0:00')
})
it('formats sub-hour times as M:SS', () => {
expect(formatTime(0)).toBe('0:00')
expect(formatTime(59)).toBe('0:59')
expect(formatTime(61.7)).toBe('1:01')
})
it('formats hour-plus times as H:MM:SS', () => {
expect(formatTime(3600)).toBe('1:00:00')
expect(formatTime(3661)).toBe('1:01:01')
})
})
+37
View File
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('@tauri-apps/api/core', () => ({
convertFileSrc: (path: string) => `asset://localhost/${path}`,
}))
import { mediaSrc } from './mediaSrc'
afterEach(() => {
vi.unstubAllEnvs()
})
describe('mediaSrc', () => {
it('returns null for a null path', () => {
expect(mediaSrc(null)).toBeNull()
})
it('delegates to convertFileSrc outside UI Lab mode', () => {
expect(mediaSrc('C:/media/image.jpg')).toBe('asset://localhost/C:/media/image.jpg')
})
it('passes through absolute/http paths unchanged in UI Lab mode', () => {
vi.stubEnv('MODE', 'ui')
expect(mediaSrc('/dev-media/image.jpg')).toBe('/dev-media/image.jpg')
expect(mediaSrc('http://example.com/image.jpg')).toBe('http://example.com/image.jpg')
})
it('rewrites mock:// paths to /dev-media/ in UI Lab mode', () => {
vi.stubEnv('MODE', 'ui')
expect(mediaSrc('mock://folder/image.jpg')).toBe('/dev-media/folder/image.jpg')
})
it('falls back to convertFileSrc for other paths in UI Lab mode', () => {
vi.stubEnv('MODE', 'ui')
expect(mediaSrc('C:/media/image.jpg')).toBe('asset://localhost/C:/media/image.jpg')
})
})
+2 -4
View File
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core'
import type { StateCreator } from 'zustand'
import { notifyTaskComplete } from '../notifications'
import { invalidateDuplicateScanCaches } from './helpers'
import type { GalleryStore } from './index'
import type { DuplicateGroup, DuplicateScanProgress, DuplicateScanResult } from './types'
@@ -146,10 +147,7 @@ export const createDuplicateSlice: StateCreator<GalleryStore, [], [], DuplicateS
.filter((img) => succeededSet.has(img.id))
.map((img) => img.folder_id)
)
await invoke('invalidate_duplicate_scan_cache', { folderId: null }) // global
for (const folderId of affectedFolderIds) {
await invoke('invalidate_duplicate_scan_cache', { folderId })
}
await invalidateDuplicateScanCaches(affectedFolderIds)
return succeededIds.length
},
})
+2 -4
View File
@@ -3,6 +3,7 @@ import type { StateCreator } from 'zustand'
import {
PAGE_SIZE,
TIMELINE_PAGE_SIZE,
invalidateDuplicateScanCaches,
isCurrentGalleryRequest,
isDerivedCollectionTitle,
mergeImages,
@@ -608,10 +609,7 @@ export const createGallerySlice: StateCreator<GalleryStore, [], [], GallerySlice
}))
// The DB cascade already removed these from album_images; refresh counts/covers.
void get().loadAlbums()
await invoke('invalidate_duplicate_scan_cache', { folderId: null })
for (const folderId of affectedFolderIds) {
await invoke('invalidate_duplicate_scan_cache', { folderId })
}
await invalidateDuplicateScanCaches(affectedFolderIds)
return succeededIds.length
},
})
+349
View File
@@ -0,0 +1,349 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { makeFolderProgress, makeImage } from '../test/factories'
import {
countNewImages,
imagesAffectScope,
initialAiCaptionsEnabled,
initialBoolSetting,
initialNumberSetting,
isCurrentGalleryRequest,
matchesFilters,
matchesSearch,
mergeImages,
mergeIntoVisibleWindow,
nextGalleryRequestToken,
parseSearchValue,
replaceExistingImages,
replaceImage,
scopeHasTaggingPending,
searchModeLabel,
taggingProgressAffectsScope,
tileSizeForZoom,
} from './helpers'
describe('parseSearchValue', () => {
it('returns empty filename search for blank input', () => {
expect(parseSearchValue('')).toEqual({ mode: 'filename', query: '', prefix: null })
expect(parseSearchValue(' ')).toEqual({ mode: 'filename', query: '', prefix: null })
})
it('treats plain text as filename search', () => {
expect(parseSearchValue('sunset')).toEqual({ mode: 'filename', query: 'sunset', prefix: null })
})
it('parses slash prefixes', () => {
expect(parseSearchValue('/s beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: '/s' })
expect(parseSearchValue('/t cat')).toEqual({ mode: 'tag', query: 'cat', prefix: '/t' })
expect(parseSearchValue('/f holiday')).toEqual({
mode: 'filename',
query: 'holiday',
prefix: '/f',
})
})
it('parses a bare slash prefix with no query yet', () => {
expect(parseSearchValue('/s')).toEqual({ mode: 'semantic', query: '', prefix: '/s' })
expect(parseSearchValue('/t')).toEqual({ mode: 'tag', query: '', prefix: '/t' })
})
it('is case-insensitive on slash prefixes', () => {
expect(parseSearchValue('/S beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: '/s' })
})
it('keeps multi-word queries intact', () => {
expect(parseSearchValue('/s beach at sunset').query).toBe('beach at sunset')
})
it('falls back to filename for unknown slash prefixes', () => {
expect(parseSearchValue('/x foo')).toEqual({ mode: 'filename', query: 'foo', prefix: null })
})
it('parses colon prefixes', () => {
expect(parseSearchValue('s: beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: 's:' })
expect(parseSearchValue('t:cat')).toEqual({ mode: 'tag', query: 'cat', prefix: 't:' })
expect(parseSearchValue('f: holiday')).toEqual({
mode: 'filename',
query: 'holiday',
prefix: 'f:',
})
expect(parseSearchValue('S: beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: 's:' })
})
it('falls back to filename for unknown colon prefixes', () => {
expect(parseSearchValue('x: foo')).toEqual({ mode: 'filename', query: 'foo', prefix: null })
})
it('does not treat multi-letter colon words as prefixes', () => {
expect(parseSearchValue('note: hello')).toEqual({
mode: 'filename',
query: 'note: hello',
prefix: null,
})
})
})
describe('searchModeLabel / tileSizeForZoom', () => {
it('labels every search mode', () => {
expect(searchModeLabel('semantic')).toBe('Semantic Search')
expect(searchModeLabel('tag')).toBe('Tag Search')
expect(searchModeLabel('filename')).toBe('Filename Search')
})
it('maps zoom presets to tile sizes', () => {
expect(tileSizeForZoom('compact')).toBe(160)
expect(tileSizeForZoom('comfortable')).toBe(220)
expect(tileSizeForZoom('detail')).toBe(280)
})
})
describe('matchesSearch', () => {
it('matches any image when search is empty', () => {
expect(matchesSearch(makeImage(), '')).toBe(true)
})
it('matches filename substrings case-insensitively', () => {
const image = makeImage({ filename: 'Beach_Sunset.JPG' })
expect(matchesSearch(image, 'sunset')).toBe(true)
expect(matchesSearch(image, 'SUNSET')).toBe(true)
expect(matchesSearch(image, 'mountain')).toBe(false)
})
})
describe('matchesFilters', () => {
const pass = (image = makeImage()) =>
matchesFilters(image, null, 'all', false, 0, false, false, '')
it('passes with no filters active', () => {
expect(pass()).toBe(true)
})
it('filters by folder', () => {
const image = makeImage({ folder_id: 2 })
expect(matchesFilters(image, 2, 'all', false, 0, false, false, '')).toBe(true)
expect(matchesFilters(image, 3, 'all', false, 0, false, false, '')).toBe(false)
})
it('filters by media kind', () => {
const video = makeImage({ media_kind: 'video' })
expect(matchesFilters(video, null, 'video', false, 0, false, false, '')).toBe(true)
expect(matchesFilters(video, null, 'image', false, 0, false, false, '')).toBe(false)
})
it('filters favorites and minimum rating', () => {
const image = makeImage({ favorite: false, rating: 2 })
expect(matchesFilters(image, null, 'all', true, 0, false, false, '')).toBe(false)
expect(matchesFilters(image, null, 'all', false, 3, false, false, '')).toBe(false)
expect(matchesFilters(image, null, 'all', false, 2, false, false, '')).toBe(true)
})
it('filters failed embeddings and failed tagging', () => {
const healthy = makeImage({ embedding_status: 'ready', ai_tagger_error: null })
const broken = makeImage({ embedding_status: 'failed', ai_tagger_error: 'boom' })
expect(matchesFilters(healthy, null, 'all', false, 0, true, false, '')).toBe(false)
expect(matchesFilters(broken, null, 'all', false, 0, true, false, '')).toBe(true)
expect(matchesFilters(healthy, null, 'all', false, 0, false, true, '')).toBe(false)
expect(matchesFilters(broken, null, 'all', false, 0, false, true, '')).toBe(true)
})
it('applies the search term', () => {
const image = makeImage({ filename: 'cat.jpg' })
expect(matchesFilters(image, null, 'all', false, 0, false, false, 'cat')).toBe(true)
expect(matchesFilters(image, null, 'all', false, 0, false, false, 'dog')).toBe(false)
})
})
describe('mergeImages', () => {
it('deduplicates by path, letting new records win', () => {
const stale = makeImage({ path: 'C:/media/a.jpg', filename: 'a.jpg', rating: 0 })
const fresh = makeImage({ path: 'C:/media/a.jpg', filename: 'a.jpg', rating: 5 })
const merged = mergeImages([stale], [fresh], 'name_asc')
expect(merged).toHaveLength(1)
expect(merged[0].rating).toBe(5)
})
it('sorts by name in both directions', () => {
const a = makeImage({ path: 'a', filename: 'apple.jpg' })
const b = makeImage({ path: 'b', filename: 'banana.jpg' })
expect(mergeImages([b], [a], 'name_asc').map((i) => i.filename)).toEqual([
'apple.jpg',
'banana.jpg',
])
expect(mergeImages([b], [a], 'name_desc').map((i) => i.filename)).toEqual([
'banana.jpg',
'apple.jpg',
])
})
it('sorts by modified date, treating null as the epoch', () => {
const older = makeImage({ path: 'a', modified_at: '2025-01-01T00:00:00Z' })
const newer = makeImage({ path: 'b', modified_at: '2026-01-01T00:00:00Z' })
const undated = makeImage({ path: 'c', modified_at: null })
const asc = mergeImages([newer, undated], [older], 'date_asc')
expect(asc.map((i) => i.path)).toEqual(['c', 'a', 'b'])
const desc = mergeImages([newer, undated], [older], 'date_desc')
expect(desc.map((i) => i.path)).toEqual(['b', 'a', 'c'])
})
it('sorts by size, rating, and duration', () => {
const small = makeImage({ path: 'a', file_size: 10, rating: 1, duration_ms: 100 })
const large = makeImage({ path: 'b', file_size: 20, rating: 3, duration_ms: null })
expect(mergeImages([large], [small], 'size_asc')[0].path).toBe('a')
expect(mergeImages([large], [small], 'size_desc')[0].path).toBe('b')
expect(mergeImages([large], [small], 'rating_asc')[0].path).toBe('a')
expect(mergeImages([large], [small], 'rating_desc')[0].path).toBe('b')
// null duration sorts as 0
expect(mergeImages([large], [small], 'duration_asc')[0].path).toBe('b')
expect(mergeImages([large], [small], 'duration_desc')[0].path).toBe('a')
})
it('falls back to modified_at when taken_at is missing', () => {
const taken = makeImage({
path: 'a',
taken_at: '2024-01-01T00:00:00Z',
modified_at: '2026-01-01T00:00:00Z',
})
const untaken = makeImage({
path: 'b',
taken_at: null,
modified_at: '2025-01-01T00:00:00Z',
})
expect(mergeImages([taken], [untaken], 'taken_asc').map((i) => i.path)).toEqual(['a', 'b'])
expect(mergeImages([taken], [untaken], 'taken_desc').map((i) => i.path)).toEqual(['b', 'a'])
})
})
describe('mergeIntoVisibleWindow', () => {
it('limits the merged list to the window size', () => {
const images = [1, 2, 3, 4].map((n) => makeImage({ path: `p${n}`, filename: `${n}.jpg` }))
const windowed = mergeIntoVisibleWindow(images.slice(0, 2), images.slice(2), 'name_asc', 3)
expect(windowed).toHaveLength(3)
expect(windowed.map((i) => i.filename)).toEqual(['1.jpg', '2.jpg', '3.jpg'])
})
it('clamps negative window sizes to zero', () => {
expect(mergeIntoVisibleWindow([makeImage()], [], 'name_asc', -1)).toEqual([])
})
})
describe('countNewImages', () => {
it('counts only paths not already present', () => {
const current = [makeImage({ path: 'a' })]
const incoming = [makeImage({ path: 'a' }), makeImage({ path: 'b' }), makeImage({ path: 'c' })]
expect(countNewImages(current, incoming)).toBe(2)
})
it('counts duplicate new paths once', () => {
const incoming = [makeImage({ path: 'b' }), makeImage({ path: 'b' })]
expect(countNewImages([], incoming)).toBe(1)
})
})
describe('replaceImage / replaceExistingImages', () => {
it('replaceImage swaps the matching record and re-sorts', () => {
const a = makeImage({ path: 'a', filename: 'a.jpg' })
const b = makeImage({ path: 'b', filename: 'b.jpg' })
const renamed = makeImage({ path: 'a', filename: 'z.jpg' })
const result = replaceImage([a, b], renamed, 'name_asc')
expect(result.map((i) => i.filename)).toEqual(['b.jpg', 'z.jpg'])
})
it('replaceExistingImages swaps in place without re-sorting', () => {
const a = makeImage({ path: 'a', rating: 0 })
const b = makeImage({ path: 'b', rating: 0 })
const updated = makeImage({ path: 'b', rating: 5 })
const result = replaceExistingImages([b, a], [updated])
expect(result.map((i) => i.path)).toEqual(['b', 'a'])
expect(result[0].rating).toBe(5)
})
it('replaceExistingImages ignores updates for unknown paths and returns the same array', () => {
const current = [makeImage({ path: 'a' })]
const result = replaceExistingImages(current, [makeImage({ path: 'zzz' })])
expect(result).toBe(current)
})
})
describe('localStorage-backed initial settings', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
function stubLocalStorage(entries: Record<string, string>) {
const store = new Map(Object.entries(entries))
vi.stubGlobal('window', {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
},
})
}
it('returns the fallback when window is undefined', () => {
expect(initialBoolSetting('missing', true)).toBe(true)
expect(initialNumberSetting('missing', 7, 0, 10)).toBe(7)
expect(initialAiCaptionsEnabled('missing')).toBe(false)
})
it('initialAiCaptionsEnabled only enables on the literal string "true"', () => {
stubLocalStorage({ on: 'true', off: 'yes' })
expect(initialAiCaptionsEnabled('on')).toBe(true)
expect(initialAiCaptionsEnabled('off')).toBe(false)
expect(initialAiCaptionsEnabled('absent')).toBe(false)
})
it('reads booleans from storage', () => {
stubLocalStorage({ on: 'true', off: 'false' })
expect(initialBoolSetting('on', false)).toBe(true)
expect(initialBoolSetting('off', true)).toBe(false)
expect(initialBoolSetting('absent', true)).toBe(true)
})
it('clamps stored numbers to the allowed range', () => {
stubLocalStorage({ low: '-5', high: '999', ok: '4', junk: 'abc' })
expect(initialNumberSetting('low', 5, 0, 10)).toBe(0)
expect(initialNumberSetting('high', 5, 0, 10)).toBe(10)
expect(initialNumberSetting('ok', 5, 0, 10)).toBe(4)
expect(initialNumberSetting('junk', 5, 0, 10)).toBe(5)
expect(initialNumberSetting('absent', 5, 0, 10)).toBe(5)
})
})
describe('gallery request tokens', () => {
it('only the most recent token is current', () => {
const first = nextGalleryRequestToken()
expect(isCurrentGalleryRequest(first)).toBe(true)
const second = nextGalleryRequestToken()
expect(second).toBeGreaterThan(first)
expect(isCurrentGalleryRequest(first)).toBe(false)
expect(isCurrentGalleryRequest(second)).toBe(true)
})
})
describe('tagging scope helpers', () => {
it('scopeHasTaggingPending checks a single folder scope', () => {
const progress = { 1: makeFolderProgress({ folder_id: 1, tagging_pending: 3 }) }
expect(scopeHasTaggingPending(progress, 1)).toBe(true)
expect(scopeHasTaggingPending(progress, 2)).toBe(false)
})
it('scopeHasTaggingPending checks all folders when scope is null', () => {
const progress = {
1: makeFolderProgress({ folder_id: 1, tagging_pending: 0 }),
2: makeFolderProgress({ folder_id: 2, tagging_pending: 1 }),
}
expect(scopeHasTaggingPending(progress, null)).toBe(true)
expect(scopeHasTaggingPending({}, null)).toBe(false)
})
it('taggingProgressAffectsScope matches null or same-folder scopes', () => {
expect(taggingProgressAffectsScope(1, null)).toBe(true)
expect(taggingProgressAffectsScope(1, 1)).toBe(true)
expect(taggingProgressAffectsScope(1, 2)).toBe(false)
})
it('imagesAffectScope checks folder membership', () => {
const images = [makeImage({ folder_id: 1 }), makeImage({ folder_id: 2 })]
expect(imagesAffectScope(images, null)).toBe(true)
expect(imagesAffectScope(images, 2)).toBe(true)
expect(imagesAffectScope(images, 3)).toBe(false)
})
})
+21
View File
@@ -1,3 +1,4 @@
import { invoke } from '@tauri-apps/api/core'
import type {
ImageRecord,
MediaFilter,
@@ -268,6 +269,26 @@ export function scopeHasTaggingPending(
return (progressByFolder[folderId]?.tagging_pending ?? 0) > 0
}
// Invalidates the persisted duplicate-scan cache for every scope affected by a
// deletion: the global "all" cache (always, since a folder-scoped deletion
// still makes the global result stale) and each folder that contained a
// deleted image. This is a best-effort background refresh — a failure here
// must not turn an already-successful delete into a rejected promise for the
// caller, so failures are logged rather than thrown.
export async function invalidateDuplicateScanCaches(affectedFolderIds: Set<number>): Promise<void> {
const results = await Promise.allSettled([
invoke('invalidate_duplicate_scan_cache', { folderId: null }),
...[...affectedFolderIds].map((folderId) =>
invoke('invalidate_duplicate_scan_cache', { folderId })
),
])
for (const result of results) {
if (result.status === 'rejected') {
console.error('Failed to invalidate duplicate-scan cache:', result.reason)
}
}
}
export function taggingProgressAffectsScope(
progressFolderId: number,
scopeFolderId: number | null
+73
View File
@@ -0,0 +1,73 @@
import type { Folder, FolderJobProgress, ImageRecord } from '../store/types'
let nextImageId = 1
export function makeImage(overrides: Partial<ImageRecord> = {}): ImageRecord {
const id = overrides.id ?? nextImageId++
return {
id,
folder_id: 1,
path: `C:/media/image-${id}.jpg`,
filename: `image-${id}.jpg`,
thumbnail_path: null,
width: 1920,
height: 1080,
file_size: 1024,
created_at: null,
modified_at: '2026-01-01T00:00:00Z',
taken_at: null,
mime_type: 'image/jpeg',
media_kind: 'image',
duration_ms: null,
video_codec: null,
audio_codec: null,
metadata_updated_at: null,
metadata_error: null,
favorite: false,
rating: 0,
embedding_status: 'pending',
embedding_model: null,
embedding_updated_at: null,
embedding_error: null,
generated_caption: null,
caption_model: null,
caption_updated_at: null,
caption_error: null,
ai_rating: null,
ai_tagger_model: null,
ai_tagged_at: null,
ai_tagger_error: null,
...overrides,
}
}
export function makeFolder(overrides: Partial<Folder> = {}): Folder {
return {
id: 1,
path: 'C:/media',
name: 'media',
image_count: 0,
indexed_at: null,
scan_error: null,
sort_order: 0,
...overrides,
}
}
export function makeFolderProgress(overrides: Partial<FolderJobProgress> = {}): FolderJobProgress {
return {
folder_id: 1,
thumbnail_pending: 0,
metadata_pending: 0,
embedding_pending: 0,
embedding_ready: 0,
embedding_failed: 0,
caption_pending: 0,
caption_ready: 0,
caption_failed: 0,
tagging_pending: 0,
tagging_ready: 0,
tagging_failed: 0,
...overrides,
}
}
+6
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
@@ -9,6 +10,11 @@ const host = process.env.TAURI_DEV_HOST
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
clearScreen: false,
server: {
port: 1420,