generated from eblume/project-template
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>
113 lines
3.5 KiB
JavaScript
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;
|
|
}
|