/** * Map Obsidian-style search queries to Meilisearch format * Supports: tag:, path:, file: operators with free text search */ export function mapObsidianQueryToMeili(qRaw) { const tokens = String(qRaw).trim().split(/\s+/); const filters = []; const meiliQ = []; let restrict = null; let rangeStart = null; let rangeEnd = null; const parseDate = (s) => { if (!s) return null; // Accept YYYY-MM-DD const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!m) return null; const d = new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00Z`); if (Number.isNaN(d.getTime())) return null; return d; }; for (const token of tokens) { // tag: operator - filter by exact tag match if (token.startsWith('tag:')) { const value = token.slice(4).replace(/^["']|["']$/g, ''); if (value) { // Remove leading # if present const cleanTag = value.startsWith('#') ? value.substring(1) : value; filters.push(`tags = "${cleanTag}"`); } } // path: operator - filter by parent directory path // We use parentDirs array to simulate startsWith behavior else if (token.startsWith('path:')) { const value = token.slice(5).replace(/^["']|["']$/g, ''); if (value) { // Normalize path separators const normalizedPath = value.replace(/\\/g, '/'); filters.push(`parentDirs = "${normalizedPath}"`); } } // file: operator - restrict search to file field else if (token.startsWith('file:')) { const value = token.slice(5).replace(/^["']|["']$/g, ''); if (value) { // Restrict search to file field only restrict = ['file']; meiliQ.push(value); } } // year: operator - facet filter else if (token.startsWith('year:')) { const value = token.slice(5).replace(/^["']|["']$/g, ''); const n = Number(value); if (!Number.isNaN(n)) { filters.push(`year = ${n}`); } } // month: operator - facet filter (1-12) else if (token.startsWith('month:')) { const value = token.slice(6).replace(/^["']|["']$/g, ''); const n = Number(value); if (!Number.isNaN(n)) { filters.push(`month = ${n}`); } } // date:YYYY-MM-DD (single day) else if (token.startsWith('date:')) { const value = token.slice(5); const d = parseDate(value); if (d) { const start = new Date(d); start.setUTCHours(0,0,0,0); const end = new Date(d); end.setUTCHours(23,59,59,999); const startMs = start.getTime(); const endMs = end.getTime(); filters.push(`((createdAt >= ${startMs} AND createdAt <= ${endMs}) OR (updatedAt >= ${startMs} AND updatedAt <= ${endMs}))`); } } // from:/to: YYYY-MM-DD else if (token.startsWith('from:')) { const d = parseDate(token.slice(5)); if (d) { const s = new Date(d); s.setUTCHours(0,0,0,0); rangeStart = s.getTime(); } } else if (token.startsWith('to:')) { const d = parseDate(token.slice(3)); if (d) { const e = new Date(d); e.setUTCHours(23,59,59,999); rangeEnd = e.getTime(); } } // Regular text token else if (token) { meiliQ.push(token); } } // If we captured a from/to range, add a combined date filter if (rangeStart !== null || rangeEnd !== null) { const startMs = rangeStart ?? 0; const endMs = rangeEnd ?? 8640000000000000; // large future filters.push(`((createdAt >= ${startMs} AND createdAt <= ${endMs}) OR (updatedAt >= ${startMs} AND updatedAt <= ${endMs}))`); } return { meiliQ: meiliQ.join(' ').trim(), filters, restrict }; } /** * Build Meilisearch search parameters from parsed query */ export function buildSearchParams(parsedQuery, options = {}) { const { limit = 20, offset = 0, sort, highlight = true } = options; const params = { q: parsedQuery.meiliQ, limit: Number(limit), offset: Number(offset), filter: parsedQuery.filters.length ? parsedQuery.filters.join(' AND ') : undefined, facets: ['tags', 'parentDirs', 'year', 'month'], attributesToHighlight: highlight ? ['title', 'content'] : [], highlightPreTag: '', highlightPostTag: '', attributesToCrop: ['content'], cropLength: 80, cropMarker: '…', attributesToSearchOn: parsedQuery.restrict?.length ? parsedQuery.restrict : undefined, sort: sort ? [String(sort)] : undefined, showMatchesPosition: false }; // Remove undefined values Object.keys(params).forEach(key => { if (params[key] === undefined) { delete params[key]; } }); return params; }