10 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
18 changed files with 378 additions and 40 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
+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
@@ -1,7 +1,7 @@
{
"name": "phokus",
"private": true,
"version": "0.1.1",
"version": "0.2.0",
"license": "MIT",
"type": "module",
"scripts": {
+1 -1
View File
@@ -4595,7 +4595,7 @@ dependencies = [
[[package]]
name = "phokus"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"anyhow",
"candle-core",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "phokus"
version = "0.1.1"
version = "0.2.0"
description = "Local-first desktop media library"
authors = ["JezzWTF"]
license = "MIT"
+6 -2
View File
@@ -2,7 +2,9 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default",
@@ -19,6 +21,8 @@
"core:window:allow-minimize",
"core:window:allow-close",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized"
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging"
]
}
+25 -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",
@@ -3325,6 +3335,15 @@ 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();
+2
View File
@@ -142,6 +142,7 @@ fn remote_content_length(url: &str) -> Option<u64> {
"30",
"--max-time",
"30",
"--",
url,
]);
let output = command.output().ok()?;
@@ -179,6 +180,7 @@ fn run_curl_download(
.arg("-s") // no progress meter (we watch the file instead)
.arg("-o")
.arg(dest)
.arg("--")
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
+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
+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([])
})
})
+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
},
})
+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