UNPKG

unified-query

Version:

Composable search input with autocompletion and a rich query-language parser for the Unified Data System

229 lines (228 loc) 8.57 kB
// query.ts import { KINDS } from './analyzers/kind.js'; import moment from 'moment'; /* ======================================================================= */ /* main converter */ /* ======================================================================= */ export function toQuery(res, head) { const findSeg = (k) => res.segments.find(s => s.keyword == k.keyword); const q = {}; for (const parsedKw of res.keywords) { const kw = parsedKw.keyword; switch (kw) { case 'head': q[head ?? 'name'] = parsedKw.parsed; break; case 'name': q.name = parsedKw.parsed; break; case 'content': q.content = parsedKw.parsed; break; case 'id': if (parsedKw.parsed.length) { q.id = parsedKw.parsed; } break; case 'kind': if (parsedKw.parsed.length) { q.kind = parsedKw.parsed; } break; case 'in': if (parsedKw.parsed.length) { // TODO if uuid's deep op (*) is present will have to include all collections // in that parent q.parent = parsedKw.parsed.map(r => r.id); } break; case 'by': if (parsedKw.parsed.length) { q.author = parsedKw.parsed.map(r => r.id); } break; case 'draft': q.draft = parseBooleanKeyword(findSeg(parsedKw)); break; case 'archived': parseBooleanOrTimestamp('archived', findSeg(parsedKw), q); break; case 'deleted': parseBooleanOrTimestamp('deleted', findSeg(parsedKw), q); break; case 'todo': q.completed = false; break; case 'done': q.completed = parseBooleanKeyword(findSeg(parsedKw)); applyTimestampIfPresent('completed_at', findSeg(parsedKw), q); break; case 'created': case 'updated': case 'changed': { const f = `${kw}_at`; const iv = timestampSegmentToInterval(findSeg(parsedKw)); if (iv) mergeIntervalField(q, f, iv); break; } case 'date': { const iv = dateOrTimeSegmentToInterval(findSeg(parsedKw), 'date'); if (iv) q.date = iv; break; } case 'time': { const iv = dateOrTimeSegmentToInterval(findSeg(parsedKw), 'time'); if (iv) q.time = iv; break; } case 'sort': q.sort = parseSort(findSeg(parsedKw)); break; // TODO: implement when added // case 'limit': // q.limit = parseInt(seg.body.trim(), 10); // break; default: // handle utilities // Handle kind if (KINDS.includes(kw)) { if (!q.kind) { q.kind = []; } if (!q.kind.includes(kw)) { q.kind.push(kw); } } // Handle today, yesterday // TODO: analyze interferrence between @changed and today/yesterday // TODO: analyze when two of them are provided: today and yesterday — perhaps we merge the interval? if (['today', 'yesterday'].includes(kw)) { const util = { util: kw }; const iv = singleTimestampTokenToInterval({ kind: 'dateutil', value: util }); if (iv) mergeIntervalField(q, 'changed_at', iv); } break; } } return q; } // TODO: how does this work? function mergeIntervalField(q, field, iv) { const prev = q[field]; if (!prev) { q[field] = iv; return; } q[field] = [Math.max(prev[0], iv[0]), Math.min(prev[1], iv[1])]; } function parseBooleanKeyword(seg) { const tok = seg.tokens.find((t) => t.kind === 'boolean'); return tok ? tok.value : true; // default to true if no explicit value } function parseBooleanOrTimestamp(fieldBase, seg, q) { const boolTok = seg.tokens.find((t) => t.kind === 'boolean'); if (boolTok) { q[fieldBase] = boolTok.value; return; } q[fieldBase] = true; // presence implies true const iv = timestampSegmentToInterval(seg); if (iv) mergeIntervalField(q, `${fieldBase}_at`, iv); } function applyTimestampIfPresent(field, seg, q) { const iv = timestampSegmentToInterval(seg); if (iv) mergeIntervalField(q, field, iv); } /* ======================================================================= */ /* helpers */ /* ======================================================================= */ function timestampSegmentToInterval(seg) { const tsToks = seg.tokens.filter((t) => t.kind === 'date' || t.kind === 'datetime' || t.kind === 'dateutil'); if (tsToks.length === 0 || tsToks.length > 2) return undefined; if (tsToks.length === 1) { return singleTimestampTokenToInterval(tsToks[0]); } // Exactly two — merge by intersection const a = singleTimestampTokenToInterval(tsToks[0]); const b = singleTimestampTokenToInterval(tsToks[1]); if (!a || !b) return undefined; return [Math.max(a[0], b[0]), Math.min(a[1], b[1])]; } function singleTimestampTokenToInterval(tok) { switch (tok.kind) { case 'dateutil': return dateUtilityToInterval(tok.value); case 'date': return dateValueToInterval(tok.op ?? '', tok.value); case 'datetime': return datetimeToInterval(tok.op ?? '', tok.value); default: return undefined; } } /* ───────────────────────────── utilities ─────────────────────────────── */ function dateUtilityToInterval(u) { const now = moment(); // local time if (u.util === 'today') { return [now.clone().startOf('day').unix(), now.clone().startOf('day').add(1, 'day').unix()]; } if (u.util === 'yesterday') { const y = now.clone().subtract(1, 'day'); return [y.startOf('day').unix(), y.startOf('day').add(1, 'day').unix()]; } // lastN{unit} const r = u; const unitMap = { days: 'day', weeks: 'week', months: 'month', years: 'year', }; const singularUnit = unitMap[r.unit]; const start = now.clone().subtract(r.n, singularUnit).startOf(singularUnit); return [start.unix(), now.clone().endOf('day').unix()]; } function dateValueToInterval(op, dv) { const ts = moment(`${dv.y}/${dv.m ?? 1}/${dv.d ?? 1}`, 'YYYY/M/D', true); const unit = dv.d ? 'day' : dv.m ? 'month' : 'year'; if (op === '>') return [ts.clone().startOf(unit).unix(), Infinity]; if (op === '<') return [0, ts.clone().startOf(unit).unix()]; return [ts.clone().startOf(unit).unix(), ts.clone().startOf(unit).add(1, unit).unix()]; } function datetimeToInterval(op, dt) { const ts = moment(`${dt.y}/${dt.m}/${dt.d} ${dt.h}:${dt.min}`, 'YYYY/M/D H:mm', true); if (op === '>') return [ts.unix(), Infinity]; if (op === '<') return [0, ts.unix() - 1]; return [ts.unix(), ts.clone().add(1, 'minute').unix()]; } function dateOrTimeSegmentToInterval(seg, kind) { const toks = seg.tokens.filter((t) => t.kind === kind); if (toks.length === 0 || toks.length > 2) return undefined; const raw = toks.map((t) => t.value); if (toks.length === 1) return [raw[0], raw[0]]; return [raw[0], raw[1]]; } function parseSort(seg) { const res = []; for (const t of seg.tokens) { if (t.kind !== 'string') continue; const [field, dir] = t.value.split(':'); res.push({ field: field, dir: dir }); } return res; }