Compare commits
4 Commits
b8d009c973
...
2bc4a98164
| 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`.
|
/// 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 +2134,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 +2150,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 +2201,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 +2213,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 +2349,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 +2357,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 +3332,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() {
|
||||||
|
|||||||
@@ -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