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:
238
src/utils/todotxt.spec.ts
Normal file
238
src/utils/todotxt.spec.ts
Normal 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
231
src/utils/todotxt.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user