Files
mdcaldav/SPEC.md
Tyler Perkins 8b5cd370ea
Some checks failed
CI / test (push) Failing after 11s
CI / image (push) Has been skipped
Initial commit
2026-08-01 21:03:35 -04:00

30 KiB
Raw Blame History

markdown-to-caldav — Implementation Specification

Status: Implemented · Date: 2026-07-31 · Target runtime: Python 3.14, Radicale 3.7.7


1. Overview

markdown-to-caldav exposes the TODO checkboxes scattered through a directory of markdown notes (a zk or Obsidian vault) as a CalDAV task server. Standard CalDAV clients — DAVx⁵ + Tasks.org, jtx Board, Thunderbird — discover the vault as one or more task lists, display the tasks with their nesting and priorities, and can check them off. Completing a task in the client rewrites the corresponding - [ ] to - [x] in the original markdown file.

The markdown files remain the single source of truth. There is no separate task database that can drift; the sidecar index (§6) is a derived cache that can be deleted and rebuilt at any time.

1.1 Design goals

  1. The vault is authoritative. Every task the server serves is a line in a file. Nothing exists only in the server.
  2. Notes stay human-first. No IDs, no metadata blocks, no reformatting injected into the markdown. A file edited by the server must remain something you'd be happy to open in vim.
  3. Bidirectional, promptly. Vim edits appear in the client within seconds; client edits appear in the file immediately.
  4. Never corrupt notes. A bug, a crash, or a misbehaving client must not be able to mangle a file. Concurrent edits are detected and rejected, not merged blindly.

1.2 Architecture

Radicale provides the protocol layer; we provide a storage plugin that presents markdown as calendar collections of VTODO items.

CalDAV client (Tasks.org / jtx Board / Thunderbird)
        │  HTTP: PROPFIND, REPORT, PUT, DELETE
        ▼
Radicale 3.7.7          ← WebDAV/CalDAV verbs, XML, auth, sync-collection, client quirks
        │  BaseStorage / BaseCollection API
        ▼
mdcaldav.storage:Storage        ← this project
        ├── parser.py     markdown → task tree (exact byte spans)
        ├── ical.py       task ⇄ VTODO text
        ├── index.py      SQLite sidecar: stable UIDs, sync generations
        ├── writer.py     surgical, atomic markdown edits
        └── watcher.py    filesystem watch → incremental rescan
        ▼
~/notes/**/*.md

Why a Radicale plugin rather than a standalone server. CalDAV client compatibility is the dominant risk in a project like this — sync-collection REPORT, ETag semantics, calendar-query filtering, and a long tail of per-client quirks. Radicale has absorbed that work over a decade. We write the markdown↔VTODO mapping (the part that is actually novel) and inherit the rest.

License consequence: Radicale is GPLv3. Running as an in-process plugin makes any distribution of this project GPL-encumbered. Acceptable for a personal tool; stated here so it is not a surprise.


2. Source format

2.1 Task line grammar

<indent><bullet><space>[<state>]<space>(<priority><space>)?<title>(<inline-meta>)*
Element Accepted
indent spaces or tabs; tab width configurable (default 4)
bullet -, *, +
state see §2.2
priority [#A], [#B], [#C] — org-mode placement, immediately after the checkbox
title free text to end of line, minus trailing inline-meta
inline-meta #tagCATEGORIES; optional due syntax (§2.5)

Example exercising every element:

- [ ] [#A] Ship the release #work @due(2026-08-04)

2.2 States

Default state map (configurable via [tasks].states):

Marker STATUS
[ ] NEEDS-ACTION
[x], [X] COMPLETED
[/], [>] IN-PROCESS
[-] CANCELLED

Unrecognized markers ([?], [!]) are treated as NEEDS-ACTION and the original character is preserved on write-back, so third-party checkbox conventions survive a round trip.

2.3 Structure: tasks, descriptions, and group nodes

Nesting is determined by indentation. Each bullet is classified by two questions: does it have a checkbox, and does it have any checkbox descendants?

Has checkbox Has checkbox descendants Classification
yes Task
no no Description — appended to the nearest ancestor task's DESCRIPTION; dropped if there is no ancestor task
no yes Group node — not a task; its text is appended to group_path, and its checkbox children re-parent to the nearest ancestor task (or become roots)

This three-way rule is what makes real vaults work. Each case below occurs routinely in daily-note style vaults and is covered by a fixture in tests/fixtures/vault/.

Description:

- [x] Order the cover crop
  - Arrived on the 12th, invoice is in the binder

→ one task, SUMMARY:Order the cover crop, DESCRIPTION:Arrived on the 12th, invoice is in the binder.

Group node:

- set up the cold frame
  - [x] Cut the polycarbonate
  - [ ] Hinge the lid

→ two root tasks (Cut the polycarbonate, Hinge the lid), each with CATEGORIES including set up the cold frame. The plain parent is not a task — it has no checkbox and cannot be completed — but its text is preserved as context.

Neither:

### Retrospective

- Start the tomatoes earlier next year
- Buy fewer squash varieties

→ no tasks, and no description attached to anything, because no ancestor task exists.

2.4 Context: headings and frontmatter

  • ATX headings (#######) maintain a heading_path stack. Checkboxes are collected under any heading, not only ## TODO — daily notes routinely carry tasks under topic headings such as ## Greenhouse repairs alongside a ## Notes section that has none.
  • YAML frontmatter delimited by --- at the top of the file is parsed for tags (→ CATEGORIES) and date. It is never treated as content.

2.5 Optional due dates

Off by default. When [tasks].due_syntax is configured, the listed patterns are stripped from the title and mapped to DUE. Suggested patterns: 📅 %Y-%m-%d (Obsidian Tasks), @due(%Y-%m-%d). A daily note's frontmatter date may optionally seed DUE via [tasks].daily_note_due.

2.6 Exclusions

Content that must never yield a task:

  • fenced code blocks (``` and ~~~), including a - [ ] inside a shell snippet
  • indented code blocks (4+ spaces beyond list context)
  • YAML frontmatter
  • paths matching [vault].exclude

Path patterns use real glob semantics (globs.py), not fnmatch: **/ means zero or more segments and * does not cross /. With fnmatch, **/*.md fails to match a top-level README.md while *.tmp wrongly matches a/b.tmp — both wrong on a real vault.

Filtering is applied to targeted rescans as well as full scans. The watcher rescans individual paths, so an unfiltered targeted rescan would index excluded files the moment something wrote to them.

Default exclusions cover .git/**, .zk/**, .obsidian/**, assets/**, and Syncthing's artefacts (§9.5).

2.6.1 Syncthing-backed vaults

A Syncthing folder contains three classes of file that must never be parsed:

Path Contents Consequence if indexed
.stversions/** complete historical copies of notes every task duplicated once per retained version
**/*.sync-conflict-* whole duplicate notes from conflicting edits entire files duplicated
**/.syncthing.*, **/~syncthing~* partial in-flight writes torn tasks

.stversions/ is the severe one: with versioning enabled it multiplies the entire task list by the retention depth.

Conflict files are excluded rather than merged — a conflict is a human decision. doctor reports any present so they are not silently forgotten.

Write-back is compatible with Syncthing because edits are atomic (os.replace), so Syncthing always observes a complete file. The residual risk is a genuine concurrent edit on two devices, which Syncthing resolves by producing a conflict file; the task is not lost, but the losing edit waits in that file.

2.7 Parser implementation note

The parser is a hand-rolled line scanner, not markdown-it-py or a CommonMark AST.

Rationale: write-back requires exact byte spans for the checkbox marker, the priority token, and the title, so an edit can touch only those bytes (§7). AST libraries either discard source spans or give line-level maps that are too coarse to edit surgically. We also only need a small subset of markdown (lists, headings, fences, frontmatter). The tradeoff is that exotic CommonMark constructs — lists inside block quotes, lazy continuation lines — are out of scope; §2.6 exclusions and a fixture suite of real files cover what actually occurs.


3. Data model

@dataclass
class Source:
    rel_path: str          # vault-relative, POSIX separators
    line_no: int           # 0-based
    line_span: tuple[int, int]      # byte offsets of the full line
    marker_span: tuple[int, int]    # byte offsets of the state char inside [ ]
    title_span: tuple[int, int]     # byte offsets of the title text
    prio_span: tuple[int, int] | None
    indent: str            # verbatim leading whitespace
    bullet: str            # '-', '*', '+'

@dataclass
class Task:
    uid: str
    title: str
    status: Status                 # NEEDS-ACTION | COMPLETED | IN-PROCESS | CANCELLED
    raw_marker: str                # verbatim char, for lossless round trip
    priority: int | None           # 1..9, RFC 5545
    description: str | None
    due: date | None
    categories: list[str]
    parent_uid: str | None
    children: list[str]
    heading_path: tuple[str, ...]
    group_path: tuple[str, ...]
    sibling_index: int
    depth: int
    source: Source
    content_hash: str              # blake2b of the normalized line

4. iCalendar mapping

Each task becomes one VTODO in its own .ics resource, href <uid>.ics.

Markdown VTODO property
title SUMMARY
[ ] STATUS:NEEDS-ACTION
[x] STATUS:COMPLETED + PERCENT-COMPLETE:100 + COMPLETED
[/] STATUS:IN-PROCESS
[-] STATUS:CANCELLED
[#A] / [#B] / [#C] PRIORITY:1 / 5 / 9
description sub-bullets DESCRIPTION (newline-joined)
indentation RELATED-TO;RELTYPE=PARENT:<parent uid>
heading_path + group_path + tags CATEGORIES
due syntax (§2.5) DUE;VALUE=DATE
sidecar UID UID
X-MD-SOURCE-FILE, X-MD-SOURCE-LINE

Priority mapping follows RFC 5545 §3.8.1.9, which defines 14 as high, 5 as medium, 69 as low; A→1, B→5, C→9 places each org priority in the centre of its band. Inbound values are mapped back by band, so a client sending PRIORITY:3 yields [#A].

4.1 Timestamps

COMPLETED has no markdown representation. Resolution order:

  1. the timestamp recorded in the sidecar when the server observed the [ ][x] transition;
  2. otherwise the file's mtime (for checkboxes already complete at first index);
  3. if neither is available, omit the property.

LAST-MODIFIED and DTSTAMP derive from the source file's mtime.

4.2 Nesting caveat

RELATED-TO;RELTYPE=PARENT is the RFC 5545 mechanism for subtasks. jtx Board and Tasks.org render the hierarchy. Thunderbird ignores RELATED-TO and will show a flat list of every task — the tasks are all present and completable, just not indented. Document this in the README rather than working around it.

4.3 Generation

We emit iCalendar text and hand it to radicale.item.Item(text=...), which accepts a raw string and parses lazily with vobject. This avoids a second iCalendar library as a dependency — vobject arrives with Radicale.


5. Collections

[collections].group_by selects a strategy:

Value Collections produced
directory (default) one per directory at depth (default 1): daily, work, zets
file one per markdown file
heading one per top-level heading across the vault
tag one per frontmatter tag
single one collection for the whole vault

Strategy interface:

class GroupingStrategy(Protocol):
    def collection_for(self, task: Task) -> str: ...   # returns collection href
    def display_name(self, href: str) -> str: ...

Collection hrefs are slugified and stable. A task whose file moves between collections is treated as a delete from the old collection and a create in the new one — CalDAV has no cross-collection move that clients handle reliably.

Each collection reports tag = "VCALENDAR" and advertises VTODO in supported-calendar-component-set, so clients present it as a task list rather than a calendar.


6. Identity and the sidecar index

The central problem: a VTODO needs a UID that is stable across arbitrary vim edits, but markdown carries no identifiers and we have committed to not writing any into the notes.

6.1 Schema

SQLite at [index].db (default ~/.cache/markdown-to-caldav/index.db):

CREATE TABLE meta (schema_version INT, generation INT);

CREATE TABLE files (
  rel_path TEXT PRIMARY KEY, mtime REAL, size INT, hash TEXT, last_scan_gen INT);

CREATE TABLE tasks (
  uid TEXT PRIMARY KEY,
  collection TEXT, rel_path TEXT,
  heading_path TEXT, group_path TEXT,
  sibling_index INT, depth INT,
  title_norm TEXT, content_hash TEXT,
  status TEXT, completed_at TEXT,
  parent_uid TEXT,
  first_seen_gen INT, last_modified_gen INT, deleted_gen INT);

CREATE INDEX tasks_lookup ON tasks(rel_path, heading_path, title_norm);
CREATE INDEX tasks_sync   ON tasks(last_modified_gen);

title_norm is the title lowercased, whitespace-collapsed, and stripped of inline meta.

6.2 Matching algorithm

On rescan of a file, each parsed task is matched against surviving rows for that file first:

  1. Exact(rel_path, heading_path, group_path, title_norm) matches → reuse UID. Survives reordering and status changes, the overwhelmingly common case.
  2. Positional(rel_path, heading_path, depth, sibling_index) matches and title similarity ≥ [index].similarity_threshold (default 0.75, via rapidfuzz) → reuse UID. Survives an in-place retitle.
  3. Cross-file move — considered only when the previous occurrence disappeared in this same scan and the match is unique vault-wide → reuse UID.
  4. No match → mint a new UID (uuid4).
  5. A row not seen this scan → tombstone: set deleted_gen, retain for the sync window (§6.3).

Ambiguity within one file+heading (two identical titles) is resolved by sibling_index.

6.3 Why rule 3 is guarded

Carry-forward is a normal habit in daily notes: an unfinished task gets copied into the next day's file while the previous day's copy stays put as a record. The fixtures model this with Water the seedlings, which appears in three consecutive daily notes. These are deliberate copies, not moves. Matching on title alone across files would collapse them into one task and make completing Wednesday's item silently tick Monday's.

The disappearance requirement is standard rename detection: a task is only considered moved if it is no longer where it was. If both copies exist, both keep their own identity.

6.4 Rebuild

The index is a cache. Deleting the database and rescanning is always safe; the cost is that every UID is regenerated, so clients see a full replacement of their task list. mdcaldav doctor reports index/vault divergence without modifying anything.


7. Write-back

7.1 Supported operations

Operation Edit performed
status change rewrite only the state character inside [ ] (marker_span)
retitle replace title_span; indent, bullet, checkbox, priority and inline meta preserved
priority set/clear insert, replace, or remove the [#X] token at prio_span
description change replace the contiguous non-checkbox child-bullet block at the child indent
create append under [write].inbox_heading in the collection's inbox, creating file/heading if absent
delete remove the task line and its description lines; subtree per [write].delete_children

[write].delete_childrencascade (default — remove the whole subtree, matching client expectations), orphan (promote children one level), reject (return 409 if the task has children).

7.2 Safety invariants

These are the load-bearing requirements of the whole project.

  1. Atomic replacement. Write to a temp file in the same directory, fsync, then os.replace. File mode and ownership are preserved. A crash mid-write leaves the original intact.

  2. Byte-identical elsewhere. Every line the operation does not target must be unchanged, byte for byte. No reflow, no trailing-whitespace stripping, no indent normalization, no line-ending conversion. Final-newline presence is preserved exactly.

  3. Conflict detection before write, in two layers:

    • Client staleness is Radicale's job: it checks If-Match against the item's ETag and returns 412 Precondition Failed before upload() is ever called. Since our ETag derives from the task's content hash, any external edit to that task invalidates the client's cached copy.
    • Stale spans are ours: byte offsets captured at parse time are meaningless once the file changes. Before every write the writer compares the file's hash to what files recorded and reparses if it differs, so an edit is only ever applied through freshly computed offsets. If the task's UID no longer resolves after that reparse, the write is refused rather than guessed.

    Note that an in-place retitle is not a conflict — identity rule 2 (§6.2) says it is the same task, so the write lands on the renamed line. A vim edit is never silently overwritten, but it is also not treated as a different task just because its text moved.

  4. Locking. Radicale's acquire_lock("w") serializes writers process-wide; an additional per-file lock guards the read-modify-write cycle.

  5. Optional backups. When [write].backup_dir is set, the original is copied there once before the first modification of each file per run.

Under directory grouping the inbox resolves per collection: a task created in daily is appended to daily/inbox.md, not to a vault-root inbox.md. Otherwise the new task would be assigned to a different collection and disappear from the list the client just created it in.

7.3 Inbound PUT handling

  1. Parse the submitted .ics.
  2. Resolve UID → sidecar row → file and line. Unknown UID plus [write].allow_create → create in the collection's inbox; unknown UID without it → 403.
  3. On create, adopt the client's UID rather than minting one, so the item remains at the href the client PUT to. The sidecar row is renamed to that UID after the file is reindexed.
  4. Diff submitted properties against the task's current state; compute the minimal set of edits.
  5. Apply under lock (§7.2); bump the generation; return the new ETag.

Priority is compared by band, not by exact value: a client sending PRIORITY:3 against a task already marked [#A] is not a change, so the markdown is left alone.

Properties we do not model (RRULE, VALARM, GEO, …) are ignored on input rather than rejected, so a client that always round-trips its full object does not fail. This is stated in §11 as a non-goal, and mdcaldav doctor warns when ignored properties are seen.


8. Change detection

Freshness comes from the read path. Every discover, get_all, get_multi and sync first calls refresh_if_stale(), which stats the vault and reparses only files whose mtime/size no longer match the index. Because CalDAV has no server push — clients poll — this alone guarantees that an edit made in vim is visible on the client's next sync.

Watching is therefore an optimization, not a correctness mechanism. A watchdog observer with ~300 ms debounce moves rescan cost off the request path, which matters for large vaults. It is enabled by mdcaldav serve (disable with --no-watch) and off by default elsewhere, so embedding the storage plugin never silently spawns threads. [vault].poll_interval selects polling instead of inotify, for network mounts.

Each rescan batch increments meta.generation.

8.1 Sync tokens

Radicale's BaseCollection.sync(old_token) returns (new_token, changed_hrefs).

  • Token format: http://mdcaldav.local/ns/sync/<generation>.
  • Changed set: tasks with last_modified_gen > old_gen, plus tombstones with deleted_gen > old_gen.
  • Tombstones are pruned after [index].tombstone_retention generations (default 1000). A token older than the retained window causes sync() to raise ValueError, which is Radicale's documented signal for "force a full resync" — the correct behavior, not an error to suppress.

9. Radicale integration

9.1 Plugin contract

Radicale loads storage via utils.load_plugin(..., "storage", "Storage", BaseStorage, config), which imports the configured module and fetches the attribute Storage. So mdcaldav/storage.py must define class Storage(BaseStorage). (Radicale 2.x required a class named Collection; that guidance is stale and does not apply to 3.x.)

Radicale validates its own configuration schema and rejects unknown keys, so the vault config cannot ride along inside [storage]. It is supplied out of band: mdcaldav.storage.set_config(cfg) before the plugin is instantiated (what the CLI does), or MDCALDAV_CONFIG=/path/config.toml in the environment.

[storage]
type = mdcaldav.storage

[auth]
type = htpasswd
htpasswd_filename = ~/.config/markdown-to-caldav/users
htpasswd_encryption = bcrypt

[server]
hosts = localhost:5232

9.2 Methods to implement

Verified against Radicale 3.7.7:

class Storage(BaseStorage):
    def discover(self, path, depth="0", child_context_manager=None,
                 user_groups=set()) -> Iterable[CollectionOrItem]
    def move(self, item, to_collection, to_href) -> None
    def create_collection(self, href, items=None, props=None
                          ) -> tuple[BaseCollection, dict, list]
    @contextmanager
    def acquire_lock(self, mode, user="", *args, **kwargs) -> Iterator[None]
    def verify(self) -> bool

class Collection(BaseCollection):
    path, owner, tag, etag, last_modified, is_principal   # properties
    def get_multi(self, hrefs) -> Iterable[tuple[str, Item | None]]
    def get_all(self) -> Iterable[Item]
    def get_filtered(self, filters) -> Iterable[tuple[Item, bool]]
    def has_uid(self, uid) -> bool
    def upload(self, href, item) -> tuple[Item, Item | None]
    def delete(self, href=None) -> None
    def get_meta(self, key=None); def set_meta(self, props)
    def sync(self, old_token="") -> tuple[str, Iterable[str]]
    def serialize(self, vcf_to_ics=False, ShareActions={}) -> str

get_filtered may return (item, False) to let Radicale apply the filter itself; we do this initially and optimize calendar-query server-side only if profiling warrants it.

set_meta on a markdown-backed collection has nowhere durable to write client-set properties (display name, colour). These are held in memory keyed by collection href, so a client that renames a list does not fail — but the rename does not touch the vault and does not survive a restart.

The principal follows the authenticated user. Collections are served under whatever principal the client authenticated as (/tyler/daily/, not a hardcoded /vault/daily/), so hrefs stay inside the principal's namespace as clients expect. The vault itself is single-user; the principal name is presentational.


9.3 One backend per process

[storage] type is a single global option and Application holds exactly one BaseStorage instance; Radicale has no per-collection storage routing. Consequences:

  • This plugin cannot be added to an existing Radicale instance. Setting type would make it serve every path, and collections held by multifilesystem would stop resolving.
  • Deployment is therefore always a second instance, presented either on its own port or under a reverse-proxy sub-path via [server] script_name.

9.3.1 Reverse proxying at a sub-path

Radicale strips script_name from the incoming URI itself (from [server] script_name or the X-Script-Name / SCRIPT_NAME variables) and re-adds it when generating hrefs.

The proxy must therefore pass the full path through, prefix included. Stripping it at the proxy — proxy_pass http://host:port/; with a trailing slash in nginx, or Caddy's handle_path — makes Radicale emit hrefs without the prefix, and clients then walk to collection URLs that do not exist. Use nginx proxy_pass without a trailing slash, or Caddy's handle, and set X-Script-Name.

9.4 Root collection path

discover("/") must yield a collection whose path is "". Radicale filters out any item whose path does not match the request, so a root item reporting a non-empty path is silently dropped from the multistatus — which breaks current-user-principal and leaves clients unable to discover anything. This fails only when authentication is enabled, so it must be covered by an explicit test (§12, test 12) rather than left to client-library behaviour.

10. Configuration

~/.config/markdown-to-caldav/config.toml:

[vault]
path = "~/notes"
include = ["**/*.md"]
exclude = [".git/**", ".zk/**", ".obsidian/**", "assets/**"]
tab_width = 4
watch = false                # `serve` enables this; freshness never depends on it
# poll_interval = 2.0        # set to use polling instead of inotify

[collections]
group_by = "directory"       # directory | file | heading | tag | single
depth = 1

[tasks]
states = { " " = "NEEDS-ACTION", "x" = "COMPLETED", "/" = "IN-PROCESS", "-" = "CANCELLED" }
priority_map = { A = 1, B = 5, C = 9 }
due_syntax = []              # e.g. ["@due(%Y-%m-%d)", "📅 %Y-%m-%d"]

[write]
allow_create = true
allow_delete = true
inbox = "inbox.md"
inbox_heading = "## Inbox"
delete_children = "cascade"  # cascade | orphan | reject
# backup_dir = "~/.local/share/markdown-to-caldav/backups"

[index]
db = "~/.cache/markdown-to-caldav/index.db"
similarity_threshold = 0.75
tombstone_retention = 1000

10.1 CLI

Command Purpose
mdcaldav serve run Radicale with this storage plugin
mdcaldav scan index the vault and print the task tree; no server, no writes
mdcaldav doctor report parse warnings, index divergence, ambiguous identities, ignored properties

scan is the primary debugging tool: it answers "what does the server think my notes say?" without involving a client.


11. Non-goals

Explicitly out of scope for v1:

  • recurrence (RRULE) and alarms (VALARM)
  • VEVENT / calendar sync; this is a task server only
  • multi-user vaults, per-user views, sharing
  • org-mode .org files (the format of org priorities is borrowed; the file format is not)
  • timezone-aware scheduling beyond whole-date DUE
  • conflict merging — conflicts are detected and rejected (§7.2), never auto-resolved

12. Testing strategy

# Test Asserts
1 Fixture parse Real vault files in tests/fixtures/vault/ produce exact expected task trees, covering description bullets, group nodes, note-only bullets, non-## TODO headings, 5-level nesting
2 Round-trip invariant parse → serialize with no mutation → file is byte-identical
3 Surgical write Toggling one task produces a one-line diff and nothing else
4 Identity: reorder Moving a task within a file preserves its UID
5 Identity: retitle Editing a title in place preserves its UID
6 Identity: carry-forward The same title in three daily notes keeps three distinct UIDs; completing one does not affect the others
7 Identity: true move A task that disappears from A and appears in B keeps its UID
8 Conflict Edit the file behind the server, then PUT → 412, file unchanged
9 Code fence - [ ] inside a fenced block yields no task
10 CalDAV integration Radicale in-process + caldav client: discover collections, REPORT, PUT a completion, assert the markdown changed and the ETag advanced
11 Sync token Stale token raises ValueError; fresh token returns only changed hrefs
12 Root PROPFIND / reports current-user-principal, guarding the §9.4 discovery failure

Fixtures in tests/fixtures/vault/ are synthetic — invented content that reproduces the structural cases (group nodes, description bullets, carry-forward duplicates, five-level nesting, code blocks). Never commit real notes into this repository; the test suite must be safe to share and must not depend on anyone's private content.

12.1 Manual client checklist

Point a client at a throwaway copy of a vault, never at live notes, until write-back is trusted.

  • DAVx⁵ discovers the collections; Tasks.org lists tasks with correct nesting and priority
  • Completing on the phone rewrites [ ][x] in the file, and only that
  • Editing in vim surfaces in the client after a sync
  • jtx Board renders subtasks
  • Thunderbird lists tasks flat (expected, §4.2) and completion still round-trips

13. Repository layout

markdown-to-caldav/
├── SPEC.md
├── README.md
├── pyproject.toml
├── src/mdcaldav/
│   ├── config.py        # TOML load + validation
│   ├── model.py         # Task, Source, Status
│   ├── parser.py        # markdown → task tree, byte spans
│   ├── ical.py          # Task ⇄ VTODO text
│   ├── collections.py   # grouping strategies
│   ├── index.py         # SQLite sidecar, identity matching, generations
│   ├── writer.py        # surgical atomic edits
│   ├── watcher.py       # watchdog + debounce
│   ├── storage.py       # Radicale Storage / Collection
│   └── cli.py           # serve | scan | doctor
└── tests/
    ├── fixtures/vault/  # real notes
    └── test_*.py

Dependencies: radicale>=3.7, watchdog, rapidfuzz. Dev: pytest, caldav. tomllib and sqlite3 are stdlib. vobject arrives with Radicale; no separate iCalendar library.


14. Build order

Each step is independently testable; the CalDAV layer came last deliberately, so the risky parsing and identity work was proven before any protocol code existed.

  1. model.py, config.py
  2. parser.py + fixtures + tests 1, 2, 9 — the foundation
  3. ical.py + mapping tests
  4. index.py + identity tests 47 — the subtlest logic in the project
  5. writer.py + tests 3, 8
  6. collections.py, watcher.py
  7. storage.py + tests 10, 11
  8. cli.py, README

Remaining: systemd unit, and the manual client checklist in §12.1 against real clients.