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.
This commit is contained in:
Julien Calixte
2026-06-13 21:23:11 +02:00
parent 9380eace92
commit 70d9d8a890
2 changed files with 469 additions and 0 deletions

238
src/utils/todotxt.spec.ts Normal file
View File

@@ -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")
})
})

231
src/utils/todotxt.ts Normal file
View File

@@ -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<string, string> => {
const out: Record<string, string> = {}
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)
}
}