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
JavaScript
// 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;
}