Nov 04, 2025
6 min read

Todo.txt Format Meets Modern Web: Parsing Plain Text in a Reactive World

Building a parser for the todo.txt format means understanding a spec from 2006 and making it work with reactive frameworks.

I love the todo.txt format. It’s plain text, human-readable, and works everywhere. You can edit it in vim, sync it with Syncthing, and parse it with a script. No databases, no lock-in, no API that breaks when the company pivots.

But parsing it properly requires understanding a spec that’s older than React. And then you’ve got to make it work in a reactive UI where everything updates instantly. Here’s what I learned building a todo.txt parser for my Svelte app.

The Todo.txt Format

The spec is simple. Each line is a todo. The format looks like this:

(A) Call Mom @phone +Family due:2024-10-27
x 2024-10-26 (B) Fix bug @work +Project rec:+1w
2024-10-25 Write blog post @computer +Writing

The parts, in order: completion marker (x), completion date, priority ((A) through (Z)), creation date, description text, contexts (@word), projects (+word), and key-value metadata (key:value).

The trick is that most of these are optional. A valid todo can be as simple as “Buy milk” or as detailed as “x 2024-10-26 (A) 2024-10-20 Call dentist @phone +Health due:2024-10-27 rec:+6m”.

Parsing Strategy: Tokenize First

My parser splits each line into tokens (space-separated words), then walks through them in order:

export function parseTodoLine(line, index = 0) {
  const trimmed = line.trim();
  if (!trimmed) return null;

  const todo = {
    id: index,
    raw: line,
    completed: false,
    priority: null,
    creationDate: null,
    completionDate: null,
    description: '',
    contexts: [],
    projects: [],
    metadata: {}
  };

  let tokens = trimmed.split(/\s+/);
  let position = 0;

  // Check for completion marker
  if (tokens[position] === 'x') {
    todo.completed = true;
    position++;

    // Completion date (required after 'x')
    if (tokens[position] && DATE_PATTERN.test(tokens[position])) {
      todo.completionDate = tokens[position];
      position++;
    }
  }

  // Check for priority (only for incomplete tasks)
  if (!todo.completed && tokens[position] && PRIORITY_PATTERN.test(tokens[position])) {
    todo.priority = tokens[position].charAt(1); // Extract letter from (A)
    position++;
  }

  // Check for creation date
  if (tokens[position] && DATE_PATTERN.test(tokens[position])) {
    todo.creationDate = tokens[position];
    position++;
  }

  // ... parse remaining description, contexts, projects
}

The key insight is the order matters. Completion marker comes first. Then completion date (only if completed). Then priority (only if not completed). Then creation date. After that, everything else is fair game.

Handling Edge Cases: URLs vs Metadata

The spec says metadata is key:value format. But what about URLs? “Visit https://example.com” shouldn’t be parsed as metadata where the key is “https” and the value is “//example.com”.

// Metadata key:value (but exclude URLs)
else if (token.includes(':') && !token.startsWith(':') && !token.endsWith(':')) {
  // Skip URLs - don't treat them as metadata
  const urlSchemes = ['http://', 'https://', 'ftp://', 'ftps://', 'mailto:', 'file://'];
  const isUrl = urlSchemes.some(scheme => token.toLowerCase().startsWith(scheme));

  if (isUrl) {
    descriptionParts.push(token);
  } else {
    const colonIndex = token.indexOf(':');
    const key = token.substring(0, colonIndex);
    const value = token.substring(colonIndex + 1);
    if (key && value) {
      todo.metadata[key] = value;
    }
  }
}

This feels like a hack, but it works. We check for common URL schemes before treating something as metadata. If it’s a URL, it goes into the description. If it’s actually metadata, we split on the colon and store it.

Extracting Common Metadata

The spec doesn’t define standard metadata keys, but conventions have emerged. due: for due dates, t: for threshold dates (don’t show until this date), rec: for recurrence.

// Extract common metadata
if (todo.metadata.due) {
  todo.dueDate = todo.metadata.due;
}
if (todo.metadata.t) {
  todo.thresholdDate = todo.metadata.t;
}
if (todo.metadata.rec) {
  todo.recurrence = parseRecurrence(todo.metadata.rec);
}

Recurrence is especially fun. The format is like rec:1d (repeat daily) or rec:+2w (repeat every 2 weeks from completion). The + prefix means “strict mode”—repeat from completion date, not from the original due date.

function parseRecurrence(rec) {
  if (!rec) return null;

  const match = rec.match(/^(\+)?(\d+)([dwmyb])?$/);
  if (!match) return null;

  const [, strict, count, unit] = match;

  const unitMap = {
    d: 'day',
    b: 'businessDay',
    w: 'week',
    m: 'month',
    y: 'year'
  };

  return {
    count: parseInt(count, 10),
    unit: unitMap[unit] || 'day',
    strict: !!strict
  };
}

This gives us a structured object we can work with in the UI. Want to show “Repeats every 2 weeks”? Just format { count: 2, unit: 'week' }.

Regex Patterns for Validation

Date and priority matching is done with regex:

const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const PRIORITY_PATTERN = /^\([A-Z]\)$/;

These are strict. Dates must be YYYY-MM-DD format. Priorities must be single uppercase letters in parentheses. No lowercase, no numbers, no multi-letter codes.

This strictness is good. It means we don’t accidentally parse “(see notes)” as a priority, or “2024/10/27” as a date. The spec is clear, so we enforce it.

Parsing the Whole File

Once you can parse a line, parsing a file is trivial:

export function parseTodoFile(content) {
  if (!content) return [];

  const lines = content.split('\n');
  const todos = [];

  lines.forEach((line, index) => {
    const parsed = parseTodoLine(line, index);
    if (parsed) {
      todos.push(parsed);
    }
  });

  return todos;
}

Empty lines are skipped. Each todo gets an ID based on its line number. This makes it easy to update the file later—just replace line N with the new content.

Reactive Integration

In Svelte, parsing happens whenever the file changes. Users can edit todos in the UI, or sync the file with Syncthing, or edit it directly in a text editor. The app watches the file and re-parses on every change.

// In the todos store
async function loadFromFile() {
  if (!fileHandle) return;

  const file = await fileHandle.getFile();
  const content = await file.text();

  const parsed = parseTodoFile(content);
  todos = parsed;
}

Because Svelte’s reactivity is based on assignment (todos = parsed), the UI updates instantly. No need to manually trigger re-renders or diff the old and new lists. Just assign and go.

Lessons Learned

Plain text formats are amazing for durability, but you’ve got to handle the edge cases. URLs with colons, metadata that looks like descriptions, dates in wrong formats—it all happens. Order matters in todo.txt. Parsing isn’t just “split by space and look for patterns.” You’ve got to respect the sequence. Test with real-world data. Users won’t follow the spec perfectly. They’ll type “(a)” instead of “(A)”, or “2024/10/27” instead of “2024-10-27”. Decide whether to be strict or forgiving.

The beauty of todo.txt is its simplicity. A 200-line parser covers the entire spec. No lexer, no AST, no parser generator. Just split, match, and extract. That’s the kind of format that lasts decades.

References