1
18
19export const STATUS_VALUES = ["to-do", "in-progress", "done", "blocked", "pending"] as const;
20export type StatusValue = (typeof STATUS_VALUES)[number];
21
22export interface DateAtom {
23 kind: "date";
24 value: string;
25}
26export interface StatusAtom {
27 kind: "status";
28 value: StatusValue;
29}
30export interface LabelAtom {
31 kind: "label";
32 value: string;
33}
34export type InlineAtom = DateAtom | StatusAtom | LabelAtom;
35
36export type InlineSegment = { type: "text"; text: string } | ({ type: "atom" } & InlineAtom);
37
38const TOKEN_RE = /@(date)\((\d{4}-\d{2}-\d{2})\)|@(status)\((to-do|in-progress|done|blocked|pending)\)|@(label)\(([^()]{1,100})\)/g;
39
40
45export function parseInlineAtoms(text: string): InlineSegment[] {
46 if (typeof text !== "string" || text.length === 0) return text ? [{ type: "text", text }] : [];
47 const segments: InlineSegment[] = [];
48 let last = 0;
49 let m: RegExpExecArray | null;
50 TOKEN_RE.lastIndex = 0;
51 while ((m = TOKEN_RE.exec(text)) !== null) {
52 if (m.index > last) segments.push({ type: "text", text: text.slice(last, m.index) });
53
54
55 if (m[1] === "date") {
56 const iso = m[2] as string;
57 if (!Number.isNaN(Date.parse(iso))) {
58 segments.push({ type: "atom", kind: "date", value: iso });
59 } else {
60 segments.push({ type: "text", text: m[0] });
61 }
62 } else if (m[3] === "status") {
63 segments.push({ type: "atom", kind: "status", value: m[4] as StatusValue });
64 } else if (m[5] === "label") {
65 segments.push({ type: "atom", kind: "label", value: m[6] as string });
66 } else {
67 segments.push({ type: "text", text: m[0] });
68 }
69 last = m.index + m[0].length;
70 }
71 if (last < text.length) segments.push({ type: "text", text: text.slice(last) });
72
73 const merged: InlineSegment[] = [];
74 for (const s of segments) {
75 const prev = merged[merged.length - 1];
76 if (s.type === "text" && prev && prev.type === "text") prev.text += s.text;
77 else merged.push(s);
78 }
79 return merged;
80}
81
82
83export function atomsToText(segments: InlineSegment[]): string {
84 return segments
85 .map((s) => {
86 if (s.type === "text") return s.text;
87 if (s.kind === "date") return `@date(${s.value})`;
88 if (s.kind === "status") return `@status(${s.value})`;
89 return `@label(${s.value})`;
90 })
91 .join("");
92}
93
94
95export const STATUS_COLORS: Record<StatusValue, string> = {
96 "to-do": "gray",
97 "in-progress": "blue",
98 done: "green",
99 blocked: "red",
100 pending: "yellow"
101};
102
103
104export function formatDateAtom(iso: string): string {
105 const d = new Date(`${iso}T00:00:00Z`);
106 return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 10);
107}
108