21 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
LyAhn b23212ea1c refactor(backend): extract download and onnx_runtime modules from captioner
github/actions/ci GitHub Actions CI finished: success
captioner.rs had grown into two things: the (currently disabled)
Florence-2 captioner plus generic infrastructure the live tagger
depends on. Split the neutral parts out:

- download.rs: resilient curl downloader (resume, stall detection)
  and NuGet package extraction
- onnx_runtime.rs: shared ONNX Runtime/DirectML DLL manifest,
  provisioning, and ort init, with runtime_dir() as the single
  definition of the DLL location (kept inside the caption model dir
  so existing installs do not re-download)

Also dedupes the tagger-side copies of the DLL list and four
hardcoded caption-model path literals, and rewords tagger runtime
errors that wrongly told users to download the caption model.
2026-07-05 20:21:47 +01:00
LyAhn a791f112f5 fix(downloads): invoke system curl portably instead of curl.exe
Spawn bare `curl` (which Windows still resolves to curl.exe via
CreateProcess, and macOS/Linux ship natively) and write the size-probe
output to the platform null device instead of the Windows-only NUL,
removing the hard Windows dependency from the model download path.
Spawn quirks (console-window suppression) are centralized in a new
curl_command() helper.
2026-07-05 20:21:23 +01:00
LyAhn 7020a6b6cf style(whats-new): reformat WhatsNewModal via pnpm format
github/actions/ci GitHub Actions CI finished: success
2026-07-05 15:02:27 +01:00
LyAhn bf38fac30d test(e2e): wire Playwright to the UI Lab with scenario smoke tests 2026-07-05 15:02:26 +01:00
LyAhn 79e2e28979 refactor(dev): move DemoPanel into src/dev
DemoPanel is dev-only tooling like the rest of the UI Lab mock code, so it
lives with it now instead of among the real UI components. Import path in
App.tsx updated; the DEV gating (and production tree-shaking) is unchanged.
2026-07-05 13:38:52 +01:00
44 changed files with 2532 additions and 441 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
+36 -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`:
@@ -68,6 +99,8 @@ Key modules:
| `hnsw_index.rs` | In-memory HNSW index wrapper (hnsw_rs) |
| `tagger.rs` | WD tagger: ONNX model download, inference, CSV tag loading |
| `captioner.rs` | AI captioning (ONNX, disabled in workers but code intact) |
| `download.rs` | Resilient file downloads via the system `curl` (resume, stall detection) |
| `onnx_runtime.rs` | Shared ONNX Runtime DLL provisioning + `ort` init (used by tagger and captioner; DLLs live in the caption model dir for legacy reasons) |
| `thumbnail.rs` | Thumbnail generation (image crate + fast_image_resize, FFmpeg for video) |
| `media.rs` | FFmpeg sidecar provisioning and probing |
| `storage.rs` | `StorageProfile` for tuning worker counts |
@@ -96,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
```
+1 -1
View File
@@ -22,7 +22,7 @@ http://127.0.0.1:1422
The script runs Vite in the custom `ui` mode:
```bash
vite --mode ui --host 127.0.0.1 --port 1422 --strictPort --open
vite --mode ui --host 127.0.0.1 --port 1422 --strictPort
```
The normal app development commands are unchanged:
+9 -4
View File
@@ -1,7 +1,7 @@
{
"name": "phokus",
"private": true,
"version": "0.1.1",
"version": "0.2.0",
"license": "MIT",
"type": "module",
"scripts": {
@@ -13,7 +13,7 @@
"clean:app": "cd src-tauri && cargo clean",
"dev:app": "tauri dev",
"dev:app:cpu": "tauri dev -- --no-default-features",
"dev:ui": "vite --mode ui --host 127.0.0.1 --port 1422 --strictPort --open",
"dev:ui": "vite --mode ui --host 127.0.0.1 --port 1422 --strictPort",
"dev:vite": "vite",
"dev:web": "cd website && pnpm dev",
"format": "prettier --write .",
@@ -22,7 +22,11 @@
"format:rust": "cd src-tauri && cargo fmt",
"format:rust:check": "cd src-tauri && cargo fmt --check",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"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",
@@ -52,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"
}
}
+9 -13
View File
@@ -20,13 +20,13 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
workers: process.env.CI ? 1 : 4,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
// baseURL: 'http://localhost:3000',
baseURL: 'http://127.0.0.1:1422',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
@@ -39,11 +39,6 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
@@ -70,10 +65,11 @@ export default defineConfig({
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
/* Run the Phokus UI Lab (browser-only dev mode, see docs/ui-lab.md) before starting the tests */
webServer: {
command: 'pnpm exec vite --mode ui --host 127.0.0.1 --port 1422 --strictPort',
url: 'http://127.0.0.1:1422',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
+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"
+7 -3
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(" "));
}
}
+8 -326
View File
@@ -1,3 +1,6 @@
use crate::onnx_runtime::{
self, DIRECTML_DLL_FILE, ONNX_RUNTIME_DLL_FILE, ONNX_RUNTIME_PROVIDERS_DLL_FILE,
};
use anyhow::Result;
use hf_hub::{api::sync::Api, Repo, RepoType};
use image::{imageops::FilterType, ImageReader};
@@ -8,50 +11,16 @@ use ort::session::{builder::GraphOptimizationLevel, Session};
use ort::value::{Shape, Tensor};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Instant;
use tokenizers::Tokenizer;
// Suppress the console window when spawning curl.exe from the GUI app.
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
pub const FLORENCE_MODEL_ID: &str = "onnx-community/Florence-2-base-ft";
pub const FLORENCE_CAPTION_MODEL_NAME: &str = "florence-2-base-ft-onnx-q4";
const ONNX_RUNTIME_NUGET_URL: &str =
"https://www.nuget.org/api/v2/package/Microsoft.ML.OnnxRuntime.DirectML/1.24.2";
const DIRECTML_NUGET_URL: &str =
"https://www.nuget.org/api/v2/package/Microsoft.AI.DirectML/1.15.4";
const ONNX_RUNTIME_DLL_FILE: &str = "onnxruntime/onnxruntime.dll";
const ONNX_RUNTIME_PROVIDERS_DLL_FILE: &str = "onnxruntime/onnxruntime_providers_shared.dll";
const DIRECTML_DLL_FILE: &str = "onnxruntime/DirectML.dll";
const CAPTION_ACCELERATION_FILE: &str = "settings/caption_acceleration.txt";
const CAPTION_DETAIL_FILE: &str = "settings/caption_detail.txt";
const ONNX_RUNTIME_FILES: &[(&str, &str, &str)] = &[
(
ONNX_RUNTIME_DLL_FILE,
ONNX_RUNTIME_NUGET_URL,
"runtimes/win-x64/native/onnxruntime.dll",
),
(
ONNX_RUNTIME_PROVIDERS_DLL_FILE,
ONNX_RUNTIME_NUGET_URL,
"runtimes/win-x64/native/onnxruntime_providers_shared.dll",
),
(
DIRECTML_DLL_FILE,
DIRECTML_NUGET_URL,
"bin/x64-win/DirectML.dll",
),
];
const REQUIRED_FILES: &[&str] = &[
ONNX_RUNTIME_DLL_FILE,
ONNX_RUNTIME_PROVIDERS_DLL_FILE,
@@ -69,11 +38,6 @@ const REQUIRED_FILES: &[&str] = &[
"onnx/embed_tokens_fp16.onnx",
];
// Mutex<bool> rather than OnceLock<Result>: a failed attempt (DLL not yet
// downloaded) must NOT be cached, or a later successful download could never
// recover within the same app session.
static ORT_RUNTIME_INIT: Mutex<bool> = Mutex::new(false);
/// Set to `true` by `set_caption_acceleration` so the caption worker loop
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
@@ -294,11 +258,11 @@ pub fn prepare_caption_model_with_progress(
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
if ONNX_RUNTIME_FILES
if onnx_runtime::ONNX_RUNTIME_FILES
.iter()
.any(|(runtime_file, _, _)| runtime_file == file)
{
download_onnx_runtime_files(&local_dir)?;
onnx_runtime::download_onnx_runtime_files(&local_dir)?;
completed_files = REQUIRED_FILES
.iter()
.filter(|file| local_dir.join(file).exists())
@@ -344,7 +308,7 @@ pub fn probe_caption_runtime(app_data_dir: &Path) -> Result<CaptionRuntimeProbe>
}
let local_dir = model_dir(app_data_dir);
ensure_onnx_runtime(&local_dir)?;
onnx_runtime::ensure_onnx_runtime(&local_dir)?;
let tokenizer =
Tokenizer::from_file(local_dir.join("tokenizer.json")).map_err(anyhow::Error::msg)?;
@@ -393,7 +357,7 @@ pub fn probe_caption_vision(app_data_dir: &Path, image_path: &Path) -> Result<Ca
}
let local_dir = model_dir(app_data_dir);
ensure_onnx_runtime(&local_dir)?;
onnx_runtime::ensure_onnx_runtime(&local_dir)?;
let pixels = preprocess_image(image_path)?;
let input_shape = vec![1, 3, 768, 768];
let input = Tensor::from_array(([1usize, 3, 768, 768], pixels.into_boxed_slice()))
@@ -438,7 +402,7 @@ impl FlorenceCaptioner {
}
let local_dir = model_dir(app_data_dir);
ensure_onnx_runtime(&local_dir)?;
onnx_runtime::ensure_onnx_runtime(&local_dir)?;
let tokenizer =
Tokenizer::from_file(local_dir.join("tokenizer.json")).map_err(anyhow::Error::msg)?;
let caption_detail = caption_detail(app_data_dir);
@@ -647,288 +611,6 @@ fn probe_vision_session(
})
}
pub fn ensure_onnx_runtime(local_dir: &Path) -> Result<()> {
let mut initialized = ORT_RUNTIME_INIT
.lock()
.map_err(|_| anyhow::anyhow!("ONNX runtime init lock poisoned"))?;
if *initialized {
return Ok(());
}
let dll_path = local_dir.join(ONNX_RUNTIME_DLL_FILE);
if !dll_path.exists() {
anyhow::bail!("ONNX Runtime DLL is missing: {}", dll_path.display());
}
ort::environment::init_from(&dll_path)
.map_err(|error| anyhow::anyhow!(error.to_string()))?
.with_name("phokus-florence")
.commit();
*initialized = true;
Ok(())
}
/// Download any ONNX Runtime DLLs missing from `local_dir`, reporting per-file
/// byte progress as `(short_label, downloaded_bytes, total_bytes)`.
/// `total_bytes` is `None` when the server omits Content-Length. Unlike
/// `ensure_onnx_runtime` (init only), this actually provisions the files —
/// callers that can run on a clean install must call this first. The callback
/// fires per chunk; callers should throttle.
pub fn provision_onnx_runtime_with_progress(
local_dir: &Path,
mut on_progress: impl FnMut(&str, u64, Option<u64>),
) -> Result<()> {
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
let destination = local_dir.join(destination_file);
if destination.exists() {
continue;
}
// Strip the "onnxruntime/" prefix for a clean label.
let label = destination_file
.rsplit('/')
.next()
.unwrap_or(destination_file);
download_nuget_file(
source_url,
archive_path,
&destination,
|downloaded, total| on_progress(label, downloaded, total),
)?;
}
Ok(())
}
/// Number of ONNX Runtime DLLs still missing from `local_dir` (for progress
/// step counts before downloading).
pub fn missing_onnx_runtime_count(local_dir: &Path) -> usize {
ONNX_RUNTIME_FILES
.iter()
.filter(|(destination_file, _, _)| !local_dir.join(destination_file).exists())
.count()
}
fn download_onnx_runtime_files(local_dir: &Path) -> Result<()> {
if !cfg!(target_os = "windows") {
anyhow::bail!(
"Florence-2 ONNX Runtime download is currently configured for Windows builds"
);
}
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
let destination = local_dir.join(destination_file);
download_nuget_file(source_url, archive_path, &destination, |_, _| {})?;
}
Ok(())
}
// Give up only after this many *consecutive* curl runs that download nothing;
// a run that makes any progress resets the counter, so a large file completes
// across however many resumes it takes. Kept low so a hard stall (e.g. a
// broken VM NIC) fails in a couple of minutes — surfacing a retryable error —
// rather than locking the UI on "preparing" for many minutes.
const MAX_STALL_RETRIES: usize = 3;
/// Resiliently download `url` to `destination` using the system `curl.exe`.
///
/// ureq's read timeout does not fire on a stalled large transfer on Windows
/// (schannel doesn't honor the socket read timeout), so a stall there hangs
/// forever. curl detects an inactivity stall (`--speed-time`), resumes from
/// the partial file (`-C -`), and retries internally — the same behavior a
/// browser gets. We monitor the `.part` file's size for the progress bar and
/// wrap curl in an outer progress-aware retry as a backstop. The partial file
/// survives an app restart, so a later retry continues from disk.
pub fn download_file_resilient(
url: &str,
destination: &Path,
mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
let part = match destination.extension() {
Some(ext) => destination.with_extension(format!("{}.part", ext.to_string_lossy())),
None => destination.with_extension("part"),
};
let name = destination
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| url.to_string());
log::info!("{name}: resolving download size");
let total = remote_content_length(url);
// Reconcile any existing `.part` against the real size: exactly complete →
// finish; oversized (stale/corrupt) → discard so curl restarts cleanly
// (otherwise `curl -C -` would 416 forever).
if let Some(total) = total {
let size = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if size == total {
std::fs::rename(&part, destination)?;
return Ok(());
}
if size > total {
let _ = std::fs::remove_file(&part);
}
}
log::info!(
"{name}: downloading via curl ({} bytes)",
total
.map(|t| t.to_string())
.unwrap_or_else(|| "unknown size".into())
);
let mut stalls = 0usize;
loop {
let before = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
match run_curl_download(url, &part, total, &mut on_progress) {
Ok(()) => break,
Err(error) => {
let after = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if after > before {
log::warn!("{name}: curl interrupted at {after} bytes, resuming: {error}");
stalls = 0;
} else {
stalls += 1;
log::warn!(
"{name}: curl made no progress ({stalls}/{MAX_STALL_RETRIES}): {error}"
);
if stalls >= MAX_STALL_RETRIES {
// Discard the partial so a future attempt restarts clean
// rather than getting stuck re-resuming a bad file.
let _ = std::fs::remove_file(&part);
return Err(error);
}
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
}
if let Some(total) = total {
let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if got < total {
anyhow::bail!("{name}: incomplete after curl ({got}/{total} bytes)");
}
}
std::fs::rename(&part, destination)?;
Ok(())
}
/// Size probe via `curl -r 0-0` (a 1-byte Range request), parsing the total
/// from the `Content-Range: bytes 0-0/<total>` header. Uses curl rather than
/// ureq so no part of the download path depends on ureq (which hangs on this
/// VM's TLS stack). Returns None if the server doesn't report a size.
fn remote_content_length(url: &str) -> Option<u64> {
let mut command = Command::new("curl.exe");
command.args([
"-sL",
"-r",
"0-0",
"-D",
"-",
"-o",
"NUL",
"--connect-timeout",
"30",
"--max-time",
"30",
url,
]);
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
let output = command.output().ok()?;
let headers = String::from_utf8_lossy(&output.stdout);
for line in headers.lines() {
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-range:") {
if let Some(total) = rest.rsplit('/').next().map(str::trim) {
if let Ok(n) = total.parse::<u64>() {
return Some(n);
}
}
}
}
None
}
/// Run one `curl.exe` download to `dest`, resuming from any partial file, while
/// reporting progress from the growing file size. Returns an error (leaving the
/// partial in place) if curl exits non-zero.
fn run_curl_download(
url: &str,
dest: &Path,
total: Option<u64>,
on_progress: &mut impl FnMut(u64, Option<u64>),
) -> Result<()> {
let mut command = Command::new("curl.exe");
command
.arg("-fSL") // fail on HTTP errors, follow redirects, show errors
.args(["-C", "-"]) // resume from the existing output file
.args(["--retry", "3", "--retry-delay", "1", "--retry-connrefused"])
.args(["--connect-timeout", "30"])
// Abort (then --retry resumes) if under 1 KB/s for 30s — a real
// inactivity timeout, which is what ureq couldn't deliver here.
.args(["--speed-limit", "1024", "--speed-time", "30"])
.arg("-s") // no progress meter (we watch the file instead)
.arg("-o")
.arg(dest)
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
let mut child = command
.spawn()
.map_err(|e| anyhow::anyhow!("failed to launch curl.exe (required for downloads): {e}"))?;
loop {
if let Some(status) = child.try_wait()? {
if status.success() {
return Ok(());
}
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut stderr);
}
anyhow::bail!(
"curl exited with {}: {}",
status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into()),
stderr.trim()
);
}
let downloaded = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
on_progress(downloaded, total);
std::thread::sleep(std::time::Duration::from_millis(300));
}
}
fn download_nuget_file(
source_url: &str,
archive_path: &str,
destination: &Path,
on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
// Download the .nupkg (a zip) resiliently, then extract the one DLL.
let package = destination.with_extension("nupkg");
download_file_resilient(source_url, &package, on_progress)?;
log::info!("extracting {archive_path} from package");
let file = std::fs::File::open(&package)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut dll = archive.by_name(archive_path)?;
let temp_destination = destination.with_extension("tmp");
{
let mut out = std::fs::File::create(&temp_destination)?;
std::io::copy(&mut dll, &mut out)?;
}
std::fs::rename(&temp_destination, destination)?;
let _ = std::fs::remove_file(&package);
log::info!("extracted {archive_path}");
Ok(())
}
fn run_vision_encoder(session: &mut Session, image_path: &Path) -> Result<TensorData> {
let pixels = preprocess_image(image_path)?;
let input = Tensor::from_array(([1usize, 3, 768, 768], pixels.into_boxed_slice()))
+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());
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Resilient file downloads via the system `curl` binary.
//!
//! Used for all large model/runtime downloads (tagger models, ONNX Runtime
//! DLLs, caption model). ureq's read timeout does not fire on a stalled large
//! transfer on Windows (schannel doesn't honor the socket read timeout), so a
//! stall there hangs forever. curl detects an inactivity stall
//! (`--speed-time`), resumes from the partial file (`-C -`), and retries
//! internally — the same behavior a browser gets.
use anyhow::Result;
use std::io::Read;
use std::path::Path;
use std::process::Command;
// Suppress the console window when spawning curl from the GUI app.
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// Discard target for curl output we only need the headers of.
#[cfg(target_os = "windows")]
const NULL_DEVICE: &str = "NUL";
#[cfg(not(target_os = "windows"))]
const NULL_DEVICE: &str = "/dev/null";
/// Build a `curl` command with platform quirks applied. Windows resolves the
/// bare name to `curl.exe` (bundled since Windows 10 1803) and needs the
/// no-window flag; macOS ships curl; Linux is expected to have it installed.
fn curl_command() -> Command {
#[allow(unused_mut)]
let mut command = Command::new("curl");
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
command
}
// Give up only after this many *consecutive* curl runs that download nothing;
// a run that makes any progress resets the counter, so a large file completes
// across however many resumes it takes. Kept low so a hard stall (e.g. a
// broken VM NIC) fails in a couple of minutes — surfacing a retryable error —
// rather than locking the UI on "preparing" for many minutes.
const MAX_STALL_RETRIES: usize = 3;
/// Resiliently download `url` to `destination` using the system `curl`.
///
/// We monitor the `.part` file's size for the progress bar and wrap curl in an
/// outer progress-aware retry as a backstop. The partial file survives an app
/// restart, so a later retry continues from disk.
pub fn download_file_resilient(
url: &str,
destination: &Path,
mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
let part = match destination.extension() {
Some(ext) => destination.with_extension(format!("{}.part", ext.to_string_lossy())),
None => destination.with_extension("part"),
};
let name = destination
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| url.to_string());
log::info!("{name}: resolving download size");
let total = remote_content_length(url);
// Reconcile any existing `.part` against the real size: exactly complete →
// finish; oversized (stale/corrupt) → discard so curl restarts cleanly
// (otherwise `curl -C -` would 416 forever).
if let Some(total) = total {
let size = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if size == total {
std::fs::rename(&part, destination)?;
return Ok(());
}
if size > total {
let _ = std::fs::remove_file(&part);
}
}
log::info!(
"{name}: downloading via curl ({} bytes)",
total
.map(|t| t.to_string())
.unwrap_or_else(|| "unknown size".into())
);
let mut stalls = 0usize;
loop {
let before = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
match run_curl_download(url, &part, total, &mut on_progress) {
Ok(()) => break,
Err(error) => {
let after = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if after > before {
log::warn!("{name}: curl interrupted at {after} bytes, resuming: {error}");
stalls = 0;
} else {
stalls += 1;
log::warn!(
"{name}: curl made no progress ({stalls}/{MAX_STALL_RETRIES}): {error}"
);
if stalls >= MAX_STALL_RETRIES {
// Discard the partial so a future attempt restarts clean
// rather than getting stuck re-resuming a bad file.
let _ = std::fs::remove_file(&part);
return Err(error);
}
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
}
if let Some(total) = total {
let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if got < total {
anyhow::bail!("{name}: incomplete after curl ({got}/{total} bytes)");
}
}
std::fs::rename(&part, destination)?;
Ok(())
}
/// Size probe via `curl -r 0-0` (a 1-byte Range request), parsing the total
/// from the `Content-Range: bytes 0-0/<total>` header. Uses curl rather than
/// ureq so no part of the download path depends on ureq (which hangs on this
/// VM's TLS stack). Returns None if the server doesn't report a size.
fn remote_content_length(url: &str) -> Option<u64> {
let mut command = curl_command();
command.args([
"-sL",
"-r",
"0-0",
"-D",
"-",
"-o",
NULL_DEVICE,
"--connect-timeout",
"30",
"--max-time",
"30",
"--",
url,
]);
let output = command.output().ok()?;
let headers = String::from_utf8_lossy(&output.stdout);
for line in headers.lines() {
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-range:") {
if let Some(total) = rest.rsplit('/').next().map(str::trim) {
if let Ok(n) = total.parse::<u64>() {
return Some(n);
}
}
}
}
None
}
/// Run one `curl` download to `dest`, resuming from any partial file, while
/// reporting progress from the growing file size. Returns an error (leaving the
/// partial in place) if curl exits non-zero.
fn run_curl_download(
url: &str,
dest: &Path,
total: Option<u64>,
on_progress: &mut impl FnMut(u64, Option<u64>),
) -> Result<()> {
let mut command = curl_command();
command
.arg("-fSL") // fail on HTTP errors, follow redirects, show errors
.args(["-C", "-"]) // resume from the existing output file
.args(["--retry", "3", "--retry-delay", "1", "--retry-connrefused"])
.args(["--connect-timeout", "30"])
// Abort (then --retry resumes) if under 1 KB/s for 30s — a real
// inactivity timeout, which is what ureq couldn't deliver here.
.args(["--speed-limit", "1024", "--speed-time", "30"])
.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());
let mut child = command
.spawn()
.map_err(|e| anyhow::anyhow!("failed to launch curl (required for downloads): {e}"))?;
loop {
if let Some(status) = child.try_wait()? {
if status.success() {
return Ok(());
}
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut stderr);
}
anyhow::bail!(
"curl exited with {}: {}",
status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into()),
stderr.trim()
);
}
let downloaded = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
on_progress(downloaded, total);
std::thread::sleep(std::time::Duration::from_millis(300));
}
}
/// Download a NuGet package (a zip) resiliently, then extract the single file
/// at `archive_path` into `destination`.
pub fn download_nuget_file(
source_url: &str,
archive_path: &str,
destination: &Path,
on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
let package = destination.with_extension("nupkg");
download_file_resilient(source_url, &package, on_progress)?;
log::info!("extracting {archive_path} from package");
let file = std::fs::File::open(&package)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut dll = archive.by_name(archive_path)?;
let temp_destination = destination.with_extension("tmp");
{
let mut out = std::fs::File::create(&temp_destination)?;
std::io::copy(&mut dll, &mut out)?;
}
std::fs::rename(&temp_destination, destination)?;
let _ = std::fs::remove_file(&package);
log::info!("extracted {archive_path}");
Ok(())
}
+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");
}
}
+2
View File
@@ -3,10 +3,12 @@ mod captioner;
mod color;
mod commands;
mod db;
mod download;
mod embedder;
mod hnsw_index;
mod indexer;
mod media;
mod onnx_runtime;
mod storage;
mod tagger;
mod thumbnail;
+137
View File
@@ -0,0 +1,137 @@
//! Shared ONNX Runtime provisioning: downloading the runtime + DirectML DLLs
//! and initializing `ort` from them.
//!
//! Both ONNX consumers go through here — the tagger (live) and the Florence-2
//! captioner (backend intact, UI disabled). The DLLs are Windows/DirectML
//! specific; a future cross-platform build needs a per-OS runtime strategy.
use crate::download;
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const ONNX_RUNTIME_NUGET_URL: &str =
"https://www.nuget.org/api/v2/package/Microsoft.ML.OnnxRuntime.DirectML/1.24.2";
const DIRECTML_NUGET_URL: &str =
"https://www.nuget.org/api/v2/package/Microsoft.AI.DirectML/1.15.4";
pub const ONNX_RUNTIME_DLL_FILE: &str = "onnxruntime/onnxruntime.dll";
pub const ONNX_RUNTIME_PROVIDERS_DLL_FILE: &str = "onnxruntime/onnxruntime_providers_shared.dll";
pub const DIRECTML_DLL_FILE: &str = "onnxruntime/DirectML.dll";
/// The shared runtime DLLs, as paths relative to [`runtime_dir`].
pub const RUNTIME_DLLS: &[&str] = &[
ONNX_RUNTIME_DLL_FILE,
ONNX_RUNTIME_PROVIDERS_DLL_FILE,
DIRECTML_DLL_FILE,
];
/// `(destination_file, nuget_package_url, path_inside_package)` for each DLL.
pub const ONNX_RUNTIME_FILES: &[(&str, &str, &str)] = &[
(
ONNX_RUNTIME_DLL_FILE,
ONNX_RUNTIME_NUGET_URL,
"runtimes/win-x64/native/onnxruntime.dll",
),
(
ONNX_RUNTIME_PROVIDERS_DLL_FILE,
ONNX_RUNTIME_NUGET_URL,
"runtimes/win-x64/native/onnxruntime_providers_shared.dll",
),
(
DIRECTML_DLL_FILE,
DIRECTML_NUGET_URL,
"bin/x64-win/DirectML.dll",
),
];
// Mutex<bool> rather than OnceLock<Result>: a failed attempt (DLL not yet
// downloaded) must NOT be cached, or a later successful download could never
// recover within the same app session.
static ORT_RUNTIME_INIT: Mutex<bool> = Mutex::new(false);
/// Directory the shared runtime DLLs are provisioned into.
///
/// Historically the DLLs were downloaded as part of the Florence-2 caption
/// model, so they live inside that model's directory
/// (`models/florence-2-base-ft/onnxruntime/`). The location is kept even
/// though the tagger is now the main consumer, so existing installs don't
/// have to re-download the runtime.
pub fn runtime_dir(app_data_dir: &Path) -> PathBuf {
crate::captioner::model_dir(app_data_dir)
}
/// Initialize `ort` from the already-downloaded runtime DLL in `local_dir`.
/// Fails if the DLL is missing — use [`provision_onnx_runtime_with_progress`]
/// to download it first on a clean install.
pub fn ensure_onnx_runtime(local_dir: &Path) -> Result<()> {
let mut initialized = ORT_RUNTIME_INIT
.lock()
.map_err(|_| anyhow::anyhow!("ONNX runtime init lock poisoned"))?;
if *initialized {
return Ok(());
}
let dll_path = local_dir.join(ONNX_RUNTIME_DLL_FILE);
if !dll_path.exists() {
anyhow::bail!("ONNX Runtime DLL is missing: {}", dll_path.display());
}
ort::environment::init_from(&dll_path)
.map_err(|error| anyhow::anyhow!(error.to_string()))?
.with_name("phokus-florence")
.commit();
*initialized = true;
Ok(())
}
/// Download any ONNX Runtime DLLs missing from `local_dir`, reporting per-file
/// byte progress as `(short_label, downloaded_bytes, total_bytes)`.
/// `total_bytes` is `None` when the server omits Content-Length. Unlike
/// `ensure_onnx_runtime` (init only), this actually provisions the files —
/// callers that can run on a clean install must call this first. The callback
/// fires per chunk; callers should throttle.
pub fn provision_onnx_runtime_with_progress(
local_dir: &Path,
mut on_progress: impl FnMut(&str, u64, Option<u64>),
) -> Result<()> {
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
let destination = local_dir.join(destination_file);
if destination.exists() {
continue;
}
// Strip the "onnxruntime/" prefix for a clean label.
let label = destination_file
.rsplit('/')
.next()
.unwrap_or(destination_file);
download::download_nuget_file(
source_url,
archive_path,
&destination,
|downloaded, total| on_progress(label, downloaded, total),
)?;
}
Ok(())
}
/// Number of ONNX Runtime DLLs still missing from `local_dir` (for progress
/// step counts before downloading).
pub fn missing_onnx_runtime_count(local_dir: &Path) -> usize {
ONNX_RUNTIME_FILES
.iter()
.filter(|(destination_file, _, _)| !local_dir.join(destination_file).exists())
.count()
}
/// Download all missing runtime DLLs without progress reporting.
pub fn download_onnx_runtime_files(local_dir: &Path) -> Result<()> {
if !cfg!(target_os = "windows") {
anyhow::bail!("ONNX Runtime DLL download is currently configured for Windows builds");
}
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
let destination = local_dir.join(destination_file);
download::download_nuget_file(source_url, archive_path, &destination, |_, _| {})?;
}
Ok(())
}
+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
);
}
}
+21 -35
View File
@@ -31,15 +31,6 @@ const JOYTAG_STD: [f32; 3] = [0.268_629_54, 0.261_302_6, 0.275_777_1];
// JoyTag's recommended detection threshold.
const JOYTAG_DEFAULT_THRESHOLD: f32 = 0.4;
// Shared ONNX Runtime DLLs (live in the caption model's `onnxruntime/` dir and
// are reused by the tagger). The per-model weight + label files are listed by
// `TaggerModel::download_files`.
const TAGGER_RUNTIME_DLLS: &[&str] = &[
"onnxruntime/onnxruntime.dll",
"onnxruntime/onnxruntime_providers_shared.dll",
"onnxruntime/DirectML.dll",
];
// Tags in these Danbooru categories are kept in the output.
// Category 0 = general, category 4 = character.
// Category 9 = rating (explicit/questionable/sensitive/general) used for
@@ -354,12 +345,11 @@ pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<u
pub fn tagger_model_status(app_data_dir: &Path) -> TaggerModelStatus {
let model = tagger_model(app_data_dir);
let local_dir = model_dir(app_data_dir);
// The ONNX runtime DLLs live in the caption model dir; reuse them.
let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
let runtime_dir = crate::onnx_runtime::runtime_dir(app_data_dir);
let mut missing_files: Vec<String> = TAGGER_RUNTIME_DLLS
let mut missing_files: Vec<String> = crate::onnx_runtime::RUNTIME_DLLS
.iter()
.filter(|file| !caption_model_dir.join(file).exists())
.filter(|file| !runtime_dir.join(file).exists())
.map(|file| (*file).to_string())
.collect();
missing_files.extend(
@@ -387,15 +377,14 @@ pub fn prepare_tagger_model_with_progress(
let local_dir = model_dir(app_data_dir);
std::fs::create_dir_all(&local_dir)?;
// The tagger shares the ONNX runtime DLLs with the captioner; download
// them here so the tagger works even on a clean install where the caption
// model has never been fetched (ensure_onnx_runtime only initializes).
// Download the shared ONNX Runtime DLLs here so the tagger works even on
// a clean install (ensure_onnx_runtime only initializes).
let download_files = model.download_files();
let caption_model_dir = crate::captioner::model_dir(app_data_dir);
std::fs::create_dir_all(&caption_model_dir)?;
let runtime_dir = crate::onnx_runtime::runtime_dir(app_data_dir);
std::fs::create_dir_all(&runtime_dir)?;
// Unified step count across DLLs and tagger files so the bar is coherent.
let dll_count = crate::captioner::missing_onnx_runtime_count(&caption_model_dir);
let dll_count = crate::onnx_runtime::missing_onnx_runtime_count(&runtime_dir);
let model_pending = download_files
.iter()
.filter(|file| !local_dir.join(file).exists())
@@ -415,8 +404,8 @@ pub fn prepare_tagger_model_with_progress(
// ── ONNX runtime DLLs (small, but non-trivial on a slow link) ──
{
let mut last_emit = Instant::now() - std::time::Duration::from_secs(1);
crate::captioner::provision_onnx_runtime_with_progress(
&caption_model_dir,
crate::onnx_runtime::provision_onnx_runtime_with_progress(
&runtime_dir,
|label, downloaded, total| {
if last_emit.elapsed() >= std::time::Duration::from_millis(200) {
last_emit = Instant::now();
@@ -434,7 +423,7 @@ pub fn prepare_tagger_model_with_progress(
completed_files += dll_count;
}
log::info!("Tagger: ONNX runtime DLLs ready; initializing runtime");
crate::captioner::ensure_onnx_runtime(&caption_model_dir)?;
crate::onnx_runtime::ensure_onnx_runtime(&runtime_dir)?;
log::info!("Tagger: runtime initialized; downloading model files");
// ── Tagger model files (model.onnx is ~446 MB) ──
@@ -452,7 +441,7 @@ pub fn prepare_tagger_model_with_progress(
let url = repo.url(file);
let label = (*file).to_string();
let mut last_emit = Instant::now() - std::time::Duration::from_secs(1);
crate::captioner::download_file_resilient(&url, &destination, |downloaded, total| {
crate::download::download_file_resilient(&url, &destination, |downloaded, total| {
if last_emit.elapsed() >= std::time::Duration::from_millis(200) {
last_emit = Instant::now();
emit_progress(TaggerModelProgress {
@@ -500,8 +489,7 @@ pub fn probe_tagger_runtime(app_data_dir: &Path) -> Result<TaggerRuntimeProbe> {
}
let local_dir = model_dir(app_data_dir);
let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
crate::captioner::ensure_onnx_runtime(&caption_model_dir)?;
crate::onnx_runtime::ensure_onnx_runtime(&crate::onnx_runtime::runtime_dir(app_data_dir))?;
let acceleration = tagger_acceleration(app_data_dir);
let model_path = local_dir.join("model.onnx");
@@ -663,13 +651,11 @@ impl WdTagger {
let local_dir = model_dir(app_data_dir);
// The ONNX runtime DLLs are shared with the captioner; use the
// captioner's shared ORT init lock to avoid double-initialisation.
let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
crate::captioner::ensure_onnx_runtime(&caption_model_dir).map_err(|e| {
let runtime_dir = crate::onnx_runtime::runtime_dir(app_data_dir);
crate::onnx_runtime::ensure_onnx_runtime(&runtime_dir).map_err(|e| {
anyhow::anyhow!(
"ONNX Runtime not initialised — download the Florence-2 caption model first \
to get the shared runtime DLLs. Original error: {e}"
"ONNX Runtime not initialised — the shared runtime DLLs are missing; \
re-run the tagger model download. Original error: {e}"
)
})?;
@@ -856,11 +842,11 @@ impl JoyTagger {
let local_dir = model_dir(app_data_dir);
// Shared ONNX runtime DLLs (see WdTagger::new).
let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
crate::captioner::ensure_onnx_runtime(&caption_model_dir).map_err(|e| {
let runtime_dir = crate::onnx_runtime::runtime_dir(app_data_dir);
crate::onnx_runtime::ensure_onnx_runtime(&runtime_dir).map_err(|e| {
anyhow::anyhow!(
"ONNX Runtime not initialised — download the Florence-2 caption model first \
to get the shared runtime DLLs. Original error: {e}"
"ONNX Runtime not initialised — the shared runtime DLLs are missing; \
re-run the tagger model download. Original error: {e}"
)
})?;
+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());
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { UpdateToast } from './components/UpdateToast'
import { WhatsNewToast } from './components/WhatsNewToast'
import { WhatsNewModal } from './components/WhatsNewModal'
import { OnboardingOverlay } from './components/onboarding/OnboardingOverlay'
import { DemoPanel } from './components/DemoPanel'
import { DemoPanel } from './dev/DemoPanel'
import { initializeNotifications } from './notifications'
export default function App() {
+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
+1 -3
View File
@@ -188,9 +188,7 @@ function RailSections({ sections }: { sections: ChangelogSection[] }) {
})}
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<p
className={`mb-3 text-[11px] font-semibold tracking-[0.1em] uppercase ${style.label}`}
>
<p className={`mb-3 text-[11px] font-semibold tracking-[0.1em] uppercase ${style.label}`}>
{section.title}
</p>
<ItemList items={section.items} dot={style.dot} />
@@ -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')
})
})
+21 -7
View File
@@ -39,14 +39,28 @@ 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 &&
folderWorkers.thumbnail &&
folderWorkers.metadata &&
folderWorkers.embedding &&
folderWorkers.tagging
// "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 &&
folderWorkers.tagging
const isIndexing = progress && !progress.done
const isMissing = !!folder.scan_error && !isIndexing
@@ -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,
}
}
-18
View File
@@ -1,18 +0,0 @@
import { test, expect } from '@playwright/test'
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/')
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/)
})
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/')
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click()
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible()
})
+93
View File
@@ -0,0 +1,93 @@
import { test, expect, type Page } from '@playwright/test'
// Smoke tests against the Phokus UI Lab (docs/ui-lab.md) — a browser-only dev
// mode that runs the real App.tsx/store/components against seeded, in-memory
// mock fixtures selected via the `?scenario=` query param.
const browserErrors = new WeakMap<Page, string[]>()
test.beforeEach(async ({ page }) => {
const errors: string[] = []
browserErrors.set(page, errors)
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
page.on('pageerror', (error) => errors.push(error.message))
})
test.afterEach(async ({ page }) => {
expect(browserErrors.get(page) ?? []).toEqual([])
})
test.describe('gallery scenarios', () => {
test('rich scenario renders the gallery grid with folders in the sidebar', async ({ page }) => {
await page.goto('/?scenario=rich')
await expect(page.getByText('Camera Roll', { exact: true })).toBeVisible()
await expect(page.getByText('Client Selects', { exact: true })).toBeVisible()
await expect(page.getByText('Video Archive', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /^Open / }).first()).toBeVisible()
})
test('filename search filters the visible gallery results', async ({ page }) => {
await page.goto('/?scenario=rich')
const search = page.getByPlaceholder('Search files, or use /s /t')
await search.fill('coastal-walk-003')
await expect(page.getByText('Filename')).toBeVisible()
await expect(page.getByRole('button', { name: 'Open coastal-walk-003.jpg' })).toBeVisible()
await expect(page.getByRole('button', { name: /^Open / })).toHaveCount(1)
})
test('empty scenario shows the empty library without the onboarding tour', async ({ page }) => {
await page.goto('/?scenario=empty')
await expect(page.getByText('No media found')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Welcome' })).not.toBeVisible()
})
test('new-user scenario opens the onboarding tour on step 1', async ({ page }) => {
await page.goto('/?scenario=new-user')
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible()
await expect(page.getByText('Step 1 of 8')).toBeVisible()
})
test('duplicates fixture data shows cached groups in Duplicate Finder', async ({ page }) => {
await page.goto('/?scenario=duplicates')
await page.getByText('Duplicates', { exact: true }).click()
await expect(page.getByRole('heading', { name: 'Duplicate Finder' })).toBeVisible()
await expect(page.getByText(/\d+ groups? · .* reclaimable/)).toBeVisible()
await expect(page.getByRole('button', { name: 'Select all duplicates' })).toBeVisible()
})
})
test.describe("what's new", () => {
test('just-updated scenario shows the toast and opens the modal on click', async ({ page }) => {
await page.goto('/?scenario=just-updated')
const toastButton = page.getByRole('button', { name: "What's new" })
await expect(toastButton).toBeVisible()
await toastButton.click()
await expect(page.getByRole('heading', { name: /^Phokus v/ })).toBeVisible()
})
test('just-updated + unreleased changelog opens the modal with the section nav rail', async ({
page,
}) => {
await page.goto('/?scenario=just-updated&changelog=unreleased')
await page.getByRole('button', { name: "What's new" }).click()
await expect(page.getByRole('heading', { name: /^Phokus v/ })).toBeVisible()
const sectionRail = page.getByRole('navigation')
await expect(sectionRail.getByRole('button', { name: /Added/ })).toBeVisible()
await expect(sectionRail.getByRole('button', { name: /Changed/ })).toBeVisible()
})
})
+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,