Initial commit
This commit is contained in:
214
README.md
Normal file
214
README.md
Normal 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.
|
||||
Reference in New Issue
Block a user