docs(phase6): documentation site, landing page and open-source scaffolding
Material for MkDocs under docs/, covering self-hosting, the Rune, the quick-add and filter languages, importing from Todoist, CalDAV, mail-to-task, MCP, the AI layer and the API. The version is pinned: the maintainers have announced that MkDocs 2.0 removes the plugin system with no migration path, and an unpinned install would break this build on the day it lands. The landing page shows the quick-add parser as actual text rather than a screenshot of it — that is the thing worth showing, and it reads better as the real thing. It shares the app's design tokens, so the site and what it describes look like one project. MIT licence, contributing guide, security policy and issue templates. Pages deployment is behind workflow_dispatch, so the docs build on every push but nothing is published until the owner chooses to. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b19ed062d
commit
7c9bde2cfd
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -30,3 +30,6 @@ node_modules/
|
||||
# Frontend build output and dependencies
|
||||
/web/build/
|
||||
/web/.svelte-kit/
|
||||
|
||||
# Built documentation
|
||||
/site_build/
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Security
|
||||
|
||||
## Reporting
|
||||
|
||||
Email **[email protected]** 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.
|
||||
+67
@@ -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.
|
||||
+150
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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: [email protected]` | 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+55
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
+77
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- Wunjo, the rune Verdande's name begins with. Drawn as strokes rather than
|
||||
as a filled glyph so it stays legible at 16px in a browser tab. -->
|
||||
<rect width="64" height="64" rx="14" fill="#111312"/>
|
||||
<g fill="none" stroke="#5fbf8f" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 14v36"/>
|
||||
<path d="M22 17l17 8-17 8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
+455
@@ -0,0 +1,455 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>verdande — self-hosted tasks, shared</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. CalDAV, mail-to-task and an MCP connector for Claude."
|
||||
/>
|
||||
<meta name="theme-color" content="#111312" />
|
||||
|
||||
<meta property="og:title" content="verdande" />
|
||||
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||
|
||||
<style>
|
||||
/*
|
||||
* The same tokens as the app, so the site and the thing it describes
|
||||
* look like one project rather than two.
|
||||
*/
|
||||
:root {
|
||||
--ground: #111312;
|
||||
--surface: #171a19;
|
||||
--surface-raised: #1d211f;
|
||||
--line: #262b29;
|
||||
--line-strong: #343a37;
|
||||
--ink: #e8ebe9;
|
||||
--ink-muted: #9aa39e;
|
||||
--ink-faint: #6b736f;
|
||||
--accent: #5fbf8f;
|
||||
--accent-ink: #0d1411;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme='dark']) {
|
||||
--ground: #faf9f7;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--line: #e5e3de;
|
||||
--line-strong: #d3d0c9;
|
||||
--ink: #1c201e;
|
||||
--ink-muted: #5f6763;
|
||||
--ink-faint: #8b918d;
|
||||
--accent: #2f8f63;
|
||||
--accent-ink: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: var(--font);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 940px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* --- hero --- */
|
||||
|
||||
header {
|
||||
padding: 6rem 0 4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mark {
|
||||
font-size: 3rem;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.5rem, 7vw, 3.75rem);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 1.25rem auto 0;
|
||||
max-width: 30rem;
|
||||
font-size: 1.125rem;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.norn {
|
||||
margin: 2rem auto 0;
|
||||
max-width: 34rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 550;
|
||||
border: 1px solid var(--line-strong);
|
||||
color: var(--ink);
|
||||
transition: border-color 150ms ease, background 150ms ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* --- the one screenshot substitute: the thing it actually does --- */
|
||||
|
||||
.demo {
|
||||
margin: 4rem 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.demo-body {
|
||||
padding: 1.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
line-height: 2;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.typed {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The tints match the app's own parse highlighting. */
|
||||
mark {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--ink);
|
||||
border-radius: 3px;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
|
||||
mark.p {
|
||||
background: color-mix(in srgb, #cf6b6b 22%, transparent);
|
||||
}
|
||||
|
||||
mark.proj {
|
||||
background: color-mix(in srgb, #6b9ecf 22%, transparent);
|
||||
}
|
||||
|
||||
mark.lbl {
|
||||
background: color-mix(in srgb, #d69f5c 22%, transparent);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--ink-faint);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
font-family: var(--font);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
padding: 1px 0.625rem;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* --- features --- */
|
||||
|
||||
section {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cell {
|
||||
background: var(--ground);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.cell h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.cell p {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* --- install --- */
|
||||
|
||||
pre {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.7;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
ul.plain {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1rem 0 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
ul.plain li {
|
||||
padding-left: 1.25rem;
|
||||
position: relative;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
ul.plain li::before {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 0.375rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: 4rem;
|
||||
padding: 2rem 0 4rem;
|
||||
text-align: center;
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<span class="mark" aria-hidden="true">ᚹ</span>
|
||||
<h1>verdande</h1>
|
||||
<p class="lede">
|
||||
Self-hosted tasks and projects, shared with the people you share the work with.
|
||||
One binary, SQLite inside it.
|
||||
</p>
|
||||
<p class="norn">
|
||||
Verdande is one of the three Norns — the one who spins <em>that which is
|
||||
becoming</em>. 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.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||
Self-host it
|
||||
</a>
|
||||
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
||||
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
|
||||
shows better as the actual text than as a picture of it. -->
|
||||
<div class="demo">
|
||||
<div class="demo-bar">
|
||||
<span aria-hidden="true">+</span>
|
||||
<span>Add a task</span>
|
||||
</div>
|
||||
<div class="demo-body">
|
||||
<div class="typed">
|
||||
betal moms <mark>i morgen</mark> <mark>kl 10</mark>
|
||||
<mark class="p">p1</mark> <mark class="proj">#Firma</mark>
|
||||
<mark class="lbl">@regnskab</mark>
|
||||
</div>
|
||||
<div class="result">
|
||||
<span class="arrow">→</span>
|
||||
<strong>betal moms</strong>
|
||||
<span class="chip">Tomorrow 10:00</span>
|
||||
<span class="chip">P1</span>
|
||||
<span class="chip">Firma</span>
|
||||
<span class="chip">regnskab</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>What it does</h2>
|
||||
<div class="grid">
|
||||
<div class="cell">
|
||||
<h3>Reads what you type</h3>
|
||||
<p>
|
||||
Dates, times, priorities, projects, labels and repetition, parsed out of
|
||||
one line — in Danish and English, mixed freely.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Shared, properly</h3>
|
||||
<p>
|
||||
Owner, editor and viewer roles. People outside your instance join by
|
||||
invite link. Changes appear live.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>A real CalDAV server</h3>
|
||||
<p>
|
||||
Two-way. Tick something off in Apple Reminders and it is ticked off
|
||||
here. Not a read-only feed pretending.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Claude, connected</h3>
|
||||
<p>
|
||||
A built-in MCP server. Ask what is due this week, or have a task added,
|
||||
in conversation.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Mail to task</h3>
|
||||
<p>
|
||||
Forward an email to your own address and it becomes a task, subject
|
||||
parsed like anything else you type.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Yours to leave</h3>
|
||||
<p>
|
||||
Import from Todoist, export back to it, or take everything as JSON. The
|
||||
round trip is tested, not promised.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Running it</h2>
|
||||
<pre><code>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</code></pre>
|
||||
<p class="muted" style="margin-top: 1rem">
|
||||
Open the address and create the first account. That one is the administrator.
|
||||
</p>
|
||||
<ul class="plain">
|
||||
<li>No database to provision — SQLite is inside the binary.</li>
|
||||
<li>Distroless image: no shell, no package manager, one static file.</li>
|
||||
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
|
||||
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What it will not do</h2>
|
||||
<p class="muted">
|
||||
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 <em>are</em> the sync.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
MIT licensed ·
|
||||
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
|
||||
</p>
|
||||
<p>Deployed as a Rune in <a href="https://github.com/kristianwind/yggdrasil">Yggdrasil</a>.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user