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:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
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/**'
|
||||||
- 'src-tauri/**'
|
- 'src-tauri/**'
|
||||||
|
- 'tsconfig*.json'
|
||||||
|
- 'vite.config.ts'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
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/**'
|
||||||
- 'src-tauri/**'
|
- 'src-tauri/**'
|
||||||
|
- 'tsconfig*.json'
|
||||||
|
- 'vite.config.ts'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -21,7 +44,9 @@ jobs:
|
|||||||
# windows-latest to match the release target — the ML crates (ort, candle)
|
# windows-latest to match the release target — the ML crates (ort, candle)
|
||||||
# and the NSIS bundle only ever ship from Windows.
|
# and the NSIS bundle only ever ship from Windows.
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
|
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Report pending status to Gitea
|
- name: Report pending status to Gitea
|
||||||
@@ -58,10 +83,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: pnpm test:unit
|
||||||
|
|
||||||
# tsc + vite build; also produces dist/ which tauri's build script expects
|
# tsc + vite build; also produces dist/ which tauri's build script expects
|
||||||
- name: Type-check and build frontend
|
- name: Type-check and build frontend
|
||||||
run: pnpm build:vite
|
run: pnpm build:vite
|
||||||
@@ -85,6 +114,10 @@ jobs:
|
|||||||
working-directory: src-tauri
|
working-directory: src-tauri
|
||||||
run: cargo clippy --all-targets --locked --no-default-features -- -D warnings
|
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
|
- name: Report final status to Gitea
|
||||||
if: always() && github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
|
if: always() && github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
@@ -8,15 +8,59 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
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:
|
jobs:
|
||||||
release:
|
release:
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 120
|
||||||
env:
|
env:
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
|
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||||
steps:
|
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
|
- name: Report pending status to Gitea
|
||||||
if: env.GITEA_STATUS_TOKEN != ''
|
if: env.GITEA_STATUS_TOKEN != ''
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
@@ -36,13 +80,11 @@ jobs:
|
|||||||
} | ConvertTo-Json
|
} | ConvertTo-Json
|
||||||
Invoke-RestMethod `
|
Invoke-RestMethod `
|
||||||
-Method Post `
|
-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 `
|
-Headers $headers `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 11
|
version: 11
|
||||||
@@ -51,6 +93,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -71,7 +114,7 @@ jobs:
|
|||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
with:
|
with:
|
||||||
tagName: ${{ github.ref_name }}
|
tagName: ${{ env.RELEASE_TAG }}
|
||||||
releaseName: 'Phokus v__VERSION__'
|
releaseName: 'Phokus v__VERSION__'
|
||||||
releaseBody: 'See the assets below to download and install this version.'
|
releaseBody: 'See the assets below to download and install this version.'
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
@@ -105,9 +148,11 @@ jobs:
|
|||||||
description = "GitHub Actions release finished: $env:JOB_STATUS"
|
description = "GitHub Actions release finished: $env:JOB_STATUS"
|
||||||
target_url = $env:GITHUB_RUN_URL
|
target_url = $env:GITHUB_RUN_URL
|
||||||
} | ConvertTo-Json
|
} | 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 `
|
Invoke-RestMethod `
|
||||||
-Method Post `
|
-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 `
|
-Headers $headers `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-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)
|
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||||
(0.x: anything may change between minor versions).
|
(0.x: anything may change between minor versions).
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.2.0] — 2026-07-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -264,5 +264,6 @@ installer with a built-in updater.
|
|||||||
Settings, with live size/reclaimable stats.
|
Settings, with live size/reclaimable stats.
|
||||||
- **Window state** persistence and single-instance handling.
|
- **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.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.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",
|
"name": "phokus",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -4595,7 +4595,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phokus"
|
name = "phokus"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"candle-core",
|
"candle-core",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "phokus"
|
name = "phokus"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
description = "Local-first desktop media library"
|
description = "Local-first desktop media library"
|
||||||
authors = ["JezzWTF"]
|
authors = ["JezzWTF"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
@@ -19,6 +21,8 @@
|
|||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-toggle-maximize",
|
"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`.
|
/// True if any claimable (pending, non-excluded) jobs exist in `job_table`.
|
||||||
/// Used by lower-priority workers to defer to higher-priority queues.
|
/// Used by lower-priority workers to defer to higher-priority queues.
|
||||||
/// `extra_predicate` narrows the image join (e.g. videos only for metadata).
|
/// `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",
|
_ => "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 favorites_flag = i64::from(favorites_only);
|
||||||
let embedding_failed_flag = i64::from(embedding_failed_only);
|
let embedding_failed_flag = i64::from(embedding_failed_only);
|
||||||
let tagging_failed_flag = i64::from(tagging_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
|
ai_rating, ai_tagger_model, ai_tagged_at, ai_tagger_error
|
||||||
FROM images
|
FROM images
|
||||||
WHERE (?1 IS NULL OR folder_id = ?1)
|
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 (?3 IS NULL OR media_kind = ?3)
|
||||||
AND (?4 = 0 OR favorite = 1)
|
AND (?4 = 0 OR favorite = 1)
|
||||||
AND rating >= ?5
|
AND rating >= ?5
|
||||||
@@ -2194,7 +2204,7 @@ pub fn count_images(
|
|||||||
tagging_failed_only: bool,
|
tagging_failed_only: bool,
|
||||||
color: Option<(u8, u8, u8)>,
|
color: Option<(u8, u8, u8)>,
|
||||||
) -> Result<i64> {
|
) -> 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 favorites_flag = i64::from(favorites_only);
|
||||||
let embedding_failed_flag = i64::from(embedding_failed_only);
|
let embedding_failed_flag = i64::from(embedding_failed_only);
|
||||||
@@ -2206,7 +2216,7 @@ pub fn count_images(
|
|||||||
let count = conn.query_row(
|
let count = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM images
|
"SELECT COUNT(*) FROM images
|
||||||
WHERE (?1 IS NULL OR folder_id = ?1)
|
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 (?3 IS NULL OR media_kind = ?3)
|
||||||
AND (?4 = 0 OR favorite = 1)
|
AND (?4 = 0 OR favorite = 1)
|
||||||
AND rating >= ?5
|
AND rating >= ?5
|
||||||
@@ -2342,7 +2352,7 @@ pub fn search_tags_autocomplete(
|
|||||||
folder_id: Option<i64>,
|
folder_id: Option<i64>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<ExploreTagEntry>> {
|
) -> Result<Vec<ExploreTagEntry>> {
|
||||||
let pattern = format!("%{}%", query.to_lowercase());
|
let pattern = format!("%{}%", escape_like_pattern(&query.to_lowercase()));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id,
|
"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,
|
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
|
FROM image_tags t
|
||||||
JOIN images i ON i.id = t.image_id
|
JOIN images i ON i.id = t.image_id
|
||||||
WHERE (?1 IS NULL OR i.folder_id = ?1)
|
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
|
GROUP BY t.tag
|
||||||
ORDER BY tag_count DESC, t.tag ASC
|
ORDER BY tag_count DESC, t.tag ASC
|
||||||
LIMIT ?3",
|
LIMIT ?3",
|
||||||
@@ -3325,6 +3335,15 @@ mod tests {
|
|||||||
use super::test_support::{test_conn, test_image};
|
use super::test_support::{test_conn, test_image};
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn insert_folder_is_idempotent_per_path() {
|
fn insert_folder_is_idempotent_per_path() {
|
||||||
let conn = test_conn();
|
let conn = test_conn();
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ fn remote_content_length(url: &str) -> Option<u64> {
|
|||||||
"30",
|
"30",
|
||||||
"--max-time",
|
"--max-time",
|
||||||
"30",
|
"30",
|
||||||
|
"--",
|
||||||
url,
|
url,
|
||||||
]);
|
]);
|
||||||
let output = command.output().ok()?;
|
let output = command.output().ok()?;
|
||||||
@@ -179,6 +180,7 @@ fn run_curl_download(
|
|||||||
.arg("-s") // no progress meter (we watch the file instead)
|
.arg("-s") // no progress meter (we watch the file instead)
|
||||||
.arg("-o")
|
.arg("-o")
|
||||||
.arg(dest)
|
.arg(dest)
|
||||||
|
.arg("--")
|
||||||
.arg(url)
|
.arg(url)
|
||||||
.stdout(std::process::Stdio::null())
|
.stdout(std::process::Stdio::null())
|
||||||
.stderr(std::process::Stdio::piped());
|
.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 { PhokusMark } from './PhokusMark'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
|
|
||||||
const THEME_OPTIONS: { value: AppTheme; label: string }[] = [
|
const THEME_LABELS: Record<AppTheme, string> = {
|
||||||
{ value: 'phokus', label: 'Phokus' },
|
phokus: 'Phokus',
|
||||||
{ value: 'subtle-light', label: 'Subtle Light' },
|
'subtle-light': 'Subtle Light',
|
||||||
{ value: 'conventional-dark', label: 'Conventional Dark' },
|
'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
|
// SVG icons for window controls
|
||||||
function MinimizeIcon() {
|
function MinimizeIcon() {
|
||||||
@@ -110,7 +114,10 @@ export function TitleBar() {
|
|||||||
up its focal point and the chip becomes a button that re-opens the prompt. */}
|
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">
|
<div className="flex items-center gap-2 pr-4 pl-3">
|
||||||
{updatePending ? (
|
{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. */}
|
{/* Instant tooltip (delay 0) — this affordance should read immediately. */}
|
||||||
<Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
|
<Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -39,14 +39,28 @@ export function FolderItem({
|
|||||||
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds)
|
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds)
|
||||||
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused)
|
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused)
|
||||||
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id])
|
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id])
|
||||||
|
const folderJobs = useGalleryStore((state) => state.mediaJobProgress[folder.id])
|
||||||
const isMuted = mutedFolderIds.includes(folder.id)
|
const isMuted = mutedFolderIds.includes(folder.id)
|
||||||
// "Fully paused" only when every worker for this folder is paused.
|
// "Fully paused" means every worker that currently has pending work is
|
||||||
const isPausedAll =
|
// paused. Workers with nothing pending are ignored — the background tasks
|
||||||
!!folderWorkers &&
|
// panel only lets you toggle the stages it actually shows, so requiring
|
||||||
folderWorkers.thumbnail &&
|
// every worker key to be literally true left this permanently out of sync
|
||||||
folderWorkers.metadata &&
|
// (menu kept offering "Pause" after the visible work was already paused).
|
||||||
folderWorkers.embedding &&
|
const hasPendingWork =
|
||||||
folderWorkers.tagging
|
(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 isIndexing = progress && !progress.done
|
||||||
const isMissing = !!folder.scan_error && !isIndexing
|
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 { invoke } from '@tauri-apps/api/core'
|
||||||
import type { StateCreator } from 'zustand'
|
import type { StateCreator } from 'zustand'
|
||||||
import { notifyTaskComplete } from '../notifications'
|
import { notifyTaskComplete } from '../notifications'
|
||||||
|
import { invalidateDuplicateScanCaches } from './helpers'
|
||||||
import type { GalleryStore } from './index'
|
import type { GalleryStore } from './index'
|
||||||
import type { DuplicateGroup, DuplicateScanProgress, DuplicateScanResult } from './types'
|
import type { DuplicateGroup, DuplicateScanProgress, DuplicateScanResult } from './types'
|
||||||
|
|
||||||
@@ -146,10 +147,7 @@ export const createDuplicateSlice: StateCreator<GalleryStore, [], [], DuplicateS
|
|||||||
.filter((img) => succeededSet.has(img.id))
|
.filter((img) => succeededSet.has(img.id))
|
||||||
.map((img) => img.folder_id)
|
.map((img) => img.folder_id)
|
||||||
)
|
)
|
||||||
await invoke('invalidate_duplicate_scan_cache', { folderId: null }) // global
|
await invalidateDuplicateScanCaches(affectedFolderIds)
|
||||||
for (const folderId of affectedFolderIds) {
|
|
||||||
await invoke('invalidate_duplicate_scan_cache', { folderId })
|
|
||||||
}
|
|
||||||
return succeededIds.length
|
return succeededIds.length
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { StateCreator } from 'zustand'
|
|||||||
import {
|
import {
|
||||||
PAGE_SIZE,
|
PAGE_SIZE,
|
||||||
TIMELINE_PAGE_SIZE,
|
TIMELINE_PAGE_SIZE,
|
||||||
|
invalidateDuplicateScanCaches,
|
||||||
isCurrentGalleryRequest,
|
isCurrentGalleryRequest,
|
||||||
isDerivedCollectionTitle,
|
isDerivedCollectionTitle,
|
||||||
mergeImages,
|
mergeImages,
|
||||||
@@ -608,10 +609,7 @@ export const createGallerySlice: StateCreator<GalleryStore, [], [], GallerySlice
|
|||||||
}))
|
}))
|
||||||
// The DB cascade already removed these from album_images; refresh counts/covers.
|
// The DB cascade already removed these from album_images; refresh counts/covers.
|
||||||
void get().loadAlbums()
|
void get().loadAlbums()
|
||||||
await invoke('invalidate_duplicate_scan_cache', { folderId: null })
|
await invalidateDuplicateScanCaches(affectedFolderIds)
|
||||||
for (const folderId of affectedFolderIds) {
|
|
||||||
await invoke('invalidate_duplicate_scan_cache', { folderId })
|
|
||||||
}
|
|
||||||
return succeededIds.length
|
return succeededIds.length
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import type {
|
import type {
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
MediaFilter,
|
MediaFilter,
|
||||||
@@ -268,6 +269,26 @@ export function scopeHasTaggingPending(
|
|||||||
return (progressByFolder[folderId]?.tagging_pending ?? 0) > 0
|
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(
|
export function taggingProgressAffectsScope(
|
||||||
progressFolderId: number,
|
progressFolderId: number,
|
||||||
scopeFolderId: number | null
|
scopeFolderId: number | null
|
||||||
|
|||||||
Reference in New Issue
Block a user