Initial commit
This commit is contained in:
715
SPEC.md
Normal file
715
SPEC.md
Normal file
@@ -0,0 +1,715 @@
|
||||
# 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 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:
|
||||
|
||||
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 4–7 — 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.
|
||||
Reference in New Issue
Block a user