diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f000579..4b00dc3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -162,6 +162,8 @@ pub struct TagSearchParams { pub media_kind: Option, pub favorites_only: Option, pub rating_min: Option, + /// Optional `[r, g, b]` color filter applied within the tag result set. + pub color: Option<[u8; 3]>, pub limit: Option, pub offset: Option, } @@ -957,6 +959,7 @@ pub async fn search_images_by_tag( let conn = db.get().map_err(|e| e.to_string())?; let limit = params.limit.unwrap_or(64); let offset = params.offset.unwrap_or(0); + let color = params.color.map(|[r, g, b]| (r, g, b)); let (images, total) = db::search_images_by_tag( &conn, ¶ms.query, @@ -964,6 +967,7 @@ pub async fn search_images_by_tag( params.media_kind.as_deref(), params.favorites_only.unwrap_or(false), params.rating_min.unwrap_or(0), + color, limit, offset, ) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 29ba254..8249ef0 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -2244,6 +2244,7 @@ pub fn search_images_by_tag( media_kind: Option<&str>, favorites_only: bool, rating_min: i64, + color: Option<(u8, u8, u8)>, limit: usize, offset: usize, ) -> Result<(Vec, usize)> { @@ -2252,6 +2253,10 @@ pub fn search_images_by_tag( return Ok((Vec::new(), 0)); } let favorites_flag = i64::from(favorites_only); + let (color_flag, qr, qg, qb) = match color { + Some((r, g, b)) => (1i64, r as i64, g as i64, b as i64), + None => (0, 0, 0, 0), + }; // Total count (for pagination) let total: usize = conn.query_row( @@ -2262,13 +2267,25 @@ pub fn search_images_by_tag( AND (?2 IS NULL OR i.media_kind = ?2) AND (?3 = 0 OR i.favorite = 1) AND i.rating >= ?4 - AND LOWER(TRIM(t.tag)) = ?5", + AND LOWER(TRIM(t.tag)) = ?5 + AND (?6 = 0 OR EXISTS ( + SELECT 1 FROM image_colors c + WHERE c.image_id = i.id + AND c.weight >= ?11 + AND ((c.r - ?7)*(c.r - ?7) + (c.g - ?8)*(c.g - ?8) + (c.b - ?9)*(c.b - ?9)) <= ?10 + ))", params![ folder_id, media_kind, favorites_flag, rating_min, - normalized_query + normalized_query, + color_flag, + qr, + qg, + qb, + crate::color::MATCH_DISTANCE_SQ, + crate::color::MATCH_MIN_WEIGHT ], |row| row.get::<_, i64>(0), )? as usize; @@ -2282,8 +2299,14 @@ pub fn search_images_by_tag( AND (?3 = 0 OR i.favorite = 1) AND i.rating >= ?4 AND LOWER(TRIM(t.tag)) = ?5 + AND (?6 = 0 OR EXISTS ( + SELECT 1 FROM image_colors c + WHERE c.image_id = i.id + AND c.weight >= ?11 + AND ((c.r - ?7)*(c.r - ?7) + (c.g - ?8)*(c.g - ?8) + (c.b - ?9)*(c.b - ?9)) <= ?10 + )) ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC - LIMIT ?6 OFFSET ?7", + LIMIT ?12 OFFSET ?13", )?; let image_ids = stmt @@ -2294,6 +2317,12 @@ pub fn search_images_by_tag( favorites_flag, rating_min, normalized_query, + color_flag, + qr, + qg, + qb, + crate::color::MATCH_DISTANCE_SQ, + crate::color::MATCH_MIN_WEIGHT, limit as i64, offset as i64 ], diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 58f86bd..7269d9c 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -9,6 +9,36 @@ let nextAlbumId = 100; let nextTagId = 10_000; type AnyPayload = Record | undefined; +type Rgb = [number, number, number]; + +const mockPalette: Rgb[] = [ + [59, 125, 216], + [31, 182, 166], + [76, 175, 80], + [242, 207, 46], + [232, 134, 46], + [226, 59, 59], + [139, 92, 246], + [236, 72, 153], + [139, 90, 43], + [26, 26, 26], + [245, 245, 245], + [154, 160, 166], +]; + +function isRgb(value: unknown): value is Rgb { + return Array.isArray(value) && value.length === 3 && value.every((entry) => typeof entry === "number"); +} + +function colorDistanceSq(left: Rgb, right: Rgb): number { + return (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2 + (left[2] - right[2]) ** 2; +} + +function matchesMockColor(image: ImageRecord, color: Rgb): boolean { + const primary = mockPalette[(image.id - 1) % mockPalette.length]; + const secondary = mockPalette[(image.folder_id + image.rating) % mockPalette.length]; + return colorDistanceSq(primary, color) <= 4_900 || colorDistanceSq(secondary, color) <= 4_900; +} function params(payload: unknown): Record { if (!payload || typeof payload !== "object") return {}; @@ -28,6 +58,7 @@ function page(items: T[], offset = 0, limit = 200) { function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] { const p = params(payload); const query = String(p.search ?? p.query ?? "").trim().toLowerCase(); + const color = isRgb(p.color) ? p.color : null; return input.filter((image) => { if (p.folder_id !== undefined && p.folder_id !== null && image.folder_id !== p.folder_id) return false; if (p.media_kind && image.media_kind !== p.media_kind) return false; @@ -36,6 +67,7 @@ function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] { if (p.embedding_failed_only && image.embedding_status !== "failed") return false; if (p.tagging_failed_only && !image.ai_tagger_error) return false; if (query && !image.filename.toLowerCase().includes(query)) return false; + if (color && !matchesMockColor(image, color)) return false; return true; }); } diff --git a/src/store.ts b/src/store.ts index f044e57..f97cd6a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1161,6 +1161,7 @@ export const useGalleryStore = create((set, get) => ({ media_kind: mediaFilter === "all" ? null : mediaFilter, favorites_only: favoritesOnly, rating_min: minimumRating > 0 ? minimumRating : null, + color: colorFilter, limit: PAGE_SIZE, offset, },