From 70d9d8a890d000a8281b115298441ad80934e9eb Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 13 Jun 2026 21:23:11 +0200 Subject: [PATCH] feat(todotxt): add parser/serializer for todo.txt format Pure utility with Task model, line/file parse/serialize, project/context/tag extraction, due/rec segment splitting, and toggleCompleted. Round-trip tests cover priorities, dates, tag tokens, mid-word @ guards, and blank lines. --- src/utils/todotxt.spec.ts | 238 ++++++++++++++++++++++++++++++++++++++ src/utils/todotxt.ts | 231 ++++++++++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 src/utils/todotxt.spec.ts create mode 100644 src/utils/todotxt.ts diff --git a/src/utils/todotxt.spec.ts b/src/utils/todotxt.spec.ts new file mode 100644 index 0000000..4d98eb7 --- /dev/null +++ b/src/utils/todotxt.spec.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest" + +import { + contextsOf, + isBlank, + parseFile, + parseLine, + projectsOf, + removeTagFromBody, + removeTokenFromBody, + segmentBody, + serializeFile, + serializeTask, + tagsOf, + toggleCompleted +} from "./todotxt" + +describe("todotxt parseLine", () => { + it("parses a plain task with no metadata", () => { + const task = parseLine("Pick up groceries") + expect(task.completed).toBe(false) + expect(task.priority).toBeUndefined() + expect(task.completionDate).toBeUndefined() + expect(task.creationDate).toBeUndefined() + expect(task.body).toBe("Pick up groceries") + }) + + it("parses priority + creation date + body", () => { + const task = parseLine("(A) 2024-01-15 Call Mom +Family @phone") + expect(task.priority).toBe("A") + expect(task.creationDate).toBe("2024-01-15") + expect(task.body).toBe("Call Mom +Family @phone") + }) + + it("parses a completed task with completion + creation dates", () => { + const task = parseLine("x 2024-01-20 (A) 2024-01-15 Finish report") + expect(task.completed).toBe(true) + expect(task.completionDate).toBe("2024-01-20") + expect(task.priority).toBe("A") + expect(task.creationDate).toBe("2024-01-15") + expect(task.body).toBe("Finish report") + }) + + it("parses a completed task with no completion date", () => { + const task = parseLine("x Buy milk") + expect(task.completed).toBe(true) + expect(task.completionDate).toBeUndefined() + expect(task.body).toBe("Buy milk") + }) + + it("does not treat uppercase X as completion", () => { + const task = parseLine("X-ray appointment") + expect(task.completed).toBe(false) + expect(task.body).toBe("X-ray appointment") + }) + + it("does not match a lowercase priority", () => { + const task = parseLine("(a) lowercase priority is body text") + expect(task.priority).toBeUndefined() + expect(task.body).toBe("(a) lowercase priority is body text") + }) +}) + +describe("todotxt serializeTask", () => { + it("round-trips a complex task", () => { + const input = "x 2024-01-20 (A) 2024-01-15 Finish report +work @office" + expect(serializeTask(parseLine(input))).toBe(input) + }) + + it("round-trips a plain task", () => { + const input = "Buy milk" + expect(serializeTask(parseLine(input))).toBe(input) + }) + + it("round-trips priority + body", () => { + const input = "(B) Walk the dog" + expect(serializeTask(parseLine(input))).toBe(input) + }) + + it("drops completion date when task is not completed", () => { + const task = parseLine("(A) 2024-01-15 Hello") + const out = serializeTask({ ...task, completionDate: "2024-02-01" }) + expect(out).toBe("(A) 2024-01-15 Hello") + }) +}) + +describe("todotxt projectsOf / contextsOf / tagsOf", () => { + it("extracts projects", () => { + const task = parseLine("Plan trip +Travel +Family @home") + expect(projectsOf(task)).toEqual(["Travel", "Family"]) + }) + + it("extracts contexts", () => { + const task = parseLine("Call +Family @phone @home") + expect(contextsOf(task)).toEqual(["phone", "home"]) + }) + + it("extracts key:value tags", () => { + const task = parseLine("Finish report due:2024-02-01 pri:A") + expect(tagsOf(task)).toEqual({ due: "2024-02-01", pri: "A" }) + }) + + it("does not treat an email address as a context", () => { + const task = parseLine("Email boss at boss@example.com") + expect(contextsOf(task)).toEqual([]) + }) + + it("does not treat a URL scheme as a tag", () => { + const task = parseLine("Read https://example.com later") + expect(tagsOf(task)).toEqual({}) + }) +}) + +describe("todotxt segmentBody", () => { + it("splits text + project + context", () => { + expect(segmentBody("Call Mom +Family @phone")).toEqual([ + { kind: "text", value: "Call Mom " }, + { kind: "project", value: "Family" }, + { kind: "text", value: " " }, + { kind: "context", value: "phone" } + ]) + }) + + it("keeps mid-word @ as text", () => { + expect(segmentBody("Email boss@example.com")).toEqual([ + { kind: "text", value: "Email boss@example.com" } + ]) + }) + + it("handles a token at the start", () => { + expect(segmentBody("@phone Call Mom")).toEqual([ + { kind: "context", value: "phone" }, + { kind: "text", value: " Call Mom" } + ]) + }) + + it("recognizes due: and rec: as their own segments", () => { + expect(segmentBody("Pay rent due:2025-01-15 rec:1m")).toEqual([ + { kind: "text", value: "Pay rent " }, + { kind: "due", value: "2025-01-15" }, + { kind: "text", value: " " }, + { kind: "rec", value: "1m" } + ]) + }) + + it("does not match due: inside a word", () => { + expect(segmentBody("xdue:foo")).toEqual([ + { kind: "text", value: "xdue:foo" } + ]) + }) +}) + +describe("todotxt removeTagFromBody", () => { + it("removes a due tag", () => { + expect( + removeTagFromBody("Pay rent due:2025-01-15 rec:1m", "due", "2025-01-15") + ).toBe("Pay rent rec:1m") + }) + + it("removes a rec tag at the end", () => { + expect(removeTagFromBody("Pay rent rec:1m", "rec", "1m")).toBe("Pay rent") + }) +}) + +describe("todotxt removeTokenFromBody", () => { + it("removes a project token mid-body", () => { + expect( + removeTokenFromBody("Plan trip +Travel +Family @home", "+", "Travel") + ).toBe("Plan trip +Family @home") + }) + + it("removes a context token at the start", () => { + expect(removeTokenFromBody("@phone Call Mom", "@", "phone")).toBe( + "Call Mom" + ) + }) + + it("removes a token at the end", () => { + expect(removeTokenFromBody("Call Mom @phone", "@", "phone")).toBe( + "Call Mom" + ) + }) + + it("leaves untouched a token-prefix substring inside another word", () => { + expect(removeTokenFromBody("boss@example.com here", "@", "example")).toBe( + "boss@example.com here" + ) + }) +}) + +describe("todotxt parseFile / serializeFile", () => { + it("round-trips a file with trailing newline", () => { + const input = `(A) 2024-01-15 First task +work +x 2024-01-20 Second task +Plain task +` + const parsed = parseFile(input) + expect(parsed).toHaveLength(3) + expect(serializeFile(parsed)).toBe(input) + }) + + it("preserves blank lines", () => { + const input = `Task one + +Task two +` + const parsed = parseFile(input) + expect(parsed).toHaveLength(3) + expect(isBlank(parsed[1])).toBe(true) + expect(serializeFile(parsed)).toBe(input) + }) + + it("handles file with no trailing newline", () => { + const parsed = parseFile("only line") + expect(serializeFile(parsed, { trailingNewline: false })).toBe("only line") + }) +}) + +describe("todotxt toggleCompleted", () => { + it("marks a task complete with today's date", () => { + // Construct via local-time fields so the test doesn't depend on the + // runner's timezone (todayIso uses getFullYear/getMonth/getDate). + const now = new Date(2024, 2, 15, 12, 0) + const task = parseLine("(A) 2024-01-15 Call Mom") + const toggled = toggleCompleted(task, now) + expect(toggled.completed).toBe(true) + expect(toggled.completionDate).toBe("2024-03-15") + expect(serializeTask(toggled)).toBe("x 2024-03-15 (A) 2024-01-15 Call Mom") + }) + + it("uncompletes a task and drops the completion date", () => { + const task = parseLine("x 2024-01-20 (A) 2024-01-15 Call Mom") + const toggled = toggleCompleted(task) + expect(toggled.completed).toBe(false) + expect(toggled.completionDate).toBeUndefined() + expect(serializeTask(toggled)).toBe("(A) 2024-01-15 Call Mom") + }) +}) diff --git a/src/utils/todotxt.ts b/src/utils/todotxt.ts new file mode 100644 index 0000000..fe54e22 --- /dev/null +++ b/src/utils/todotxt.ts @@ -0,0 +1,231 @@ +// Parser / serializer for the todo.txt format. +// Spec reference: https://github.com/todotxt/todo.txt + +export interface Task { + raw: string + completed: boolean + priority?: string + completionDate?: string + creationDate?: string + body: string +} + +export interface BlankLine { + blank: true + raw: string +} + +export type FileLine = Task | BlankLine + +export const isBlank = (line: FileLine): line is BlankLine => + (line as BlankLine).blank === true + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ +const PRIORITY_RE = /^\(([A-Z])\)$/ +const PROJECT_TOKEN_RE = /(?:^|\s)\+(\S+)/g +const CONTEXT_TOKEN_RE = /(?:^|\s)@(\S+)/g +// Match key:value tags but skip URL-like values (anything containing a slash) +// to avoid grabbing the scheme of `https://example.com`. +const TAG_TOKEN_RE = /(?:^|\s)([A-Za-z][A-Za-z0-9_-]*):([^\s/]+)/g + +const tryConsume = ( + rest: string, + predicate: (token: string) => boolean +): { token: string; rest: string } | null => { + const match = rest.match(/^(\S+)(\s+|$)/) + if (!match || !predicate(match[1])) return null + return { token: match[1], rest: rest.slice(match[0].length) } +} + +export const parseLine = (line: string): Task => { + let rest = line + let completed = false + let priority: string | undefined + let completionDate: string | undefined + let creationDate: string | undefined + + if (rest.startsWith("x ")) { + completed = true + rest = rest.slice(2) + + const dateAttempt = tryConsume(rest, (t) => DATE_RE.test(t)) + if (dateAttempt) { + completionDate = dateAttempt.token + rest = dateAttempt.rest + } + } + + const priorityAttempt = tryConsume(rest, (t) => PRIORITY_RE.test(t)) + if (priorityAttempt) { + priority = priorityAttempt.token.slice(1, 2) + rest = priorityAttempt.rest + } + + const creationAttempt = tryConsume(rest, (t) => DATE_RE.test(t)) + if (creationAttempt) { + creationDate = creationAttempt.token + rest = creationAttempt.rest + } + + return { + raw: line, + completed, + priority, + completionDate, + creationDate, + body: rest + } +} + +export const serializeTask = (task: Task): string => { + const parts: string[] = [] + if (task.completed) { + parts.push("x") + if (task.completionDate) parts.push(task.completionDate) + } + if (task.priority) parts.push(`(${task.priority})`) + if (task.creationDate) parts.push(task.creationDate) + if (task.body.length) parts.push(task.body) + return parts.join(" ") +} + +export const parseFile = (text: string): FileLine[] => { + const lines = text.split("\n") + // Drop the trailing empty element produced by a final newline. + // We re-add a trailing newline in serializeFile when there was one in the source. + const trailing = lines.length > 0 && lines[lines.length - 1] === "" + const meaningful = trailing ? lines.slice(0, -1) : lines + + return meaningful.map((line) => { + if (line.trim() === "") return { blank: true, raw: line } + return parseLine(line) + }) +} + +export const serializeFile = ( + items: FileLine[], + { trailingNewline = true }: { trailingNewline?: boolean } = {} +): string => { + const lines = items.map((item) => + isBlank(item) ? item.raw : serializeTask(item) + ) + return lines.join("\n") + (trailingNewline ? "\n" : "") +} + +const collectTokens = (body: string, re: RegExp): string[] => { + const out: string[] = [] + for (const match of body.matchAll(re)) { + out.push(match[1]) + } + return out +} + +export const projectsOf = (task: Task): string[] => + collectTokens(task.body, PROJECT_TOKEN_RE) + +export const contextsOf = (task: Task): string[] => + collectTokens(task.body, CONTEXT_TOKEN_RE) + +export const tagsOf = (task: Task): Record => { + const out: Record = {} + for (const match of task.body.matchAll(TAG_TOKEN_RE)) { + out[match[1]] = match[2] + } + return out +} + +export type BodySegment = + | { kind: "text"; value: string } + | { kind: "project" | "context"; value: string } + | { kind: "due" | "rec"; value: string } + +// Split a task body into display segments. `due:VALUE` and `rec:VALUE` tags +// get their own segment kinds so the UI can render them as themed chips; +// `+project` and `@context` tokens become project/context segments. +// All tokens only count when they sit at the start of the body or after +// whitespace — so mid-word `@` (e.g. `boss@example.com`) stays as text. +export const segmentBody = (body: string): BodySegment[] => { + const segments: BodySegment[] = [] + const re = /(due|rec):(\S+)|([+@])(\S+)/g + let last = 0 + for (const match of body.matchAll(re)) { + const start = match.index ?? 0 + const prevChar = start > 0 ? body[start - 1] : " " + if (!/\s/.test(prevChar)) continue + if (start > last) { + segments.push({ kind: "text", value: body.slice(last, start) }) + } + if (match[1]) { + segments.push({ + kind: match[1] as "due" | "rec", + value: match[2] + }) + } else { + segments.push({ + kind: match[3] === "+" ? "project" : "context", + value: match[4] + }) + } + last = start + match[0].length + } + if (last < body.length) { + segments.push({ kind: "text", value: body.slice(last) }) + } + return segments +} + +const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +// Remove a `+project` or `@context` token from a task body. +// Anchored on `(^|\s)` so mid-word matches (e.g. inside email addresses) are not touched. +export const removeTokenFromBody = ( + body: string, + prefix: "+" | "@", + value: string +): string => { + const re = new RegExp( + `(?:^|\\s)\\${prefix}${escapeRegex(value)}(?=\\s|$)`, + "g" + ) + return body.replace(re, "").replace(/[ \t]+/g, " ").trim() +} + +// Remove a `key:value` tag (e.g. `due:2025-01-15`) from a task body. +export const removeTagFromBody = ( + body: string, + key: string, + value: string +): string => { + const re = new RegExp( + `(?:^|\\s)${escapeRegex(key)}:${escapeRegex(value)}(?=\\s|$)`, + "g" + ) + return body.replace(re, "").replace(/[ \t]+/g, " ").trim() +} + +const todayIso = (now: Date): string => { + const y = now.getFullYear() + const m = String(now.getMonth() + 1).padStart(2, "0") + const d = String(now.getDate()).padStart(2, "0") + return `${y}-${m}-${d}` +} + +// Toggle the completed state of a task. +// On complete: stamp today's completion date. Priority stays in place per spec. +// On uncomplete: drop completion date but leave the rest of the line as-is — +// we deliberately do not try to recover any original priority that may have +// been stashed in a `pri:X` tag. +export const toggleCompleted = (task: Task, now = new Date()): Task => { + if (task.completed) { + return { + ...task, + completed: false, + completionDate: undefined + } + } + return { + ...task, + completed: true, + completionDate: todayIso(now) + } +}