Initial commit
Some checks failed
CI / test (push) Failing after 11s
CI / image (push) Has been skipped

This commit is contained in:
2026-08-01 21:03:35 -04:00
commit 8b5cd370ea
41 changed files with 5462 additions and 0 deletions

214
README.md Normal file
View File

@@ -0,0 +1,214 @@
# 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.