Compare commits
10 Commits
dcc1612802
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| cad3b9c57d | |||
| e551b15aca | |||
| 0b4459365d | |||
| 4aa74d535b | |||
| 48df4f2965 | |||
| 2bc4a98164 | |||
| 6923777345 | |||
| 96e62cb7c1 | |||
| 42564a93e0 | |||
| b8d009c973 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "phokus",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -4595,7 +4595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phokus"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"candle-core",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user