Project Files
src / search / queryParser.ts
/**
* Structured Query Parser for Find Image
*
* Parses queries containing labeled metadata fields (as output by find_image)
* into a clean promptQuery + hard filters.
*
* Labels: Size, Model, LoRAs, Source, Origin, Timestamp
* Separator: • (bullet) or inline label boundaries
*
* Anything without a known label is treated as prompt search text.
* When filters are present they act as a HARD AND — a generation must
* pass **every** filter to be included. Fuzzy/partial matching only
* applies to the prompt portion.
*/
import path from 'path';
import type { GenerationMetadata } from '../types';
import type { DtcModelMappingSnapshotV1 } from '../helpers/dtcModelMappingSnapshot';
// ═══════════════════════════════════════════════════════════════
// Types
// ═══════════════════════════════════════════════════════════════
export interface ParsedQueryFilters {
/** Exact dimensions [width, height] */
size?: [number, number];
/** Model filename (exact) and/or family name (via snapshot) */
model?: { filename?: string; family?: string; raw: string };
/** LoRA filenames — ALL must be present on the generation */
loras?: string[];
/** User-managed tags — ALL must be present on the generation */
tags?: string[];
/** Source type + optional ID */
source?: { type: string; id?: string };
/** Origin / image type (Draw Things | ComfyUI) */
origin?: string;
/** Timestamp plus the maximum permitted difference in minutes. */
timestamp?: { value: string; toleranceMinutes: number };
gps?: { latitude: number; longitude: number; radiusMeters: number };
lensModel?: string;
exposureTime?: string;
fNumber?: number;
iso?: number;
exposureCompensation?: number;
focalLength?: { physical?: number; equivalent35mm?: number };
exposureProgram?: string;
meteringMode?: string;
whiteBalance?: string;
flash?: string;
orientation?: string;
exposureMode?: string;
}
export interface ParsedQuery {
/** Clean prompt text (everything not consumed by a label) */
promptQuery: string;
/** Hard filters extracted from labeled segments */
filters: ParsedQueryFilters;
/** true when at least one filter field is populated */
hasFilters: boolean;
}
export interface StructuredFilterTolerances {
gpsRadiusMeters?: number;
timestampToleranceMinutes?: number;
}
// ═══════════════════════════════════════════════════════════════
// Parser
// ═══════════════════════════════════════════════════════════════
/** Searchable canonical English labels. */
const LABEL_ALIASES: Record<keyof ParsedQueryFilters, string[]> = {
timestamp: ["Timestamp", "Captured"], source: ["Source"], origin: ["Origin"], model: ["Model", "Camera"],
loras: ["LoRA", "LoRAs"], tags: ["Tag", "Tags"], size: ["Size"], gps: ["GPS"], lensModel: ["Lens"],
exposureTime: ["Shutter"], fNumber: ["Aperture"], iso: ["ISO"], exposureCompensation: ["EV"],
focalLength: ["Focal length"], exposureProgram: ["Program"], meteringMode: ["Metering"],
whiteBalance: ["White balance"], flash: ["Flash"], orientation: ["Orientation"], exposureMode: ["Exposure mode"],
};
const LABEL_DEFS: Array<{ key: keyof ParsedQueryFilters; re: RegExp }> = Object.entries(LABEL_ALIASES).map(([key, aliases]) => ({
key: key as keyof ParsedQueryFilters,
re: new RegExp(`\\b(?:${aliases.sort((left, right) => right.length - left.length).map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\s*:`, "gi"),
}));
/** Non-searchable labels that we skip (don't treat as prompt either). */
const SKIP_LABEL_RE = /\b(?:Match|Score)\s*:/gi;
type LabelHit = { key: keyof ParsedQueryFilters | '__skip'; start: number; labelEnd: number };
/**
* Parse a query string into {promptQuery, filters}.
*
* Strategy:
* 1. Normalise bullet separators to newlines.
* 2. Scan the whole string for known label positions.
* 3. Text before the first label → promptQuery.
* 4. Each label's value extends to the next label (or end of string).
*/
export function parseStructuredQuery(
query: string,
snapshot?: DtcModelMappingSnapshotV1,
knownProjectFiles?: string[],
tolerances: StructuredFilterTolerances = {},
): ParsedQuery {
const filters: ParsedQueryFilters = {};
// Normalise bullets → newlines (makes label extraction simpler)
let text = query.replace(/\s*•\s*/g, ' \n ').trim();
// Pre-clean: strip "Available presets for this model: ..." fragments
// BEFORE label scanning, because "for this model:" contains a false
// \bModel\s*: match that would split the segment incorrectly.
text = text.replace(/\bAvailable presets?\b[^\n]*/gi, '');
// ── Find all label positions ─────────────────────────────────
const hits: LabelHit[] = [];
for (const { key, re } of LABEL_DEFS) {
// Reset lastIndex (regex is /g)
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
hits.push({ key, start: m.index, labelEnd: m.index + m[0].length });
}
}
// Skip non-searchable labels
SKIP_LABEL_RE.lastIndex = 0;
let sm: RegExpExecArray | null;
while ((sm = SKIP_LABEL_RE.exec(text)) !== null) {
hits.push({ key: '__skip', start: sm.index, labelEnd: sm.index + sm[0].length });
}
hits.sort((a, b) => a.start - b.start);
if (hits.length === 0) {
// No labels — try heuristic detection of unlabeled metadata
const heuristic = tryHeuristicParse(text, snapshot, knownProjectFiles);
if (heuristic) return heuristic;
// Truly plain text → entire text is prompt
return { promptQuery: text.replace(/\n/g, ' ').trim(), filters, hasFilters: false };
}
// ── Extract prompt (text before first label) ─────────────────
const rawPrompt = text.slice(0, hits[0].start);
const cleanedRaw = rawPrompt
.replace(/\b(?:Match|Score)\s*:[^\n]*/gi, '')
.replace(/\bAvailable presets?\b[^\n]*/gi, '')
.replace(/\n/g, ' ')
.trim();
// The pre-label text may contain unlabeled metadata (model filenames,
// dimensions, etc.). Run it through the heuristic parser so those
// artefacts become proper hard-AND filters instead of prompt text.
let cleanPrompt = cleanedRaw;
const preHeuristic = cleanedRaw ? tryHeuristicParse(cleanedRaw, snapshot, knownProjectFiles) : null;
if (preHeuristic) {
cleanPrompt = preHeuristic.promptQuery;
// Merge heuristic-extracted filters (model, size, loras) into main filters.
if (preHeuristic.filters.model && !filters.model) filters.model = preHeuristic.filters.model;
if (preHeuristic.filters.size && !filters.size) filters.size = preHeuristic.filters.size;
if (preHeuristic.filters.loras) {
filters.loras = filters.loras
? [...preHeuristic.filters.loras, ...filters.loras]
: preHeuristic.filters.loras;
}
if (preHeuristic.filters.tags) {
filters.tags = filters.tags
? [...preHeuristic.filters.tags, ...filters.tags]
: preHeuristic.filters.tags;
}
if (preHeuristic.filters.timestamp && !filters.timestamp) filters.timestamp = preHeuristic.filters.timestamp;
if (preHeuristic.filters.source && !filters.source) filters.source = preHeuristic.filters.source;
if (preHeuristic.filters.origin && !filters.origin) filters.origin = preHeuristic.filters.origin;
}
// ── Extract each labeled segment ─────────────────────────────
for (let i = 0; i < hits.length; i++) {
const hit = hits[i];
const nextStart = (i + 1 < hits.length) ? hits[i + 1].start : text.length;
const rawValue = text.slice(hit.labelEnd, nextStart)
.replace(/\n/g, ' ')
.trim();
if (hit.key === '__skip') continue;
if (!rawValue) continue;
applyFilter(filters, hit.key, rawValue, tolerances);
}
return {
promptQuery: cleanPrompt,
filters,
hasFilters: Object.values(filters).some(v => v != null),
};
}
// ═══════════════════════════════════════════════════════════════
// Dictionary-Scan Parser (separator-agnostic)
// ═══════════════════════════════════════════════════════════════
/**
* Find model/LoRA filenames in text — matches any token ending in .ckpt.
* Draw Things exclusively uses .ckpt for both models and LoRAs.
* Works regardless of surrounding separators (comma, pipe, bullet, paren, space …).
*/
const FILE_IN_TEXT_RE = /[\w][\w.\-]*\.ckpt\b/gi;
/** Dimension patterns: "768x1024", "768 x 1024", "768 x 1024 Pixel", "768×1024" */
const DIMENSION_RE = /(\d{2,5})\s*[×xX✕]\s*(\d{2,5})(?:\s*(?:px|pixel|pixels))?/i;
/**
* Extract structured filters from free text by **scanning for known tokens**
* rather than splitting on separators.
*
* Classification rules (per the user's stated invariants):
* • Every relevant filename ends in .ckpt
* • LoRAs always contain "lora" in the filename
* • The first non-LoRA file → base model; further non-LoRAs → also LoRA bucket
* • Dimensions are a structural regex (NNN x NNN)
* • The snapshot provides family names for known base models
* • Everything else → prompt text
*
* Returns null when no metadata tokens are found (plain prompt query).
*/
function tryHeuristicParse(
text: string,
snapshot?: DtcModelMappingSnapshotV1,
knownProjectFiles?: string[],
): ParsedQuery | null {
const flat = text.replace(/\n/g, ' ');
const filters: ParsedQueryFilters = {};
// ── 1. Scan for every model/LoRA filename in the text ────────
FILE_IN_TEXT_RE.lastIndex = 0;
const fileHits: { text: string; index: number }[] = [];
let fm: RegExpExecArray | null;
while ((fm = FILE_IN_TEXT_RE.exec(flat)) !== null) {
fileHits.push({ text: fm[0], index: fm.index });
}
// ── 2. Scan for dimension pattern ────────────────────────────
const dimMatch = flat.match(DIMENSION_RE);
if (dimMatch) {
filters.size = [parseInt(dimMatch[1], 10), parseInt(dimMatch[2], 10)];
}
// ── 3. Scan for known project names (dictionary-based) ─────
// Tolerance: basename with or without ".sqlite3".
let projectMatchSpan: RegExp | null = null;
if (knownProjectFiles && knownProjectFiles.length > 0) {
const byName = new Map<string, string>(); // lowercase name → full path
for (const pf of knownProjectFiles) {
const base = path.basename(pf); // "Untitled-17953.sqlite3"
const noExt = base.replace(/\.sqlite3$/i, ''); // "Untitled-17953"
byName.set(base.toLowerCase(), pf);
byName.set(noExt.toLowerCase(), pf);
}
// Pattern A: "draw things project (ProjectName)" — canonical format
const dtpMatch = flat.match(/draw\s+things\s+project\s*\(\s*([^)]+)\s*\)/i);
if (dtpMatch) {
const resolved = byName.get(dtpMatch[1].trim().toLowerCase());
if (resolved) {
filters.source = { type: 'draw_things_project', id: resolved };
const esc = dtpMatch[1].trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
projectMatchSpan = new RegExp(
`draw\\s+things\\s+project\\s*\\(\\s*${esc}\\s*\\)`, 'gi',
);
}
}
// Pattern B: bare project name anywhere in text (longest-first)
if (!filters.source) {
const sorted = [...byName.entries()].sort((a, b) => b[0].length - a[0].length);
for (const [name, fullPath] of sorted) {
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`\\b${esc}\\b`, 'i');
if (re.test(flat)) {
filters.source = { type: 'draw_things_project', id: fullPath };
// Span: optional "draw things project (" wrapper
projectMatchSpan = new RegExp(
`(?:draw\\s+things\\s+project\\s*\\(?\\s*)?${esc}(?:\\s*\\))?`, 'gi',
);
break;
}
}
}
}
// ── 4. Nothing found → try family-only match or bail ─────────
if (fileHits.length === 0 && !filters.size && !filters.source) {
const familyHit = tryMatchModelFamily(flat.trim(), snapshot);
if (familyHit) {
return { promptQuery: '', filters: { model: familyHit }, hasFilters: true };
}
return null;
}
// ── 5. Classify: base model vs LoRA ──────────────────────────
// Build a lowercase lookup for snapshot families.
const snapshotFamilies = new Map<string, string>();
if (snapshot?.models) {
for (const [key, val] of Object.entries(snapshot.models)) {
if (val.model_family) snapshotFamilies.set(key.toLowerCase(), val.model_family);
}
}
let baseModelFile: string | undefined;
let modelFamily: string | undefined;
const loraFiles: string[] = [];
for (const hit of fileHits) {
if (/lora/i.test(hit.text)) {
loraFiles.push(hit.text);
} else if (!baseModelFile) {
baseModelFile = hit.text;
modelFamily = snapshotFamilies.get(hit.text.toLowerCase());
} else {
// Second non-LoRA model file → LoRA bucket
loraFiles.push(hit.text);
}
}
// ── 6. Build filters (family only from snapshot — no guessing) ──
if (baseModelFile) {
filters.model = {
filename: baseModelFile,
family: modelFamily || undefined,
raw: modelFamily ? `${modelFamily} (${baseModelFile})` : baseModelFile,
};
}
if (loraFiles.length > 0) {
filters.loras = loraFiles;
}
const hasFilters = Object.values(filters).some(v => v != null);
if (!hasFilters) return null;
// ── 7. Compute remaining text → prompt ───────────────────────
// Remove all consumed tokens from the text, then clean up.
let prompt = flat;
// Remove project match span (e.g. "draw things project (Untitled-17953)")
if (projectMatchSpan) {
prompt = prompt.replace(projectMatchSpan, ' ');
}
// Remove filenames AND any family prefix "word(filename.ckpt)"
// by replacing the entire "word (filename)" span, not just the filename.
for (const f of [...fileHits].sort((a, b) => b.text.length - a.text.length)) {
// Build pattern: optional "word-or-words (" before, optional ")" after
const esc = f.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const span = new RegExp(`[\\w][\\w\\-]*\\s*\\(\\s*${esc}\\s*\\)|${esc}`, 'gi');
prompt = prompt.replace(span, ' ');
}
// Remove dimension
if (dimMatch) {
prompt = prompt.replace(DIMENSION_RE, ' ');
}
// Remove the family name (it's metadata, not prompt content)
if (modelFamily) {
const esc = modelFamily.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
prompt = prompt.replace(new RegExp(esc, 'gi'), ' ');
}
// Strip separators, parens, orphaned label words, whitespace
prompt = prompt
.replace(/[,|•()]/g, ' ')
.replace(/\bLoRAs?\b/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
return { promptQuery: prompt, filters, hasFilters: true };
}
/**
* Check if a query string matches a known model family in the snapshot.
* Returns a model filter if a match is found, null otherwise.
*/
function tryMatchModelFamily(
query: string,
snapshot?: DtcModelMappingSnapshotV1,
): ParsedQueryFilters['model'] | null {
if (!snapshot?.models || !query) return null;
const queryLower = query.toLowerCase().replace(/[_\-]/g, ' ').trim();
if (!queryLower || queryLower.length < 2) return null;
// Build reverse map: family → filenames
const familyFiles = new Map<string, string[]>();
for (const [filename, entry] of Object.entries(snapshot.models)) {
const fam = entry.model_family?.toLowerCase().replace(/[_\-]/g, ' ');
if (!fam) continue;
const list = familyFiles.get(fam) ?? [];
list.push(filename);
familyFiles.set(fam, list);
}
// Exact family match
if (familyFiles.has(queryLower)) {
return { family: query, raw: query };
}
// Prefix match (e.g. "z-image" matches "z-image turbo")
for (const fam of familyFiles.keys()) {
if (fam.startsWith(queryLower) || queryLower.startsWith(fam)) {
return { family: query, raw: query };
}
}
return null;
}
function applyFilter(filters: ParsedQueryFilters, key: keyof ParsedQueryFilters, value: string, tolerances: StructuredFilterTolerances): void {
switch (key) {
case 'size': { const v = parseSize(value); if (v) filters.size = v; break; }
case 'model': { const v = parseModel(value); if (v) filters.model = v; break; }
case 'loras': { const v = parseLoras(value); if (v.length) filters.loras = v; break; }
case 'tags': { const v = parseTags(value); if (v.length) filters.tags = v; break; }
case 'source': { const v = parseSource(value); if (v) filters.source = v; break; }
case 'origin': filters.origin = value.trim(); break;
case 'timestamp': filters.timestamp = { value: value.trim(), toleranceMinutes: normaliseTolerance(tolerances.timestampToleranceMinutes) }; break;
case 'gps': { const v = parseGps(value, tolerances.gpsRadiusMeters); if (v) filters.gps = v; break; }
case 'lensModel': filters.lensModel = value.trim(); break;
case 'exposureTime': { const v = parseExposureTime(value); if (v) filters.exposureTime = v; break; }
case 'fNumber': { const v = parseNumber(value, /f\/?\s*([\d.]+)/i); if (v !== undefined) filters.fNumber = v; break; }
case 'iso': { const v = parseNumber(value, /\bISO\s*(\d+)/i) ?? parseNumber(value, /(\d+)/); if (v !== undefined) filters.iso = v; break; }
case 'exposureCompensation': { const v = parseNumber(value, /([+-]?\d+(?:\.\d+)?)\s*(?:EV)?/i); if (v !== undefined) filters.exposureCompensation = v; break; }
case 'focalLength': { const v = parseFocalLength(value); if (v) filters.focalLength = v; break; }
case 'exposureProgram': filters.exposureProgram = value.trim(); break;
case 'meteringMode': filters.meteringMode = value.trim(); break;
case 'whiteBalance': filters.whiteBalance = value.trim(); break;
case 'flash': filters.flash = value.trim(); break;
case 'orientation': filters.orientation = value.trim(); break;
case 'exposureMode': filters.exposureMode = value.trim(); break;
}
}
const DEFAULT_GPS_RADIUS_METERS = 250;
function normaliseTolerance(value: number | undefined, defaultValue = 0): number {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : defaultValue;
}
function parseGps(value: string, defaultRadiusMeters = DEFAULT_GPS_RADIUS_METERS): ParsedQueryFilters['gps'] | undefined {
const match = value.match(/^\s*([+-]?\d+(?:\.\d+)?)\s*[,;]\s*([+-]?\d+(?:\.\d+)?)(?:\s*(?:±|\+\/-)\s*(\d+(?:\.\d+)?)\s*(m|km)?)?\s*$/i);
if (!match) return undefined;
const latitude = Number(match[1]);
const longitude = Number(match[2]);
const radiusValue = match[3] === undefined ? normaliseTolerance(defaultRadiusMeters, DEFAULT_GPS_RADIUS_METERS) : Number(match[3]);
const radiusMeters = match[4]?.toLowerCase() === 'km' ? radiusValue * 1000 : radiusValue;
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || !Number.isFinite(radiusMeters)
|| latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 || radiusMeters < 0) return undefined;
return { latitude, longitude, radiusMeters };
}
function parseNumber(value: string, pattern: RegExp): number | undefined {
const match = value.match(pattern);
const parsed = match ? Number(match[1]) : Number.NaN;
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseExposureTime(value: string): string | undefined {
const match = value.match(/(\d+(?:\.\d+)?\s*\/\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?)\s*(?:s|sec(?:onds?)?)?/i);
return match ? match[1].replace(/\s+/g, '') : undefined;
}
function parseFocalLength(value: string): ParsedQueryFilters['focalLength'] | undefined {
const values = [...value.matchAll(/(\d+(?:\.\d+)?)\s*mm/gi)].map((match) => Number(match[1]));
if (!values.length || values.some((number) => !Number.isFinite(number) || number <= 0)) return undefined;
return { physical: values[0], equivalent35mm: values[1] };
}
// ── Size ────────────────────────────────────────────────────────
function parseSize(value: string): [number, number] | undefined {
const m = value.match(/(\d{2,5})\s*[×xX✕]\s*(\d{2,5})/);
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : undefined;
}
// ── Model ───────────────────────────────────────────────────────
function parseModel(value: string): ParsedQueryFilters['model'] | undefined {
// Strip "Available presets …" suffix (present in find_image output)
let cleaned = value.replace(/\s*[Aa]vailable presets?\b.*/i, '').trim();
if (!cleaned) return undefined;
// Format: "family-name (filename.ckpt)"
const parenMatch = cleaned.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
if (parenMatch) {
return { family: parenMatch[1].trim(), filename: parenMatch[2].trim(), raw: cleaned };
}
// Looks like a filename?
if (/\.ckpt$/i.test(cleaned)) {
return { filename: cleaned, raw: cleaned };
}
// Assume family name
return { family: cleaned, raw: cleaned };
}
// ── LoRAs ───────────────────────────────────────────────────────
function parseLoras(value: string): string[] {
return value.split(/\s*,\s*/).map(s => s.trim()).filter(s => s.length > 0);
}
function parseTags(value: string): string[] {
return [...new Set(value.split(/[\s,]+/).map((tag) => tag.trim().toLocaleLowerCase()).filter(Boolean))];
}
// ── Source ───────────────────────────────────────────────────────
function parseSource(value: string): ParsedQueryFilters['source'] | undefined {
const t = value.trim();
if (!t) return undefined;
// "attachment (9.png)"
const attachMatch = t.match(/^attachment\s*(?:\(([^)]*)\))?$/i);
if (attachMatch) return { type: 'attachment', id: attachMatch[1]?.trim() };
// "saved image (Draw Things)" or "saved image (/path)"
const savedMatch = t.match(/^saved\s+image\s*(?:\(([^)]*)\))?$/i);
if (savedMatch) return { type: 'saved_image', id: savedMatch[1]?.trim() };
// "generate image (chat-id)"
const genMatch = t.match(/^generate\s+image\s*(?:\(([^)]*)\))?$/i);
if (genMatch) return { type: 'variant', id: genMatch[1]?.trim() };
// "(chat: 1769640286970)" — alternative format from LLM
const chatMatch = t.match(/^\(?chat\s*:\s*([^)]+)\)?$/i);
if (chatMatch) return { type: 'variant', id: chatMatch[1]?.trim() };
// "Draw Things project (/path/to/file.sqlite3)"
const projectMatch = t.match(/^draw\s+things\s+project\s*(?:\(([^)]*)\))?$/i);
if (projectMatch) return { type: 'draw_things_project', id: projectMatch[1]?.trim() };
// Generic fallback
return { type: t };
}
// ═══════════════════════════════════════════════════════════════
// Filter Matching (Hard AND)
// ═══════════════════════════════════════════════════════════════
function normalizeModelFilename(value: string): string {
return path.basename(value).trim().toLowerCase();
}
/** Normalize for loose model comparison: strip extension, convert separators. */
function normalizeForModelCompare(value: string): string {
return path.basename(value).trim().toLowerCase()
.replace(/\.ckpt$/i, '')
.replace(/[_\-]/g, ' ');
}
function resolveSourceType(filterType: string): string {
const aliases: Record<string, string> = {
'attachment': 'attachment',
'variant': 'variant',
'image': 'image',
'picture': 'picture',
'saved_image': 'saved_image',
'saved image': 'saved_image',
'generate_image_variant': 'variant',
'generate_image': 'variant',
'generate image': 'variant',
'chat': 'variant',
'draw_things_project': 'draw_things_project',
'draw things project': 'draw_things_project',
'project': 'draw_things_project',
};
return aliases[filterType.toLowerCase()] ?? filterType;
}
/**
* Check if a generation passes ALL hard filters.
* Returns false as soon as ANY filter fails.
*/
export function matchesFilters(
gen: GenerationMetadata,
filters: ParsedQueryFilters,
snapshot?: DtcModelMappingSnapshotV1,
): boolean {
// ── Size (exact) ──
if (filters.size) {
if (gen.width !== filters.size[0] || gen.height !== filters.size[1]) return false;
}
// ── Model ──
if (filters.model) {
if (!matchesModelFilter(gen, filters.model, snapshot)) return false;
}
// ── LoRAs (all named must be present) ──
if (filters.loras && filters.loras.length > 0) {
if (!matchesLoraFilter(gen, filters.loras)) return false;
}
// ── Tags (all named must be present) ──
if (filters.tags && filters.tags.length > 0) {
const generationTags = new Set((gen.tags ?? []).map((tag) => tag.trim().toLocaleLowerCase()));
if (!filters.tags.every((tag) => generationTags.has(tag))) return false;
}
// ── Source ──
if (filters.source) {
if (!matchesSourceFilter(gen, filters.source)) return false;
}
// ── Origin ──
if (filters.origin) {
if (!matchesOriginFilter(gen, filters.origin)) return false;
}
// ── Timestamp (prefix match) ──
if (filters.timestamp) {
if (!matchesTimestampFilter(gen, filters.timestamp)) return false;
}
if (filters.gps && !matchesGpsFilter(gen, filters.gps)) return false;
if (filters.lensModel && !matchesTextFilter(gen.lensModel, filters.lensModel)) return false;
if (filters.exposureTime && !matchesExposureTime(gen.exposureTime, filters.exposureTime)) return false;
if (filters.fNumber !== undefined && !matchesNumberFilter(gen.fNumber, filters.fNumber)) return false;
if (filters.iso !== undefined && gen.iso !== filters.iso) return false;
if (filters.exposureCompensation !== undefined && !matchesNumberFilter(gen.exposureCompensation, filters.exposureCompensation)) return false;
if (filters.focalLength && !matchesFocalLengthFilter(gen, filters.focalLength)) return false;
if (filters.exposureProgram && !matchesTextFilter(gen.exposureProgram, filters.exposureProgram)) return false;
if (filters.meteringMode && !matchesTextFilter(gen.meteringMode, filters.meteringMode)) return false;
if (filters.whiteBalance && !matchesTextFilter(gen.whiteBalance, filters.whiteBalance)) return false;
if (filters.flash && !matchesTextFilter(gen.flash, filters.flash)) return false;
if (filters.orientation && !matchesTextFilter(gen.orientation, filters.orientation)) return false;
if (filters.exposureMode && !matchesTextFilter(gen.exposureMode, filters.exposureMode)) return false;
return true;
}
// ── Model filter ────────────────────────────────────────────────
function matchesModelFilter(
gen: GenerationMetadata,
filter: NonNullable<ParsedQueryFilters['model']>,
snapshot?: DtcModelMappingSnapshotV1,
): boolean {
if (!gen.model || gen.model === 'unknown') return false;
const genNorm = normalizeModelFilename(gen.model);
// 1. Exact filename (also with separator normalisation: _ ↔ -)
if (filter.filename) {
const filterNorm = normalizeModelFilename(filter.filename);
if (genNorm === filterNorm) return true;
// Without extension
const genBase = genNorm.replace(/\.ckpt$/i, '');
const filterBase = filterNorm.replace(/\.ckpt$/i, '');
if (genBase === filterBase) return true;
// Separator-normalised (z-image vs z_image)
if (genBase.replace(/[-_]/g, '') === filterBase.replace(/[-_]/g, '')) return true;
}
// 2. Family match via snapshot
if (filter.family && snapshot?.models) {
const entry = snapshot.models[genNorm];
if (entry?.model_family) {
if (entry.model_family.toLowerCase() === filter.family.toLowerCase()) return true;
}
}
// 3. Fallback: loose contains using separator-normalised names
if (filter.family) {
const genComp = normalizeForModelCompare(gen.model);
const filterComp = filter.family.toLowerCase().replace(/[_\-]/g, ' ');
if (genComp.includes(filterComp) || filterComp.includes(genComp)) return true;
}
return false;
}
// ── LoRA filter ─────────────────────────────────────────────────
function matchesLoraFilter(gen: GenerationMetadata, filterLoras: string[]): boolean {
if (!gen.loras || gen.loras.length === 0) return false;
const genNames = gen.loras.map(l =>
normalizeModelFilename(typeof l === 'string' ? l : l.model)
);
for (const wanted of filterLoras) {
const wantedNorm = normalizeModelFilename(wanted);
if (!wantedNorm) continue;
if (!genNames.some(g => g === wantedNorm)) return false;
}
return true;
}
// ── Source filter ────────────────────────────────────────────────
function matchesSourceFilter(
gen: GenerationMetadata,
filter: NonNullable<ParsedQueryFilters['source']>,
): boolean {
const src = gen.sourceInfo;
if (!src) return false;
const resolvedType = resolveSourceType(filter.type);
if (resolvedType !== src.type) return false;
// Type match alone is sufficient when no ID specified
if (!filter.id) return true;
const idLower = filter.id.toLowerCase();
switch (src.type) {
case 'attachment':
return src.originalName.toLowerCase().includes(idLower);
case 'saved_image': {
// Output format uses imageType in parens, filePath is secondary
if (src.imageType && src.imageType.toLowerCase() === idLower) return true;
return src.filePath.toLowerCase().includes(idLower);
}
case 'variant':
case 'image':
case 'picture':
return src.chatId.toLowerCase().includes(idLower);
case 'draw_things_project':
return src.projectFile.toLowerCase().includes(idLower);
default:
return false;
}
}
// ── Origin filter ───────────────────────────────────────────────
function matchesOriginFilter(gen: GenerationMetadata, filterOrigin: string): boolean {
const origin = filterOrigin.toLowerCase();
// Attachment / saved_image carry an explicit imageType
const imageType = (gen.sourceInfo as any)?.imageType;
if (imageType) return String(imageType).toLowerCase() === origin;
// Draw Things project records are implicitly Draw Things
if (origin === 'draw things') {
return gen.sourceInfo?.type === 'draw_things_project';
}
return false;
}
// ── Timestamp filter (normalised date comparison) ───────────────
/**
* Normalise a variety of timestamp formats to "YYYY-MM-DD HH:MM:SS" (or a prefix thereof).
*
* Accepted inputs:
* "01/29/2026, 12:38:08 GMT+1" → "2026-01-29 12:38:08" (US-style)
* "4.2.2026, 13:16:01" → "2026-02-04 13:16:01" (de-DE / project)
* "2026-01-29T12:38:08.000Z" → "2026-01-29 12:38:08" (ISO)
* "29.01.2026" → "2026-01-29" (partial DE)
* "2026-01-29" → "2026-01-29" (partial ISO)
* "2026" → "2026" (year only)
*/
function normaliseTimestamp(raw: string): string {
const s = raw.trim();
// ISO 8601
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{2}):(\d{2}):?(\d{2})?)?/);
if (iso) {
const [, y, m, d, H, M, S] = iso;
let out = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
if (H) out += ` ${H}:${M}${S ? ':' + S : ''}`;
return out;
}
// US format: MM/DD/YYYY[, HH:MM:SS[ GMT±N]]
const us = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s*,?\s*(\d{2}):(\d{2}):?(\d{2})?)?/);
if (us) {
const [, m, d, y, H, M, S] = us;
let out = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
if (H) out += ` ${H}:${M}${S ? ':' + S : ''}`;
return out;
}
// German format: D.M.YYYY[, HH:MM:SS]
const de = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s*,?\s*(\d{2}):(\d{2}):?(\d{2})?)?/);
if (de) {
const [, d, m, y, H, M, S] = de;
let out = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
if (H) out += ` ${H}:${M}${S ? ':' + S : ''}`;
return out;
}
return s;
}
function matchesTimestampFilter(gen: GenerationMetadata, filter: NonNullable<ParsedQueryFilters['timestamp']>): boolean {
if (!gen.timestamp) return false;
const genNorm = normaliseTimestamp(gen.timestamp);
const filterNorm = normaliseTimestamp(filter.value);
const filterTime = timestampAsUtcMilliseconds(filterNorm);
const generationTime = timestampAsUtcMilliseconds(genNorm);
if (filterTime !== undefined && generationTime !== undefined) {
return Math.abs(generationTime - filterTime) <= filter.toleranceMinutes * 60_000;
}
return filter.toleranceMinutes === 0 && (genNorm.startsWith(filterNorm) || filterNorm.startsWith(genNorm));
}
function timestampAsUtcMilliseconds(value: string): number | undefined {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})(?::(\d{2}))?$/);
if (!match) return undefined;
const [, year, month, day, hour, minute, second = "0"] = match;
const timestamp = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
return Number.isNaN(timestamp) ? undefined : timestamp;
}
function normaliseText(value: string): string {
return value.toLocaleLowerCase().replace(/\s+/g, ' ').trim();
}
function matchesTextFilter(value: string | undefined, filter: string): boolean {
const actual = value ? normaliseText(value) : '';
const wanted = normaliseText(filter);
return Boolean(actual && wanted && actual.includes(wanted));
}
function matchesNumberFilter(value: number | undefined, filter: number): boolean {
return value !== undefined && Math.abs(value - filter) < 0.000_001;
}
function matchesExposureTime(value: string | undefined, filter: string): boolean {
return value !== undefined && value.replace(/\s+/g, '') === filter;
}
function matchesFocalLengthFilter(gen: GenerationMetadata, filter: NonNullable<ParsedQueryFilters['focalLength']>): boolean {
return (filter.physical === undefined || matchesNumberFilter(gen.focalLength, filter.physical))
&& (filter.equivalent35mm === undefined || matchesNumberFilter(gen.focalLength35mm, filter.equivalent35mm));
}
function matchesGpsFilter(gen: GenerationMetadata, filter: NonNullable<ParsedQueryFilters['gps']>): boolean {
if (gen.latitude === undefined || gen.longitude === undefined) return false;
const toRadians = (degrees: number) => degrees * Math.PI / 180;
const latitudeDelta = toRadians(gen.latitude - filter.latitude);
const longitudeDelta = toRadians(gen.longitude - filter.longitude);
const latitude1 = toRadians(filter.latitude);
const latitude2 = toRadians(gen.latitude);
const haversine = Math.sin(latitudeDelta / 2) ** 2 + Math.cos(latitude1) * Math.cos(latitude2) * Math.sin(longitudeDelta / 2) ** 2;
return 6_371_000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)) <= filter.radiusMeters;
}
// ═══════════════════════════════════════════════════════════════
// Filter-Based Scoring
// ═══════════════════════════════════════════════════════════════
/**
* Score a generation that already passed hard filters (no prompt text).
* More filter categories in the query → higher score.
*/
export function computeFilterScore(filters: ParsedQueryFilters): number {
let categories = 0;
if (filters.size) categories++;
if (filters.model) categories++;
if (filters.loras) categories++;
if (filters.source) categories++;
if (filters.origin) categories++;
if (filters.timestamp) categories++;
if (filters.gps) categories++;
if (filters.lensModel) categories++;
if (filters.exposureTime) categories++;
if (filters.fNumber !== undefined) categories++;
if (filters.iso !== undefined) categories++;
if (filters.exposureCompensation !== undefined) categories++;
if (filters.focalLength) categories++;
if (filters.exposureProgram) categories++;
if (filters.meteringMode) categories++;
if (filters.whiteBalance) categories++;
if (filters.flash) categories++;
if (filters.orientation) categories++;
if (filters.exposureMode) categories++;
// Base 85 + 3 per extra category (1→85, 2→88, 3→91, 4→94, 5→97, 6→100)
return Math.min(100, 85 + Math.max(0, categories - 1) * 3);
}
/**
* Build matchedTerms list describing which filters matched (for result display).
*/
export function describeFilterMatches(filters: ParsedQueryFilters): string[] {
const terms: string[] = [];
if (filters.size) terms.push(`${filters.size[0]}×${filters.size[1]}`);
if (filters.model) terms.push(filters.model.raw);
if (filters.loras) terms.push(...filters.loras);
if (filters.source) terms.push(`source:${filters.source.type}${filters.source.id ? `(${filters.source.id})` : ''}`);
if (filters.origin) terms.push(filters.origin);
if (filters.timestamp) terms.push(filters.timestamp.value);
if (filters.gps) terms.push(`GPS: ${filters.gps.latitude}, ${filters.gps.longitude} ± ${filters.gps.radiusMeters}m`);
if (filters.lensModel) terms.push(filters.lensModel);
if (filters.exposureTime) terms.push(filters.exposureTime);
if (filters.fNumber !== undefined) terms.push(`f/${filters.fNumber}`);
if (filters.iso !== undefined) terms.push(`ISO ${filters.iso}`);
if (filters.exposureCompensation !== undefined) terms.push(`${filters.exposureCompensation} EV`);
if (filters.focalLength?.physical !== undefined) terms.push(`${filters.focalLength.physical}mm`);
if (filters.exposureProgram) terms.push(filters.exposureProgram);
if (filters.meteringMode) terms.push(filters.meteringMode);
if (filters.whiteBalance) terms.push(filters.whiteBalance);
if (filters.flash) terms.push(filters.flash);
if (filters.orientation) terms.push(filters.orientation);
if (filters.exposureMode) terms.push(filters.exposureMode);
return terms;
}