hephaestus/heph-pwa/src/quickadd.js
Erich Blume c3111d498b feat(heph-pwa): port quickadd + datespec parsers to JS (with parity tests)
Faithful JS ports of hephd's quickadd.rs / datespec.rs so the PWA's quick-add
accepts the identical syntax (p1-4, #Project greedy match, today/+3d/fri/ISO,
'every …' recurrence) and produces the same RRULEs and local-midnight do-dates
as the CLI/TUI. test/parsers.test.mjs replays the Rust unit cases under
`node --test` (13/13 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:42:09 -07:00

113 lines
3.5 KiB
JavaScript

// Single-line natural-language quick-add — a faithful JS port of hephd's
// `quickadd.rs` (tech-spec §8.1). Todoist-style capture:
// `Water plants tomorrow p2 #Chores every 3 days`
//
// Recognized inline tokens are extracted and the remainder is the title (order
// preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1):
// - Priority p1..p4 → attention (p1 red, p2 orange, p3 blue, p4 white)
// - Project #Name → resolved against existing projects, greedily matching
// multi-word titles (#Camano Chores). Unresolved #tags
// stay in the title verbatim (no surprise project).
// - Do-date a datespec token: today/tomorrow/+3d/fri/ISO
// - Recurrence an `every …` phrase (the longest suffix that parses)
import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js";
/** p1..p4 → attention color string (matching the RPC serialization), or null. */
function priorityAttention(token) {
switch (token.toLowerCase()) {
case "p1": return "red";
case "p2": return "orange";
case "p3": return "blue";
case "p4": return "white";
default: return null;
}
}
/**
* Greedily match `first` (+ following words) against a known project title,
* case-insensitively, longest-first. Returns [projectId, extraWordsTaken] or
* null. `projects` is an array of { id, title }.
*/
function matchProject(first, rest, projects) {
const maxExtra = Math.min(rest.length, 4);
for (let extra = maxExtra; extra >= 0; extra--) {
const candidate = [first, ...rest.slice(0, extra)].join(" ");
const p = projects.find((p) => p.title.toLowerCase() === candidate.toLowerCase());
if (p) return [p.id, extra];
}
return null;
}
/** Find the first `every` token and consume the longest suffix that parses. */
function extractRecurrence(tokens, out) {
const start = tokens.findIndex((t) => t.toLowerCase() === "every");
if (start === -1) return;
for (let end = tokens.length; end > start + 1; end--) {
const phrase = tokens.slice(start, end).join(" ");
const rrule = parseRecurrenceOrNull(phrase);
if (rrule) {
out.recurrence = rrule;
tokens.splice(start, end - start);
return;
}
}
}
/**
* Parse a quick-add line against `today` (a local-midnight Date) and the known
* `projects` (array of { id, title }). Returns:
* { title, attention|null, doDate(ms)|null, recurrence(RRULE)|null, projectId|null }
*/
export function parse(input, todayDate, projects = []) {
const tokens = input.split(/\s+/).filter(Boolean);
const out = {
title: "",
attention: null,
doDate: null,
recurrence: null,
projectId: null,
};
extractRecurrence(tokens, out);
const title = [];
let i = 0;
while (i < tokens.length) {
const tok = tokens[i];
const att = priorityAttention(tok);
if (att !== null) {
out.attention = att;
i += 1;
continue;
}
if (tok.startsWith("#")) {
const stripped = tok.slice(1);
const matched = matchProject(stripped, tokens.slice(i + 1), projects);
if (matched) {
out.projectId = matched[0];
i += 1 + matched[1];
continue;
}
// Unresolved #tag: keep the word (with the #) in the title.
}
if (out.doDate === null) {
try {
out.doDate = toEpochMs(parseDate(tok, todayDate));
i += 1;
continue;
} catch {
// not a date token; fall through to title
}
}
title.push(tok);
i += 1;
}
out.title = title.join(" ");
return out;
}