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

716 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](https://github.com/zk-org/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` | `#tag``CATEGORIES`; optional due syntax (§2.5) |
Example exercising every element:
```markdown
- [ ] [#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:**
```markdown
- [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:**
```markdown
- 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:**
```markdown
### 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
```python
@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:
```python
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`):
```sql
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_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.
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.
```ini
[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:
```python
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`:
```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.