Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bc4a98164 | |||
| 6923777345 | |||
| 96e62cb7c1 | |||
| 42564a93e0 |
+22
-6
@@ -1304,6 +1304,13 @@ 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 +2134,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 +2150,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 +2201,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 +2213,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 +2349,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 +2357,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 +3332,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() {
|
||||
|
||||
@@ -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