9 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
16 changed files with 350 additions and 30 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"
+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
@@ -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