Two deployment bugs that both present as "the client says done but the markdown never changes, and nothing errors". 1. upload() diffed the client's VTODO against the cached in-memory task. When that cache disagreed with disk the diff came out empty, apply() returned early, and Radicale answered 2xx having written nothing. The cache goes stale on NFS: refresh_if_stale() compares stat(), and a `- [ ]` to `- [x]` toggle does not change the file's size, so mtime is the only signal — which NFS attribute caching hides for the length of the attr timeout. Add Index.refresh_file(), which compares the actual bytes, and call it before diffing. 2. docker/mdcaldav.toml set `exclude`, which REPLACES the built-in list rather than extending it, silently dropping the Syncthing patterns. Against a real Syncthing vault this reindexes `*.sync-conflict-*` (and `.stversions/`, which holds whole historical copies of every note): the task list gains duplicates, and completing one can write to a conflict file the user never opens while the note they are watching stays put. Drop the override so the defaults apply. Also switch the container to a polling observer. inotify reports nothing for a network mount written by another host, so `watch = true` was a silent no-op exactly where it was most wanted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
markdown-to-caldav
Point it at a directory of markdown notes — a zk or Obsidian vault —
and every - [ ] checkbox becomes a task in your CalDAV client. Check a task off on your phone and
the markdown file is rewritten to - [x]. Edit in vim and the client picks it up.
The notes stay the source of truth. Nothing is injected into them — no IDs, no metadata blocks, no reformatting. Untouched lines are preserved byte for byte.
~/notes/daily/2026-01-07.md Tasks.org on your phone
┌────────────────────────┐
- [ ] [#A] Plan the seed order ───▶ │ ! Plan the seed order │
- [ ] Draft the bed layout │ ☐ Draft the bed… │
- Measure the north plot │ Measure the nor… │
- [x] Refill the bird feeder │ ☑ Refill the bird fee… │
└────────────────────────┘
Supported: nested subtasks (org-mode style), [#A]/[#B]/[#C] priorities, descriptions from
non-checkbox sub-bullets, and completion round-tripping back to the file.
See SPEC.md for the full design.
Quickstart
uv venv && uv pip install -e ".[dev]"
# See what the parser makes of your vault — read-only, no server, no writes.
mdcaldav --vault ~/notes scan
# Serve it. Use a COPY of your vault until you trust the write-back.
mdcaldav --vault /path/to/vault-copy serve --host localhost:5232 --no-auth
Then point a client at http://localhost:5232/. Each top-level directory becomes a task list.
--no-auth is localhost-only. For anything else, create an htpasswd file and drop --no-auth.
Commands
| Command | Purpose |
|---|---|
mdcaldav scan |
print the task tree the server would expose |
mdcaldav doctor |
index health, ambiguous titles, orphaned subtasks |
mdcaldav serve |
run the CalDAV server |
Docker
cp .env.example .env # set VAULT_PATH, VAULT_UID, VAULT_GID
# One user, bcrypt-hashed:
docker run --rm markdown-to-caldav:latest python -c \
"import bcrypt;print('you:'+bcrypt.hashpw(b'yourpassword',bcrypt.gensalt()).decode())" \
> docker/users
docker compose up -d
Serves on 127.0.0.1:5233. Point a client at http://127.0.0.1:5233/.
Three things the compose file gets right, and which matter:
user:is set fromVAULT_UID/VAULT_GID. Write-back happens as that uid, so notes stay owned by you rather than by root. Get this wrong and the container either can't write or leaves root-owned files in your vault.- The index lives in
./data, not in the vault. It's a derived cache, not a note. - The port binds to
127.0.0.1. This service rewrites your notes; don't expose it to a LAN without TLS and a real password.
backup_dir is enabled by default in docker/mdcaldav.toml, snapshotting each file to
./data/backups before its first modification. Turn it off once you trust it.
Read-only inspection without starting the server:
docker compose run --rm markdown-to-caldav mdcaldav --config /config/mdcaldav.toml scan
Deploying alongside an existing Radicale
You cannot add this to your existing Radicale instance. [storage] type is a single global
option and Radicale holds exactly one storage backend per process — setting it to mdcaldav.storage
would make that backend serve every path, and your existing calendars and contacts (stored by
multifilesystem) would stop resolving. There is no per-collection storage routing.
Run a second instance instead. Two ways to present it:
Separate ports — simplest. Existing Radicale on 5232, this on 5233. Add both accounts in your client.
One hostname, two paths — on the reverse-proxy machine, route /notes/ here and tell
this instance its prefix:
location /notes/ {
proxy_pass http://notes-host.lan:5233; # NO trailing slash — see below
proxy_set_header X-Script-Name /notes;
proxy_set_header Host $host;
}
location / { proxy_pass http://existing-radicale.lan:5232; }
The missing trailing slash is load-bearing. Radicale strips the prefix itself and re-adds it when generating hrefs. With
proxy_pass .../;nginx strips it first, Radicale then emits hrefs without/notes, and clients walk to collection URLs that don't exist. Same reason the Caddy example useshandlerather thanhandle_path.
Verified end-to-end: through a sub-path proxy, collections come back as
/notes/tyler/daily/ and completing a task writes through to the file.
Full configs for both web servers: reverse-proxy/.
Production deployment
cp .env.example .env # VAULT_PATH, VAULT_UID, VAULT_GID, BIND_ADDRESS
docker run --rm markdown-to-caldav:latest python -c \
"import bcrypt;print('you:'+bcrypt.hashpw(b'yourpassword',bcrypt.gensalt()).decode())" \
> docker/users
docker compose -f docker-compose.prod.yml up -d
There is no proxy container in this stack by design. TLS is terminated by the central
reverse proxy on a separate machine, which reaches this host over the LAN on
BIND_PORT (default 5233). Reference configs for that machine — nginx and Caddy, plain
hostname and sub-path variants — are in reverse-proxy/.
Because the published port is plain HTTP:
- Set
BIND_ADDRESSto this host's LAN address rather than leaving it on all interfaces. - Firewall the port to the proxy's IP. Anything else on the LAN can otherwise reach it.
- Keep htpasswd auth on. The port is not private just because it isn't public.
The app runs with a read-only root filesystem, all capabilities dropped, and
no-new-privileges; only /vault, /data and a 64 MB /tmp are writable. That
configuration is tested, not aspirational.
To run Syncthing inside the same stack, add --profile syncthing. Omit it if Syncthing
already runs on the host and simply shares VAULT_PATH.
Serving a Syncthing folder
This works, and it's a good setup — but a Syncthing folder is not just your notes, and two of its features will corrupt your task list if ignored.
What gets excluded, and why it matters
| Path | What it is | If indexed |
|---|---|---|
.stversions/ |
old copies of your notes | every task duplicated, once per retained version |
*.sync-conflict-*.md |
whole duplicate notes from conflicting edits | entire files duplicated in your client |
.syncthing.*, ~syncthing~* |
partial in-flight writes | torn, half-parsed tasks |
All are excluded by default. .stversions/ is the one that bites hardest: with file
versioning enabled, it holds complete historical copies of every note, so indexing it
multiplies your task list by your retention depth.
Operational notes
-
Match the uid.
VAULT_UID/VAULT_GIDmust match whoever owns the Syncthing folder. Mismatched ownership means either write-back fails or Syncthing fights over permissions. -
Expect occasional conflicts. If the server completes a task at the same moment a remote edit arrives, Syncthing keeps one version and renames the other to
*.sync-conflict-*. Those are excluded from indexing, so tasks won't duplicate, but the edit is parked in that file until you merge it.mdcaldav doctorlists any present. -
Reduce the conflict window by keeping
backup_diron and letting this host be the only automated writer. Writes are single-line and atomic (os.replace), so Syncthing always observes a complete file, never a partial one. -
Verify before trusting it:
docker compose -f docker-compose.prod.yml run --rm markdown-to-caldav \ mdcaldav --config /config/mdcaldav.toml doctorCheck the task count is what you expect. If it's a large multiple of reality, something under
.stversions/is being indexed.
How it works
Radicale provides the CalDAV protocol layer; this project is a Radicale storage plugin that presents
markdown as collections of VTODO items. A SQLite sidecar index (outside the vault) keeps task UIDs
stable across edits so clients don't lose their state.
The plugin is loaded by name, so it works under stock Radicale — the Docker image runs
radicale --config, not a custom server. Because Radicale's config schema rejects unknown keys, the
vault config is passed out of band via MDCALDAV_CONFIG.
Notes and caveats
- Back up first. This software rewrites your notes. Point it at a copy until you trust it.
- Nesting in Thunderbird: subtasks use
RELATED-TO;RELTYPE=PARENT. jtx Board and Tasks.org render the hierarchy; Thunderbird ignores it and shows a flat list. The tasks are all still there and still completable. - Carry-forward duplicates are distinct tasks. Copying an unfinished task into tomorrow's daily note gives you two independent tasks, not one in two places. Completing one does not tick the other. See SPEC §6.3 — this is deliberate.
- Creating a task from a client appends it to
<collection>/inbox.mdunder an## Inboxheading, so it stays in the list you created it in. - License: GPLv3, because Radicale is loaded in-process as a library.
Development
.venv/bin/python -m pytest # 86 tests
Test fixtures in tests/fixtures/vault/ are synthetic. Never commit real notes into this
repository — the suite must be safe to share and must not depend on private content.