30 KiB
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
- The vault is authoritative. Every task the server serves is a line in a file. Nothing exists only in the server.
- 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.
- Bidirectional, promptly. Vim edits appear in the client within seconds; client edits appear in the file immediately.
- 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 |
#tag → CATEGORIES; 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 aheading_pathstack. Checkboxes are collected under any heading, not only## TODO— daily notes routinely carry tasks under topic headings such as## Greenhouse repairsalongside a## Notessection that has none. - YAML frontmatter delimited by
---at the top of the file is parsed fortags(→CATEGORIES) anddate. 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 1–4 as high, 5 as medium, 6–9 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:
- the timestamp recorded in the sidecar when the server observed the
[ ]→[x]transition; - otherwise the file's mtime (for checkboxes already complete at first index);
- 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:
- Exact —
(rel_path, heading_path, group_path, title_norm)matches → reuse UID. Survives reordering and status changes, the overwhelmingly common case. - Positional —
(rel_path, heading_path, depth, sibling_index)matches and title similarity ≥[index].similarity_threshold(default 0.75, viarapidfuzz) → reuse UID. Survives an in-place retitle. - Cross-file move — considered only when the previous occurrence disappeared in this same scan and the match is unique vault-wide → reuse UID.
- No match → mint a new UID (
uuid4). - 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_children ∈ cascade (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.
-
Atomic replacement. Write to a temp file in the same directory,
fsync, thenos.replace. File mode and ownership are preserved. A crash mid-write leaves the original intact. -
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.
-
Conflict detection before write, in two layers:
- Client staleness is Radicale's job: it checks
If-Matchagainst the item's ETag and returns 412 Precondition Failed beforeupload()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
filesrecorded 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.
- Client staleness is Radicale's job: it checks
-
Locking. Radicale's
acquire_lock("w")serializes writers process-wide; an additional per-file lock guards the read-modify-write cycle. -
Optional backups. When
[write].backup_diris 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
- Parse the submitted
.ics. - Resolve
UID→ sidecar row → file and line. Unknown UID plus[write].allow_create→ create in the collection's inbox; unknown UID without it → 403. - 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.
- Diff submitted properties against the task's current state; compute the minimal set of edits.
- 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 withdeleted_gen > old_gen. - Tombstones are pruned after
[index].tombstone_retentiongenerations (default 1000). A token older than the retained window causessync()to raiseValueError, 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
typewould make it serve every path, and collections held bymultifilesystemwould 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
.orgfiles (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.
- ✅
model.py,config.py - ✅
parser.py+ fixtures + tests 1, 2, 9 — the foundation - ✅
ical.py+ mapping tests - ✅
index.py+ identity tests 4–7 — the subtlest logic in the project - ✅
writer.py+ tests 3, 8 - ✅
collections.py,watcher.py - ✅
storage.py+ tests 10, 11 - ✅
cli.py, README
Remaining: systemd unit, and the manual client checklist in §12.1 against real clients.