# markdown-to-caldav Point it at a directory of markdown notes — a [zk](https://github.com/zk-org/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](./SPEC.md)** for the full design. ## Quickstart ```sh 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 ```sh 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 from `VAULT_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: ```sh 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: ```nginx 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 uses > `handle` rather than `handle_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/`](./reverse-proxy). ## Production deployment ```sh 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/`](./reverse-proxy). Because the published port is plain HTTP: - Set `BIND_ADDRESS` to 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_GID` must 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 doctor` lists any present. - **Reduce the conflict window** by keeping `backup_dir` on 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:** ```sh docker compose -f docker-compose.prod.yml run --rm markdown-to-caldav \ mdcaldav --config /config/mdcaldav.toml doctor ``` Check 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 `/inbox.md` under an `## Inbox` heading, so it stays in the list you created it in. - **License:** GPLv3, because Radicale is loaded in-process as a library. ## Development ```sh .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.