feat: highlight matched metadata on thumbnails during search
Renders a thin top-of-cell banner on each photo while a text search is active, labelling which metadata field matched (filename, title, note, tag, or EXIF key name) and showing a short excerpt with the exact matched substring highlighted in amber. Extends the list endpoint's search to also match photos whose tag names contain the query so tags show up alongside filename/title/notes/EXIF hits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
175
frontend/src/lib/searchMatch.ts
Normal file
175
frontend/src/lib/searchMatch.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Client-side re-implementation of the backend list-endpoint text search,
|
||||
* used to explain on each thumbnail *which* metadata field actually
|
||||
* matched the user's query. The backend search at
|
||||
* `backend/app/routers/photos.py` walks filename, user_title,
|
||||
* user_notes, exif_json, and tag names with case-insensitive ILIKE; we
|
||||
* mirror that here over the PhotoResponse fields that the list endpoint
|
||||
* already returns on the wire.
|
||||
*
|
||||
* Pure, deterministic, no I/O. Returns `null` when the query is empty
|
||||
* or no field matches (the latter shouldn't happen when the backend
|
||||
* and frontend logic agree, but we'd rather render nothing than lie).
|
||||
*/
|
||||
import type { Photo } from '../types/photo'
|
||||
|
||||
export type SearchMatchField =
|
||||
| 'filename'
|
||||
| 'title'
|
||||
| 'notes'
|
||||
| 'tag'
|
||||
| 'exif'
|
||||
|
||||
export interface SearchMatch {
|
||||
field: SearchMatchField
|
||||
/** Human-friendly label shown before the excerpt, e.g. "Tag" or
|
||||
* "EXIF: Make". The backend doesn't store per-field breakdowns for
|
||||
* exif so we parse the blob and name the key whose value matched. */
|
||||
label: string
|
||||
/** Short excerpt centred on the matched substring — up to ~40 chars
|
||||
* of context on either side, ellipsed when trimmed. */
|
||||
excerpt: string
|
||||
/** Char offset of the match WITHIN ``excerpt`` (not the original
|
||||
* value), so the UI can wrap the matched run in a highlight span
|
||||
* without redoing the search. */
|
||||
matchStart: number
|
||||
/** Length of the matched run inside ``excerpt``. Together with
|
||||
* ``matchStart`` this fully describes where to highlight. */
|
||||
matchLength: number
|
||||
}
|
||||
|
||||
const MAX_EXCERPT = 48
|
||||
|
||||
interface Excerpt {
|
||||
text: string
|
||||
/** Offset of the matched substring inside ``text``. */
|
||||
matchStart: number
|
||||
matchLength: number
|
||||
}
|
||||
|
||||
/** Build a compact excerpt of ``value`` centred on the first occurrence
|
||||
* of ``needle`` (case-insensitive) and return it alongside the exact
|
||||
* offset of the match inside the excerpt, so the caller can highlight
|
||||
* it without re-searching. Preserves the original casing so the user
|
||||
* reads what the metadata actually contains. */
|
||||
function excerpt(value: string, needleLower: string): Excerpt {
|
||||
const lower = value.toLowerCase()
|
||||
const idx = lower.indexOf(needleLower)
|
||||
if (idx === -1) {
|
||||
return {
|
||||
text: value.slice(0, MAX_EXCERPT),
|
||||
matchStart: 0,
|
||||
matchLength: 0,
|
||||
}
|
||||
}
|
||||
const half = Math.floor((MAX_EXCERPT - needleLower.length) / 2)
|
||||
const start = Math.max(0, idx - half)
|
||||
const end = Math.min(value.length, idx + needleLower.length + half)
|
||||
const prefix = start > 0 ? '…' : ''
|
||||
const suffix = end < value.length ? '…' : ''
|
||||
const text = prefix + value.slice(start, end) + suffix
|
||||
return {
|
||||
text,
|
||||
matchStart: prefix.length + (idx - start),
|
||||
matchLength: needleLower.length,
|
||||
}
|
||||
}
|
||||
|
||||
function wrap(
|
||||
field: SearchMatchField,
|
||||
label: string,
|
||||
ex: Excerpt,
|
||||
): SearchMatch {
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
excerpt: ex.text,
|
||||
matchStart: ex.matchStart,
|
||||
matchLength: ex.matchLength,
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk a parsed EXIF object looking for the first (key, value) pair
|
||||
* whose stringified value contains the needle. Keys are stripped of
|
||||
* their `Group:` prefix for display (EXIF output from exiftool looks
|
||||
* like `EXIF:Make` / `IFD0:Model`). Returns null when nothing inside
|
||||
* the blob matches. */
|
||||
function findExifMatch(
|
||||
exifJson: string | null | undefined,
|
||||
needleLower: string,
|
||||
): { key: string; value: string } | null {
|
||||
if (!exifJson) return null
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(exifJson)
|
||||
} catch {
|
||||
// Not JSON? Fall back to substring — the whole blob still
|
||||
// counts as "EXIF" in the chip.
|
||||
const lower = exifJson.toLowerCase()
|
||||
if (lower.includes(needleLower)) {
|
||||
return { key: 'metadata', value: exifJson }
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
for (const [rawKey, rawVal] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (rawVal == null) continue
|
||||
const str = typeof rawVal === 'string' ? rawVal : JSON.stringify(rawVal)
|
||||
if (str.toLowerCase().includes(needleLower)) {
|
||||
// Drop the `Group:` prefix some exiftool outputs carry.
|
||||
const label = rawKey.includes(':') ? rawKey.split(':').slice(-1)[0] : rawKey
|
||||
return { key: label, value: str }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Figure out which field of ``photo`` the user's ``query`` matches and
|
||||
* return a compact chip-ready description. Field priority mirrors what
|
||||
* a human would look at first: filename → title → notes → tag → EXIF.
|
||||
* Returns `null` for an empty query or (rarely) a photo that somehow
|
||||
* came back from the backend without any client-visible match.
|
||||
*/
|
||||
export function findSearchMatch(
|
||||
photo: Photo,
|
||||
query: string,
|
||||
): SearchMatch | null {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return null
|
||||
const needle = trimmed.toLowerCase()
|
||||
|
||||
if (photo.filename && photo.filename.toLowerCase().includes(needle)) {
|
||||
return wrap('filename', 'Filename', excerpt(photo.filename, needle))
|
||||
}
|
||||
|
||||
// PhotoResponse's user_title/user_notes aren't on the Photo type used
|
||||
// by the timeline (that's the lighter list shape), but any extra
|
||||
// props are still carried through React Query's cache — cast through
|
||||
// `unknown` so we can read them opportunistically without widening
|
||||
// the Photo type for every consumer.
|
||||
const anyPhoto = photo as unknown as Record<string, unknown>
|
||||
const title = typeof anyPhoto.user_title === 'string' ? anyPhoto.user_title : null
|
||||
if (title && title.toLowerCase().includes(needle)) {
|
||||
return wrap('title', 'Title', excerpt(title, needle))
|
||||
}
|
||||
const notes = typeof anyPhoto.user_notes === 'string' ? anyPhoto.user_notes : null
|
||||
if (notes && notes.toLowerCase().includes(needle)) {
|
||||
return wrap('notes', 'Note', excerpt(notes, needle))
|
||||
}
|
||||
|
||||
if (photo.tags && photo.tags.length > 0) {
|
||||
const hit = photo.tags.find((t) => t.name.toLowerCase().includes(needle))
|
||||
if (hit) {
|
||||
return wrap('tag', 'Tag', excerpt(hit.name, needle))
|
||||
}
|
||||
}
|
||||
|
||||
const exifJson = typeof anyPhoto.exif_json === 'string' ? anyPhoto.exif_json : null
|
||||
const exifHit = findExifMatch(exifJson, needle)
|
||||
if (exifHit) {
|
||||
return wrap('exif', `EXIF: ${exifHit.key}`, excerpt(exifHit.value, needle))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user