215 lines
9.3 KiB
Markdown
215 lines
9.3 KiB
Markdown
# 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 `<collection>/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.
|