Project Files
src / search / queryParser.ts
/**
* Structured Query Parser for Draw Things Index
*
* Parses queries containing labeled metadata fields (as output by index_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[];
/** Source type + optional ID */
source?: { type: string; id?: string };
/** Origin / image type (Draw Things | ComfyUI) */
origin?: string;
/** Timestamp prefix (supports partial date matching) */
timestamp?: 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;
}
// ═══════════════════════════════════════════════════════════════
// Parser
// ═══════════════════════════════════════════════════════════════
/** Searchable label definitions (order doesn't matter — all positions are found via regex). */
const LABEL_DEFS: Array<{ key: keyof ParsedQueryFilters; re: RegExp }> = [
{ key: 'timestamp', re: /\bTimestamp\s*:/gi },
{ key: 'source', re: /\bSource\s*:/gi },
{ key: 'origin', re: /\bOrigin\s*:/gi },
{ key: 'model', re: /\bModel\s*:/gi },
{ key: 'loras', re: /\bLoRAs?\s*:/gi },
{ key: 'size', re: /\bSize\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[],
): 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.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);
}
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): 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 'source': { const v = parseSource(value); if (v) filters.source = v; break; }
case 'origin': filters.origin = value.trim(); break;
case 'timestamp': filters.timestamp = value.trim(); break;
}
}
// ── 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 index_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);
}
// ── 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: 'generate_image_variant', id: genMatch[1]?.trim() };
// "(chat: 1769640286970)" — alternative format from LLM
const chatMatch = t.match(/^\(?chat\s*:\s*([^)]+)\)?$/i);
if (chatMatch) return { type: 'generate_image_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',
'saved_image': 'saved_image',
'saved image': 'saved_image',
'generate_image_variant': 'generate_image_variant',
'generate_image': 'generate_image_variant',
'generate image': 'generate_image_variant',
'chat': 'generate_image_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;
}
// ── 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;
}
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 'generate_image_variant':
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;
// Projects and JSONL logs are implicitly Draw Things
if (origin === 'draw things') {
return gen.sourceInfo?.type === 'draw_things_project' ||
gen.sourceInfo?.type === 'generate_image_variant';
}
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 / JSONL)
* "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, filterTs: string): boolean {
if (!gen.timestamp) return false;
const genNorm = normaliseTimestamp(gen.timestamp);
const filterNorm = normaliseTimestamp(filterTs);
// Prefix match on normalised form — "2026-01" matches "2026-01-29 12:38:08"
return genNorm.startsWith(filterNorm) || filterNorm.startsWith(genNorm);
}
// ═══════════════════════════════════════════════════════════════
// 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++;
// 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);
return terms;
}