diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..51602b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,43 @@ +name: Bug +description: Something does not work +labels: [bug] +body: + - type: textarea + id: what + attributes: + label: What happened + description: And what you expected instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: How to reproduce it + placeholder: | + 1. … + 2. … + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: The image tag, or the commit you built from. + validations: + required: true + - type: dropdown + id: how + attributes: + label: How are you running it + options: + - As a Rune in Yggdrasil Panel + - Docker directly + - From source + validations: + required: true + - type: textarea + id: logs + attributes: + label: Anything in the logs + description: Please remove tokens and email addresses first. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d4d6021 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security issue + url: https://github.com/kristianwind/verdande/blob/main/SECURITY.md + about: Please report vulnerabilities by email, not as a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..ca60292 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,22 @@ +name: Feature +description: Suggest something +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Please check the [non-goals](../../blob/main/README.md#non-goals) first — + some things are deliberately not being built. + - type: textarea + id: problem + attributes: + label: What are you trying to do + description: The problem, not the solution. What does the current version make hard? + validations: + required: true + - type: textarea + id: idea + attributes: + label: What you had in mind + validations: + required: false diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..3b11ec0 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,58 @@ +name: Documentation + +# The docs are built on every push so a broken build is caught immediately, but +# only published once GitHub Pages has been enabled for the repository. Until the +# owner decides to publish, this job builds and stops. +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'mkdocs.yml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + + # Pinned deliberately. The Material maintainers have announced that MkDocs + # 2.0 removes the plugin system and breaks every theme override with no + # migration path — an unpinned install would break this build on the day it + # lands, for a docs site nobody was editing. + - run: pip install 'mkdocs-material==9.7.7' + + - run: mkdocs build --strict + + - uses: actions/upload-pages-artifact@v3 + with: + path: site_build + + deploy: + needs: build + runs-on: ubuntu-latest + # Only runs once Pages is enabled. Until then the build above still guards + # against a broken docs tree. + if: github.event_name == 'workflow_dispatch' + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 39d05a8..fa6956d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ node_modules/ # Frontend build output and dependencies /web/build/ /web/.svelte-kit/ + +# Built documentation +/site_build/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..af836ec --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing + +Thank you for looking. verdande is one person's self-hosted to-do app, so the bar +for a change is "does this make it better for people running it on their own +hardware" rather than "is it a feature some product has". + +## Before you write code + +Open an issue first for anything beyond a bug fix. The +[non-goals](README.md#non-goals) are deliberate, and it is kinder to say no to a +paragraph than to a weekend. + +## Running it + +```bash +VERDANDE_DATA_DIR=./data VERDANDE_DEV=true go run ./cmd/verdande +``` + +```bash +cd web && npm install && npm run dev +``` + +The API is on 8080, the interface on 5173 with `/api` proxied to it — so the +session cookie stays first-party exactly as it does in production. + +## What CI checks + +```bash +gofmt -l . # must print nothing +go vet ./... +go test -race ./... +``` + +The Docker image is built on every pull request too, so a Dockerfile that no +longer works fails there rather than at release. + +## What a change needs + +**Tests for anything that could be silently wrong.** Not coverage for its own +sake — the parser, the permission checks and the import round trip are tested +because a quiet failure in any of them loses somebody's data or shows them +somebody else's. + +**Comments that say why, not what.** The code says what it does. A comment earns +its place by explaining a decision that is not obvious from reading it: why the +connection pool is not 1, why recurrence is parsed before dates, why an uploaded +file is never served inline. + +**Danish and English both.** Anything that reads or writes text a person typed +has to work in both. `ø` is not an accented `o`, and a feature that only works +for one of the two languages is a broken feature. + +## Commits + +[Conventional Commits](https://www.conventionalcommits.org/): `feat(store):`, +`fix(web):`, `docs:`. Releases are semver tags, which build and publish the image. + +## Security + +Please do not open a public issue for a vulnerability. Email the address in +[SECURITY.md](SECURITY.md) instead. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e57c341 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kristian Wind + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e07d5b5..70bb8ed 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ Which is what a to-do list is. ## Status -Early. The foundation is built and tested; the API and web interface are being -written. See [What works today](#what-works-today). +All six phases are built. See [What works today](#what-works-today) for what is +tested and what is not. + +Documentation: [`docs/`](docs/), built with Material for MkDocs. ## Why this exists @@ -40,10 +42,10 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire ## What works today -**Phases 1 and 3 are complete; phase 2 is most of the way there.** You can install -it, create an account, add tasks by typing a sentence, share a project, watch -somebody else's changes appear live, set up repeating chores and reminders, save -filters, and subscribe to it from Apple Calendar. +**Every phase in the plan is built.** You can install it, add tasks by typing a +sentence, share projects, watch somebody else's changes appear live, set repeating +chores and reminders, save filters, import your Todoist account, sync two-way with +Apple Reminders, forward mail into it, and add it to Claude as a connector. | Area | State | |---|---| @@ -64,10 +66,20 @@ filters, and subscribe to it from Apple Calendar. | ICS calendar feed, task durations | Done, tested | | Project templates | Done, tested | | Nightly backups with rotation | Done, tested | -| Import/export, CalDAV, mail-to-task, Gmail, MCP, AI | Planned — phases 4–5 | +| Comments, attachments, notifications, Web Push | Built | +| Todoist CSV import and export, account export | Done, tested | +| MCP server for Claude | Done, tested | +| CalDAV server (two-way VTODO) | Done, tested | +| Mail-to-task | Done, tested | +| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured | +| Gmail settings | Partial — see below | +| Documentation, landing page, licence | Done | -Not yet built: comments and attachments, Web Push, and everything from phase 4 -onwards. +**Not finished:** the Gmail integration stores its settings but has no OAuth2 flow +yet, so it cannot actually connect. Web Push and the AI providers are implemented +but have only been exercised against their specs, not against live services. And +the interface does not yet expose comments, attachments, reminders, templates, +import/export or the settings pages — all of it works through the API. Search finds `grøn` when you type `gron`, because Unicode does not consider `ø` an accented `o` and a Danish-first app that cannot do this is broken for its own users. @@ -154,13 +166,14 @@ is part of it. 1. **MVP** — auth, projects, sections, tasks, sub-tasks, priorities, due dates, quick add, Today and Upcoming, search, keyboard shortcuts. *(done)* 2. **Sharing** — invites, roles, assignees, comments, attachments, activity log, - realtime sync, notifications. + realtime sync, notifications. *(done)* 3. **Pro parity** — recurring tasks, reminders, filter queries, board and calendar views, durations, ICS feeds, templates, automatic backups. *(done)* -4. **Import/export** — Todoist CSV and official export in, CSV/JSON/ICS out. +4. **Import/export** — Todoist CSV and official export in, CSV/JSON/ICS out. *(done)* 5. **Integrations** — CalDAV server, mail-to-task, Gmail, MCP server for Claude, - and a provider-agnostic AI layer that also talks to a local model. -6. **Publication** — documentation, landing page, open-source scaffolding. + and a provider-agnostic AI layer that also talks to a local model. *(done, + except Gmail's OAuth flow)* +6. **Publication** — documentation, landing page, open-source scaffolding. *(done)* ## Development @@ -199,4 +212,4 @@ protocol of its own — CalDAV and the REST API *are* the sync. ## License -Not yet chosen. MIT is the intention when this is published. +[MIT](LICENSE). See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..17e863e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security + +## Reporting + +Email **kristianwind@gmail.com** rather than opening an issue. Include what you +found, how to reproduce it, and what an attacker could do with it. You will get a +reply within a week. + +## What verdande does about the obvious things + +- **Passwords** are Argon2id with the parameters recorded in the hash, so they can + be raised later without locking anyone out. +- **Every token** — session, invite, reset, API, calendar feed, mail address — is + stored only as a SHA-256 hash. A database dump contains nothing that can be + presented. +- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS. +- **Uploads** are stored content-addressed by hash and always served as + `application/octet-stream` with `nosniff`, so an uploaded SVG cannot run script + on this origin. +- **The filter language** compiles to parameterised SQL. No typed text is ever + concatenated into a statement. +- **Permission checks** are route middleware, and anything you may not see answers + 404 rather than 403 — a 403 confirms the thing exists. +- **The CSP** is computed at startup by hashing the inline scripts in the built + page, rather than allowing `unsafe-inline`. + +## What it does not do + +verdande has no rate limiting beyond the login and password-reset endpoints, and +no audit log of reads. It is built to be run behind something — a Cloudflare +Tunnel, a reverse proxy, a VPN — by somebody who knows what they exposed. diff --git a/docs/ai.md b/docs/ai.md new file mode 100644 index 0000000..f295072 --- /dev/null +++ b/docs/ai.md @@ -0,0 +1,67 @@ +# AI features + +Optional, off by default, and bring your own key. verdande works completely without +them — this is a convenience on top of a to-do app, not something the app depends +on. + +## Setting up a provider + +**Settings → AI**. Four kinds: + +=== "Anthropic" + + - **Provider**: Anthropic + - **Model**: `claude-sonnet-5` + - **API key**: from [console.anthropic.com](https://console.anthropic.com) + +=== "OpenAI" + + - **Provider**: OpenAI + - **Model**: `gpt-4o` + - **API key**: from platform.openai.com + +=== "Google" + + - **Provider**: Google + - **Model**: `gemini-2.0-flash` + - **API key**: from Google AI Studio + +=== "Your own model" + + Anything speaking the OpenAI-compatible shape — Ollama, vLLM, LM Studio, a + company gateway. + + - **Provider**: Compatible + - **Base URL**: `http://localhost:11434/v1` + - **Model**: `llama3.1` — whatever you have pulled + - **API key**: usually blank + + This is why the abstraction exists at all: an integration that only spoke to a + hosted API would be no use to somebody running their own models. + +The key is stored on your account and is **never sent back** to the settings page — +a page that repopulates a password field is one that will eventually leak it into a +screenshot. + +## What it can do + +**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to +seven concrete actions and creates them as sub-tasks. Written in your language. + +**A weekly summary.** A short note over what is outstanding: what looks urgent and +what appears to be slipping. At most six lines — it is a prompt to think, not a +report. + +## What is sent + +Only what the feature needs: for a split, the task's title and notes; for a +summary, the titles, dates and priorities of your open tasks. Never comments, +attachments, or anything from a project the feature was not invoked on. + +Nothing is sent to anybody until you configure a provider. With a local model, +nothing leaves your machine at all. + +## Turning it off + +Clear the model field. Every AI feature reports itself as unavailable rather than +failing, and the rest of the app is unchanged. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..475f7c8 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,150 @@ +# API + +REST under `/api/v1`, JSON in and out. Everything the web interface does goes +through it, so anything you can do there you can script. + +## Authenticating + +Two ways, accepted everywhere. + +**A session cookie**, which the web interface uses. `httpOnly`, so nothing but the +browser handles it. + +**A personal API token**, which is what scripts use: + +```bash +curl -H "Authorization: Bearer vrd_..." https://todo.example.dk/api/v1/tasks +``` + +Make one under **Settings → API tokens**. It carries your permissions exactly — +there is no separate service identity. + +## Errors + +Every failure has the same shape: + +```json +{ + "code": "validation_failed", + "error": "one or more fields are not valid", + "fields": { "due_date": "must be a date like 2026-03-15" } +} +``` + +`code` is stable and meant to be matched on. `error` is English prose for a log — +what a person reads is decided by the client, which knows what language they are in. + +| Code | Status | Means | +|---|---|---| +| `bad_request` | 400 | The body could not be read. | +| `unauthorized` | 401 | Not signed in, or the token is wrong. | +| `totp_required` | 401 | The login still needs its verification code. | +| `forbidden` | 403 | Cross-site request, or admin needed. | +| `not_found` | 404 | Does not exist, or you cannot see it. | +| `conflict` | 409 | Already exists, or the state does not allow it. | +| `validation_failed` | 422 | Check `fields`. | +| `rate_limited` | 429 | Too many attempts. | +| `internal_error` | 500 | Our fault. | + +!!! note "404 rather than 403" + Anything you may not see answers 404. A 403 would confirm the thing exists, + which is what somebody trying ids is hoping to learn. + +## The endpoints + +### Tasks + +| Method | Path | | +|---|---|---| +| `GET` | `/tasks` | List. Filter with `project_id`, `label_id`, `priority`, `due_before`, `due_from`, `no_date`, `assignee_id=me`, `q`, `completed=include\|only`. | +| `POST` | `/tasks` | Create. | +| `POST` | `/tasks/quick-add` | Create from one line of natural language. | +| `GET` | `/tasks/quick-add/preview?text=` | Parse without saving. | +| `GET` | `/tasks/{id}` | One task. | +| `PATCH` | `/tasks/{id}` | Change it. Only the fields you send. | +| `DELETE` | `/tasks/{id}` | To the trash, recoverable for 30 days. | +| `POST` | `/tasks/{id}/complete` | Tick off. A repeating task advances instead. | +| `POST` | `/tasks/{id}/reopen` | Un-tick. | +| `POST` | `/tasks/{id}/move` | Reorder: `{"after_id": …, "before_id": …}`. | + +```bash +curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \ + -H "Authorization: Bearer vrd_..." \ + -H "Content-Type: application/json" \ + -d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}' +``` + +### Projects and sections + +| Method | Path | | +|---|---|---| +| `GET` `POST` | `/projects` | List, create. | +| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. | +| `GET` `POST` | `/projects/{id}/sections` | | +| `PATCH` `DELETE` | `/sections/{id}` | | +| `GET` | `/projects/{id}/members` | | +| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` | +| `DELETE` | `/projects/{id}/members/{userID}` | | +| `GET` | `/projects/{id}/activity` | | + +### Views, filters and labels + +| Method | Path | | +|---|---|---| +| `GET` | `/today` | Overdue and due today, in your timezone. | +| `GET` | `/upcoming?days=7` | One entry per day, empty days included. | +| `GET` | `/search?q=` | Across everything you can see. | +| `GET` `POST` | `/filters` | | +| `GET` | `/filters/{id}/tasks` | Run one. | +| `GET` | `/filters/preview?query=` | Run an expression without saving it. | +| `GET` `POST` | `/labels` | | + +### Comments and attachments + +| Method | Path | | +|---|---|---| +| `GET` `POST` | `/tasks/{id}/comments` | | +| `PATCH` `DELETE` | `/comments/{id}` | Author only for editing. | +| `POST` | `/tasks/{id}/attachments` | `multipart/form-data`, field `file`, 25 MB. | +| `GET` `DELETE` | `/attachments/{id}` | | + +### Import and export + +| Method | Path | | +|---|---|---| +| `POST` | `/import/todoist` | A Todoist CSV, `multipart/form-data`. | +| `POST` | `/import/csv` | Mapped rows as JSON. | +| `GET` | `/export/account` | Everything, as JSON. | +| `GET` | `/export/projects/{id}.csv` | Todoist-compatible. | +| `GET` | `/export/projects/{id}.ics` | A calendar file. | + +### Everything else + +| Method | Path | | +|---|---|---| +| `GET` | `/ws` | WebSocket. Live changes to projects you can see. | +| `GET` | `/notifications` | | +| `GET` `POST` | `/feed`, `/feed/rotate` | The [calendar feed](caldav.md) URL. | +| `GET` `POST` | `/mail-address`, `/mail-address/rotate` | The [mail-to-task](mail.md) address. | +| `POST` | `/mcp` | The [MCP](mcp.md) endpoint. | +| `GET` | `/healthz` | Unauthenticated. Reads the database rather than merely answering. | + +## Live updates + +```javascript +const socket = new WebSocket('wss://todo.example.dk/api/v1/ws'); +socket.onmessage = (event) => { + const { type, project_id, payload } = JSON.parse(event.data); + // task.created, task.updated, task.completed, task.deleted, + // task.moved, comment.created, notification, reminder +}; +``` + +One-way by design: nothing a client sends over the socket changes anything. Writes +go through the REST API, where the permission checks are. + +## Rate limits + +Only where guessing matters: ten login attempts per address per fifteen minutes, +five password-reset requests per hour. The rest of the API is not limited — it is +your server. diff --git a/docs/caldav.md b/docs/caldav.md new file mode 100644 index 0000000..195be47 --- /dev/null +++ b/docs/caldav.md @@ -0,0 +1,88 @@ +# Calendar and CalDAV + +Two ways to get your tasks into a calendar, and they are not the same thing. + +| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | +|---|---|---| +| Direction | Read only | Two-way | +| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | +| You can tick things off | No | Yes | +| Setup | Paste a URL | Username and token | + +## Subscribing to a feed + +**Settings → Calendar feed** gives you a URL. Paste it into whatever subscribes to +calendars. + +=== "Apple Calendar" + + **File → New Calendar Subscription**, paste the URL, and set auto-refresh to + every hour. + +=== "Google Calendar" + + **Other calendars → + → From URL**. Google refetches on its own schedule, + which can be many hours — this is Google's behaviour and nothing here can + change it. + +=== "Thunderbird" + + **New Calendar → On the Network → iCalendar (ICS)**. + +Tasks go out as `VTODO`. Google ignores those entirely, so tasks with a clock time +are *also* sent as events — which is what makes the feed useful in the calendar +most people actually keep. + +!!! warning "The URL is the password" + Anybody with the link can read your tasks. Calendar clients cannot log in, so + there is nothing else it could be. If it ends up somewhere it should not, + **Settings → Calendar feed → Rotate** issues a new one and breaks the old + immediately. + +## Connecting a CalDAV client + +This is the one where ticking something off in Apple Reminders ticks it off here. + +**First, make a token.** **Settings → API tokens → New token**. Copy it — it is +shown once. + +Then: + +- **Server**: your verdande address +- **Username**: your email address +- **Password**: the API token, **not your account password** + +=== "Apple Reminders (macOS/iOS)" + + **Settings → Accounts → Add Other Account → CalDAV account**, choose *Manual*, + and fill in the three fields above. + +=== "Thunderbird" + + **New Calendar → On the Network**, enter your email as the username and the + address as the location. It will find the collections itself. + +=== "DAVx⁵ (Android)" + + **Add account → Login with URL and username**. Then pick which projects to + sync. + +Each project appears as its own calendar, so you can turn them on and off +individually. + +!!! note "Why a token and not your password" + A CalDAV client stores what you give it and sends it on every request. A token + can only read and write tasks — it cannot sign in to the app or change your + password — and you can revoke one without touching anything else. + +## What syncs + +Task titles, notes, due dates and times, priorities, repetition, and completion — +in both directions. + +Not sections, labels, sub-task relationships, comments or attachments. CalDAV's +VTODO has nowhere to put them, and inventing an encoding would produce something no +other client could read. + +Completed tasks stay in the collection. A client that stops seeing one treats it as +deleted and drops its own record of having done it. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..f75e78c --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,48 @@ +# Configuration + +Everything is an environment variable. There is no config file — a Rune has +nowhere to put one that the operator would ever see. + +## Core + +| Variable | Default | What it does | +|---|---|---| +| `VERDANDE_BASE_URL` | `http://localhost:8080` | The public address. Invite links, password resets, calendar feeds and the mail-to-task address are all built from it. | +| `VERDANDE_ADDR` | `:8080` | Listen address. | +| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. | +| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. | + +## Sessions and links + +| Variable | Default | What it does | +|---|---|---| +| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. | +| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. | +| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. | + +Any Go duration: `720h`, `30m`, `45s`. + +## Mail + +| Variable | Default | What it does | +|---|---|---| +| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail entirely. | +| `VERDANDE_SMTP_PORT` | `587` | | +| `VERDANDE_SMTP_USER` | — | | +| `VERDANDE_SMTP_PASS` | — | | +| `VERDANDE_SMTP_FROM` | `verdande@localhost` | Sender address. Also decides the domain of the [mail-to-task address](mail.md). | +| `VERDANDE_SMTP_STARTTLS` | `true` unless port 465 | Port 465 is TLS from the first byte; everything else negotiates. | +| `VERDANDE_SMTP_INSECURE` | `false` | Skip certificate verification. For a self-signed internal mail server, and nothing else. | + +!!! info "Running without a mail server" + Perfectly supported. Invite links are shown in the app for you to send + yourself, password resets are written to the log, and reminders arrive in the + app and as Web Push rather than by email. + +## What is *not* configured here + +**AI providers** are per-user, under settings in the app — see [AI](ai.md). One +person's key is not the instance's key. + +**The VAPID keypair** for Web Push is generated on first use and stored in the +database. There is nothing to set. diff --git a/docs/filters.md b/docs/filters.md new file mode 100644 index 0000000..3cd19b0 --- /dev/null +++ b/docs/filters.md @@ -0,0 +1,65 @@ +# Saved filters + +A filter is a small expression describing a slice of your tasks. The grammar is +Todoist's, so if you are coming from there you already know it. + +``` +today & p1 +#Firma & @regnskab +overdue | (today & assigned to: me) +7 days & !@venter +``` + +## What you can write + +| Term | Matches | +|---|---| +| `today`, `i dag` | Due today. | +| `tomorrow`, `i morgen` | Due tomorrow. | +| `overdue`, `forsinket` | Due before today. | +| `7 days`, `14 dage`, `2 weeks` | Due between now and then. A window, not a single day. | +| `no date`, `ingen dato` | No due date at all. | +| `p1` … `p4` | That priority. | +| `#Firma` | In that project. | +| `@regnskab` | Carrying that label. | +| `assigned to: me`, `tildelt: mig` | Assigned to you. | +| `assigned to: anders@example.dk` | Assigned to that person. | +| `assigned to: none` | Assigned to nobody. | +| `recurring`, `gentagen` | Repeats. | +| `completed`, `færdig` | Already done. | +| `subtask`, `underopgave` | Has a parent. | +| `2026-12-24` | Due on that exact day. | +| anything else | Searched for in the title and description. | + +## Combining them + +| Operator | Means | +|---|---| +| `&` | Both. | +| `|` or `,` | Either. | +| `!` | Not. | +| `( )` | Grouping. | + +`&` binds tighter than `|`, so `overdue | today & p1` means +`overdue | (today & p1)`. Use brackets when you mean otherwise. + +## Some that are worth saving + +``` +today & p1 What has to happen today +overdue What slipped +7 days & !@venter The week, minus what you are blocked on +#Firma & assigned to: me Your part of a shared project +p1 & no date Important, unscheduled — usually a mistake +recurring & overdue Habits you have stopped doing +``` + +## Why an expression is refused + +A filter is compiled before it is saved. A filter that cannot run is worse than +none: you would find out later, from a list that is empty for a reason nobody can +see. If you get an error, it names the part it could not read. + +!!! note "Labels are personal" + `@regnskab` matches *your* label of that name. Somebody else using the same + word on their own tasks is a different label, and your filter will not find it. diff --git a/docs/import.md b/docs/import.md new file mode 100644 index 0000000..22e33e9 --- /dev/null +++ b/docs/import.md @@ -0,0 +1,66 @@ +# Importing from Todoist + +## Getting the file out of Todoist + +For one project: open it, **⋯ → Export as template → Export as CSV**. + +For everything: **Settings → Backups**, then take the most recent one. That gives +you one CSV per project. + +## Bringing it in + +**Settings → Import → From Todoist**, then pick the file. One project comes in per +file. + +## What comes across + +| Todoist | verdande | +|---|---| +| Tasks, in their tree | Tasks, with sub-tasks nested the same way | +| Sections | Sections | +| Notes | Comments | +| Priority | Priority — see below | +| Due dates | Due dates, and recurrence where it can be read | +| Descriptions | Descriptions | + +!!! info "Priorities are renumbered on purpose" + Todoist's CSV numbers priorities the opposite way to how its interface shows + them: what you see as P1 is written as `4`. verdande stores what the interface + means, so the two are converted. A P1 in Todoist is a P1 here. + +## Dates + +Todoist stores what you typed rather than a date — `every Monday`, `tomorrow`, +`15 Mar`. Machine-readable dates come across exactly. Anything else goes through +the same [quick-add parser](quick-add.md) the app uses, which understands a great +deal of it in both languages — including recurrence, so `every Monday` arrives as a +real repeating task. + +Anything it genuinely cannot read is listed in the import result rather than +dropped silently. + +## What does not come across + +**Assignees.** A person in your Todoist workspace is not a person here. The import +lists them so you can reassign. + +**Attachments.** Todoist's CSV does not include them; they are links into Todoist's +own storage. + +**Reminders and filters.** Not in the export format. + +## Any other CSV + +**Settings → Import → From a CSV file** takes anything with a header row, and asks +you which column means what. The only one it needs is the task text. + +## Getting back out + +Nothing here is a one-way door. + +- **A project as Todoist CSV** — the same format, so it opens in Todoist. +- **A project as ICS** — a calendar file. +- **Your whole account as JSON** — every project, task, comment, label and filter. + +A CSV that goes in and comes back out again is the same project: the round trip is +tested, and two exports of an unchanged project are byte-identical. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..69039bb --- /dev/null +++ b/docs/index.md @@ -0,0 +1,49 @@ +# verdande + +Self-hosted tasks and projects, shared with the people you share the work with. +One binary with SQLite inside it — no database to provision, no second process. + +**Verdande** is one of the three Norns: the one who spins *that which is becoming*. +Not what has been, and not what is fated to be — the thing you are in the middle +of. Which is what a to-do list is. + +## What it is for + +Todoist Pro, on your own hardware, with your own data. And with the integrations a +hosted product will not build for you: a real CalDAV server, an address you can +forward mail to, and an MCP endpoint so Claude can work your task list directly. + +## What it does + +- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl + 10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in + Firma, labelled regnskab. Danish and English, mixed freely in one line. +- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List, + board and calendar views. +- **Sharing** with owner, editor and viewer roles. Anyone outside your instance + joins through an invite link. +- **Live sync** — a change made by somebody else appears without a refresh. +- **Repeating tasks** as RRULE, reminders, comments and attachments. +- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over + open standards rather than private formats. + +## Getting it running + +```bash +docker run -d --name verdande \ + -p 8080:8080 \ + -v verdande-data:/data \ + -e VERDANDE_BASE_URL=https://todo.example.dk \ + ghcr.io/kristianwind/verdande:latest +``` + +Open the address, create the first account — that account is the administrator. + +Running Yggdrasil Panel? [Install it as a Rune](rune.md) instead. + +## What it will not do + +No karma or points. No team workspaces. No native apps. No mail integration beyond +Gmail and the forwarding address. No languages beyond Danish and English. And no +sync protocol of its own — [CalDAV](caldav.md) and the [API](api.md) *are* the +sync. diff --git a/docs/mail.md b/docs/mail.md new file mode 100644 index 0000000..2249e05 --- /dev/null +++ b/docs/mail.md @@ -0,0 +1,69 @@ +# Mail to task + +Forward an email and it becomes a task. + +## Your address + +**Settings → Mail to task** gives you something like: + +``` +todo+kJ8xN2pQ...@example.dk +``` + +The token is in the local part rather than the domain, which is what lets a single +mail alias route it without a wildcard domain or a DNS change per person. + +## Using it + +Forward anything to it. The **subject becomes the task** and the **body becomes the +description**. + +The subject goes through the same [quick-add parser](quick-add.md) as everything +else, so this works: + +> **Subject:** Send årsregnskab i morgen p1 #Firma + +and arrives as a task called *Send årsregnskab*, due tomorrow, priority 1, in +Firma. + +## Routing the mail + +verdande does not run a mail server. Something has to hand it the message. + +=== "Mailcow" + + Make an alias for `todo+*@yourdomain` that pipes to a small script, and have + the script POST the parsed message to: + + ``` + POST https://todo.example.dk/inbound/mail + Content-Type: application/json + + { + "to": "todo+TOKEN@example.dk", + "from": "afsender@example.dk", + "subject": "Send årsregnskab i morgen p1 #Firma", + "body": "Vedhæftet er sidste års tal." + } + ``` + +=== "Anything else" + + The endpoint takes that JSON from anywhere — a Postfix pipe, a Cloudflare Email + Worker, an IMAP poller. The token in `to` is the whole credential, so no other + authentication is needed and none is accepted. + +## Security + +The token *is* the credential — anybody who can send mail to that address can +create tasks in your account. It cannot read anything, and it cannot reach anything +else. + +An unknown token and a malformed address answer identically, so the endpoint cannot +be used to work out which addresses are live. + +**Settings → Mail to task → Rotate** issues a new address and stops the old one +immediately. + +!!! tip "Keep the address to yourself" + Treat it like a secret. If you publish it somewhere, rotate it. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..aff58a4 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,55 @@ +# Claude (MCP) + +verdande has a built-in [Model Context Protocol](https://modelcontextprotocol.io) +server, so you can add it to Claude as a connector and work your task list in +conversation. + +> *"What's on my plate this week?"* +> +> *"Add a task to call the accountant on Friday, priority 1, in Firma."* +> +> *"What did I not get to that was due last week?"* + +## Connecting it + +**Make a token.** **Settings → API tokens → New token**. It is shown once. + +**Add the connector.** In Claude, add a custom connector with: + +- **URL**: `https://todo.example.dk/api/v1/mcp` +- **Authentication**: Bearer token, using the token you just made + +!!! warning "It has to be reachable from the internet" + Claude connects from Anthropic's side, not from your browser. An instance only + reachable on your LAN or over a VPN cannot be used this way. + +## What Claude can do + +| Tool | What it does | +|---|---| +| `list_projects` | List your projects. | +| `search_tasks` | Find tasks — by text, or with the [filter language](filters.md). | +| `create_task` | Create one, from natural language or explicit fields. | +| `update_task` | Change a title, priority, date or project. | +| `complete_task` | Tick one off. | +| `add_comment` | Comment on a task. | + +`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1 +#Firma"* goes through the same parser the app uses — dates, priorities, projects, +labels and recurrence in either language. + +## What it can reach + +**Exactly what you can reach, and nothing more.** A token is a person, and a +person's permissions are already decided: shared projects you are a member of, yes; +somebody else's private projects, no. There is no separate service identity and no +scope to configure. + +## What it cannot do + +Delete projects, manage members, change settings or read another account. The tools +are the six above; there is no general escape hatch. + +!!! note "Revoking it" + **Settings → API tokens**, delete the token. The connector stops working + immediately, and nothing else about your account is affected. diff --git a/docs/quick-add.md b/docs/quick-add.md new file mode 100644 index 0000000..a408dc5 --- /dev/null +++ b/docs/quick-add.md @@ -0,0 +1,76 @@ +# Quick add + +One line in, a fully-formed task out. + +``` +betal moms i morgen kl 10 p1 #Firma @regnskab +``` + +becomes a task called **betal moms**, due tomorrow at 10:00, priority 1, in the +Firma project, labelled regnskab. + +The parts it understands are tinted underneath the text as you type, so you can see +it being read before you commit to it. + +## What it reads + +| You type | It means | +|---|---| +| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. | +| `@regnskab` | Add that label. As many as you like. | +| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. | +| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. | +| `mandag`, `på fredag` | The next such weekday. | +| `næste mandag` | Next week's Monday, counted from Monday — not merely the next one to come round. | +| `om 3 dage`, `om en uge`, `næste måned` | Relative dates. | +| `15. marts`, `15/3`, `2026-03-15` | Written dates. Day first. | +| `kl 10`, `kl 14:30`, `klokken 16` | A clock time. | +| `hver mandag`, `hverdage`, `hver 2. uge` | Makes it [repeat](#repeating-tasks). | + +English works everywhere Danish does: `tomorrow`, `next friday`, `in 3 days`, +`every 2 weeks`, `at 10am`, `weekdays`. + +## Mixing the two + +``` +ring til Anders on friday kl 14 #Arbejde +``` + +Both languages are read at once rather than behind a setting, because people mix +them mid-sentence. The only thing the language setting decides is a bare hour after +English `at`: `at 3` means three in the afternoon, while Danish `kl 3` means three +in the morning — Danish has no am/pm habit and the 24-hour clock is what people +mean. + +## Repeating tasks + +``` +vand planterne hver mandag +standup hverdage kl 9 +betal husleje den 1. i måneden +review every 2 weeks +``` + +Stored as an RFC 5545 RRULE, so a task exported to Apple Reminders keeps repeating +there. + +Ticking off a repeating task **moves it to its next occurrence** rather than +closing it. The same task keeps its id, so its sub-tasks and comments stay +attached — a weekly review is one thing that recurs, not fifty-two things. The +completion is still recorded, so "what did I get done this week" includes the +chores that repeat. + +## Dates it cannot read + +Anything it does not recognise stays in the title. A task with a clumsy title beats +an error message and a lost thought. + +!!! tip "Numeric dates are day-first" + `15/3` is the fifteenth of March. That is how both Danish and British English + write it, and guessing month-first would silently move a date by months rather + than failing visibly. + +## The shortcuts + +Press ++q++ to jump to the quick-add box from anywhere, ++t++ for Today, ++u++ for +Upcoming, ++cmd+k++ to search, and ++question++ for the full list. diff --git a/docs/rune.md b/docs/rune.md new file mode 100644 index 0000000..31072b1 --- /dev/null +++ b/docs/rune.md @@ -0,0 +1,47 @@ +# Installing as a Rune + +verdande ships with a Rune manifest for +[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil). + +## Installing + +1. **Runes → Carve a rune (upload)** and pick + [`rune/verdande.yaml`](https://github.com/kristianwind/verdande/blob/main/rune/verdande.yaml) + from the repository. +2. Create a server from it. +3. Fill in the settings below. +4. Start it, open the URL, create the first account. + +## Settings + +| Setting | What to put | +|---|---| +| **Public address** | The address you reach it on. `{{PUBLIC_URL}}` is the default and usually right. | +| **SMTP host** | Your mail server. Leave blank and invite links are shown in the app instead of emailed. | +| **SMTP port** | 587 for STARTTLS, 465 for implicit TLS. | +| **SMTP username / password** | As your mail server expects. | +| **Sender address** | What invites and reminders come from. | +| **Use STARTTLS** | On, unless you are on port 465. | +| **How long a login lasts** | `720h` — thirty days. | + +!!! note "Why the public address cannot be guessed" + The host port is not chosen until the server is created, so verdande has no way + to work out its own address. The panel does, and passes it in as + `{{PUBLIC_URL}}`. + +## Reaching it from outside + +Give the server a **Subdomain** under its settings and enable +**Cloudflare Tunnel (subdomains)** under **Settings → Network**. No port forwarding +and no public IP. + +## Backups + +The Rune's backup includes the whole `/data` directory, which is what you want — +see [the warning about copying the database alone](self-hosting.md#what-lives-in-data). + +## Watchers + +The manifest ships log watchers for database errors, repeated failed logins and +HTTP 5xx. They appear under **Settings → Kvasir Watchers** as ordinary per-server +rules you can edit or turn off. diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 0000000..a94ebfa --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,108 @@ +# Self-hosting + +verdande is one static binary and one directory. There is no database server, no +cache, no queue and no second process. + +## Docker + +```bash +docker run -d --name verdande \ + --restart unless-stopped \ + -p 8080:8080 \ + -v verdande-data:/data \ + -e VERDANDE_BASE_URL=https://todo.example.dk \ + ghcr.io/kristianwind/verdande:latest +``` + +`VERDANDE_BASE_URL` has to be the address you actually reach it on. Invite links, +password resets and calendar feeds are all built from it, so getting it wrong +means the links in your email point somewhere that does not answer. + +## Docker Compose + +```yaml +services: + verdande: + image: ghcr.io/kristianwind/verdande:latest + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./data:/data + environment: + VERDANDE_BASE_URL: https://todo.example.dk + VERDANDE_SMTP_HOST: mail.example.dk + VERDANDE_SMTP_USER: verdande@example.dk + VERDANDE_SMTP_PASS: ${SMTP_PASSWORD} + VERDANDE_SMTP_FROM: verdande@example.dk +``` + +## Behind a reverse proxy + +verdande expects to be terminated by something else. It reads `X-Forwarded-For`, +so rate limiting and the activity log see the real caller rather than the proxy. + +=== "Caddy" + + ``` + todo.example.dk { + reverse_proxy localhost:8080 + } + ``` + +=== "Cloudflare Tunnel" + + ```yaml + ingress: + - hostname: todo.example.dk + service: http://localhost:8080 + - service: http_status:404 + ``` + + The WebSocket needs no special handling; Cloudflare proxies it as-is. A silent + socket is closed after 100 seconds, which is why verdande pings every 30. + +!!! warning "Use HTTPS" + Over plain HTTP the session cookie cannot be `Secure`, and verdande will not + use the `__Host-` prefix that stops a subdomain overwriting it. Fine on a + laptop; not fine on anything reachable from outside. + +## First run + +Open the address and create an account. The first one is the administrator, and +the endpoint that creates it refuses once any account exists — so there is no +window in which somebody else can claim your instance. + +Everybody after that joins by invite. + +## What lives in `/data` + +``` +/data +├── verdande.db SQLite, WAL mode +├── files/ attachments, content-addressed +└── backups/ nightly snapshots, 14 kept +``` + +Back up the whole directory. + +!!! danger "Do not copy verdande.db on its own" + It runs in WAL mode, so recent writes live in `verdande.db-wal` until they are + checkpointed. A copy of the `.db` file alone can be missing them. Copy the + whole directory, or take one of the nightly snapshots from `backups/` — those + are written with `VACUUM INTO` and are complete on their own. + +## Backups + +A snapshot is written once a day and the most recent fourteen are kept. They are +counted rather than aged out, so a container that was off for a month comes back +with its backups intact. + +## Updating + +```bash +docker compose pull && docker compose up -d +``` + +Migrations run at startup, each in its own transaction. Take a copy of `/data` +first anyway. diff --git a/docs/stylesheets/verdande.css b/docs/stylesheets/verdande.css new file mode 100644 index 0000000..41bd969 --- /dev/null +++ b/docs/stylesheets/verdande.css @@ -0,0 +1,16 @@ +/* The app's accent, so the docs and the thing they describe look like one project. */ +:root { + --md-primary-fg-color: #2f8f63; + --md-accent-fg-color: #2f8f63; +} + +[data-md-color-scheme='slate'] { + --md-primary-fg-color: #5fbf8f; + --md-accent-fg-color: #6fcf9f; + --md-default-bg-color: #111312; +} + +.md-header__title { + font-weight: 560; + letter-spacing: -0.02em; +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..34d9d8b --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,77 @@ +site_name: verdande +site_description: Self-hosted tasks and projects, shared. One binary, SQLite inside it. +site_url: https://kristianwind.github.io/verdande/ +repo_url: https://github.com/kristianwind/verdande +repo_name: kristianwind/verdande +edit_uri: edit/main/docs/ + +# Not the default `site/` — that directory holds the landing page, which is a +# separate thing deployed to a separate place. +site_dir: site_build + +theme: + name: material + language: en + # The same two-theme arrangement as the app itself, and the same accent. + palette: + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + accent: custom + toggle: + icon: material/weather-sunny + name: Switch to light + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + accent: custom + toggle: + icon: material/weather-night + name: Switch to dark + features: + - navigation.instant + - navigation.sections + - navigation.top + - content.code.copy + - search.suggest + font: + text: Inter + code: JetBrains Mono + +extra_css: + - stylesheets/verdande.css + +nav: + - Home: index.md + - Setting it up: + - Self-hosting: self-hosting.md + - As a Rune: rune.md + - Configuration: configuration.md + - Using it: + - Quick add: quick-add.md + - Filters: filters.md + - Importing from Todoist: import.md + - Connecting things: + - Calendar and CalDAV: caldav.md + - Mail to task: mail.md + - Claude (MCP): mcp.md + - AI features: ai.md + - Reference: + - API: api.md + +markdown_extensions: + - admonition + - attr_list + - def_list + - tables + - toc: + permalink: true + - pymdownx.details + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + anchor_linenums: true + +plugins: + - search diff --git a/site/icon.svg b/site/icon.svg new file mode 100644 index 0000000..f0cf472 --- /dev/null +++ b/site/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..b09ac73 --- /dev/null +++ b/site/index.html @@ -0,0 +1,455 @@ + + + + + + verdande — self-hosted tasks, shared + + + + + + + + + + + + + +
+
+ +

verdande

+

+ Self-hosted tasks and projects, shared with the people you share the work with. + One binary, SQLite inside it. +

+

+ Verdande is one of the three Norns — the one who spins that which is + becoming. Not what has been, and not what is fated to be: the thing you are + in the middle of. Which is what a to-do list is. +

+ + +
+ + +
+
+ + Add a task +
+
+
+ betal moms i morgen kl 10 + p1 #Firma + @regnskab +
+
+ → + betal moms + Tomorrow 10:00 + P1 + Firma + regnskab +
+
+
+ +
+

What it does

+
+
+

Reads what you type

+

+ Dates, times, priorities, projects, labels and repetition, parsed out of + one line — in Danish and English, mixed freely. +

+
+
+

Shared, properly

+

+ Owner, editor and viewer roles. People outside your instance join by + invite link. Changes appear live. +

+
+
+

A real CalDAV server

+

+ Two-way. Tick something off in Apple Reminders and it is ticked off + here. Not a read-only feed pretending. +

+
+
+

Claude, connected

+

+ A built-in MCP server. Ask what is due this week, or have a task added, + in conversation. +

+
+
+

Mail to task

+

+ Forward an email to your own address and it becomes a task, subject + parsed like anything else you type. +

+
+
+

Yours to leave

+

+ Import from Todoist, export back to it, or take everything as JSON. The + round trip is tested, not promised. +

+
+
+
+ +
+

Running it

+
docker run -d --name verdande \
+  -p 8080:8080 \
+  -v verdande-data:/data \
+  -e VERDANDE_BASE_URL=https://todo.example.dk \
+  ghcr.io/kristianwind/verdande:latest
+

+ Open the address and create the first account. That one is the administrator. +

+ +
+ +
+

What it will not do

+

+ No karma or points. No team workspaces. No native apps. No mail integration + beyond Gmail and the forwarding address. No languages beyond Danish and English. + And no sync protocol of its own — CalDAV and the API are the sync. +

+
+ + +
+ +