Compare commits
@@ -6,7 +6,9 @@ on:
|
||||
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# write rather than read: the job creates the GitHub Release the update notice
|
||||
# reads. packages: write is for pushing the image.
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
@@ -50,3 +52,15 @@ jobs:
|
||||
VERSION=${{ github.ref_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# A tag is not a release. The in-app update notice reads GitHub's
|
||||
# releases/latest, which answers 404 for a repository that only has tags —
|
||||
# so without this step the check can never find anything, however correctly
|
||||
# it is switched on.
|
||||
#
|
||||
# Created after the image, so a release never announces a version that
|
||||
# failed to build.
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
make_latest: true
|
||||
|
||||
@@ -40,3 +40,8 @@ node_modules/
|
||||
|
||||
# Built documentation
|
||||
/site_build/
|
||||
|
||||
# Vite writes these while bundling vite.config.js and removes them again — unless
|
||||
# the process is killed, which during a hung build is exactly what happens. One
|
||||
# was committed by accident.
|
||||
/web/vite.config.js.timestamp-*.mjs
|
||||
|
||||
@@ -126,14 +126,6 @@ and the manifest ships in this repository. It runs anywhere Docker does as well.
|
||||
|
||||
### With Docker directly
|
||||
|
||||
The package is private, and an anonymous pull fails with `No such image` rather
|
||||
than with a permission error. Log the host's Docker daemon in once, with a GitHub
|
||||
token carrying the `read:packages` scope:
|
||||
|
||||
```bash
|
||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
||||
```
|
||||
|
||||
```bash
|
||||
docker run -d --name verdande \
|
||||
-p 8080:8080 \
|
||||
|
||||
@@ -77,7 +77,11 @@ func run() error {
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
log.Info("listening", "addr", cfg.Addr)
|
||||
// The version is repeated here rather than only on the "starting" line
|
||||
// above: this is the line the Rune's done_regex matches, so it is the one
|
||||
// sitting at the bottom of the panel console after a deploy — which is
|
||||
// exactly when somebody wants to know what actually started.
|
||||
log.Info("listening", "addr", cfg.Addr, "version", version)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ what a person reads is decided by the client, which knows what language they are
|
||||
| `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. |
|
||||
| `gmail_not_configured` | 409 | No Google OAuth client is registered on this server. |
|
||||
| `ai_not_configured` | 409 | No AI provider has been set up. |
|
||||
| `totp_not_enabled` | 409 | Two-factor is off, and the request needs it on. |
|
||||
| `inbox_protected` | 409 | The Inbox cannot be deleted. |
|
||||
| `validation_failed` | 422 | Check `fields`. |
|
||||
| `rate_limited` | 429 | Too many attempts. |
|
||||
| `internal_error` | 500 | Our fault. |
|
||||
@@ -96,6 +100,8 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
||||
| `GET` | `/projects/{id}/activity` | |
|
||||
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
||||
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
||||
|
||||
### Views, filters and labels
|
||||
|
||||
|
||||
+27
-4
@@ -12,17 +12,40 @@ conversation.
|
||||
|
||||
## Connecting it
|
||||
|
||||
**Make a token.** **Settings → API tokens → New token**. It is shown once.
|
||||
**Make a token** under **Settings → API tokens**. The page then shows the
|
||||
connector address to go with it — token and all — which is the thing to copy:
|
||||
|
||||
**Add the connector.** In Claude, add a custom connector with:
|
||||
```
|
||||
https://todo.example.dk/mcp?key=vrd_…
|
||||
```
|
||||
|
||||
- **URL**: `https://todo.example.dk/api/v1/mcp`
|
||||
- **Authentication**: Bearer token, using the token you just made
|
||||
**Add the connector** in Claude with that address, and nothing else. The dialog
|
||||
asks for a name and a URL; leave the OAuth fields empty.
|
||||
|
||||
!!! note "Why the key is in the address"
|
||||
The dialog has no field for a bearer token — only OAuth client credentials —
|
||||
so a header cannot be configured from it. The address *is* the credential
|
||||
here, exactly as it is for the [calendar feed](caldav.md#subscribing-to-a-feed).
|
||||
Treat it like a password: anyone holding it reaches what you reach. The
|
||||
server logs request paths without their query strings, so it does not end up
|
||||
in the log.
|
||||
|
||||
!!! 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.
|
||||
|
||||
### With a header instead
|
||||
|
||||
Anything that *can* send a header should, and use the API path:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer vrd_…" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
|
||||
https://todo.example.dk/api/v1/mcp
|
||||
```
|
||||
|
||||
Both endpoints run the same server and reach the same things.
|
||||
|
||||
## What Claude can do
|
||||
|
||||
| Tool | What it does |
|
||||
|
||||
+156
-3
@@ -533,7 +533,11 @@ paths:
|
||||
type: string
|
||||
description: Where it goes when the text names no project.
|
||||
responses:
|
||||
"201": { $ref: "#/components/responses/Task" }
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/QuickAddResult" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/tasks/quick-add/preview:
|
||||
@@ -756,6 +760,35 @@ paths:
|
||||
"201": { $ref: "#/components/responses/Project" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/projects/reorder:
|
||||
post:
|
||||
tags: [projects]
|
||||
summary: Set the order projects appear in.
|
||||
description: |
|
||||
The whole list at once rather than one move at a time. A person has a
|
||||
handful of projects, so sending the order you want is simpler than
|
||||
midpoints and cannot land half-applied.
|
||||
|
||||
Projects you do not own are skipped: `sort_order` is a column on the
|
||||
project rather than a preference per viewer, so a shared one stays where
|
||||
its owner put it.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items: { type: string }
|
||||
description: Project ids, top to bottom.
|
||||
responses:
|
||||
"204": { description: Reordered. }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/projects/{projectID}:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
@@ -785,6 +818,42 @@ paths:
|
||||
"204": { description: Deleted. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/trash/projects:
|
||||
get:
|
||||
tags: [projects]
|
||||
summary: Projects you have deleted and can still bring back.
|
||||
description: |
|
||||
Only your own. Restoring has been possible since the trash existed, but
|
||||
the endpoint takes an id — and the id is what you no longer have once the
|
||||
project has gone from the interface.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
projects:
|
||||
type: array
|
||||
items:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Project"
|
||||
- type: object
|
||||
properties:
|
||||
deleted_at: { type: string, format: date-time }
|
||||
purge_after:
|
||||
type: string
|
||||
format: date-time
|
||||
description: |
|
||||
When it stops being recoverable. Sent rather than
|
||||
left for the client to work out, because the
|
||||
retention window is the server's and a second
|
||||
copy of it will one day disagree.
|
||||
task_count:
|
||||
type: integer
|
||||
description: Tasks that went with it, and would come back with it.
|
||||
|
||||
/api/v1/trash/projects/{projectID}/restore:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
@@ -1718,7 +1787,15 @@ paths:
|
||||
broken volume reports unhealthy instead of pretending to work.
|
||||
security: []
|
||||
responses:
|
||||
"200": { description: Healthy. }
|
||||
"200":
|
||||
description: Healthy. Carries the running version — the one thing you can ask without a session.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string, const: ok }
|
||||
version: { type: string, example: v0.3.1 }
|
||||
"503": { description: Unhealthy. }
|
||||
|
||||
/ics/{token}:
|
||||
@@ -1787,6 +1864,53 @@ paths:
|
||||
responses:
|
||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
||||
|
||||
/mcp:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: The MCP endpoint, with the token in the query string.
|
||||
description: |
|
||||
The same server as `/api/v1/mcp`, for a client that can only be given a
|
||||
URL. Claude's custom-connector dialog asks for a name, an address and
|
||||
OAuth client credentials — there is no field for a bearer token — so the
|
||||
API path cannot be configured from it.
|
||||
|
||||
The key is the only credential accepted here. A session cookie is
|
||||
ignored: this route sits outside the CSRF check, and honouring a cookie
|
||||
would make it a cross-site request that acts as whoever is signed in.
|
||||
security: []
|
||||
parameters:
|
||||
- name: key
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string, example: vrd_… }
|
||||
description: A personal API token. Treat the whole address as a password.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [jsonrpc, method]
|
||||
properties:
|
||||
jsonrpc: { type: string, const: "2.0" }
|
||||
id: { oneOf: [{ type: string }, { type: integer }] }
|
||||
method: { type: string }
|
||||
params: { type: object }
|
||||
responses:
|
||||
"200":
|
||||
description: A JSON-RPC response.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
jsonrpc: { type: string, const: "2.0" }
|
||||
id: { oneOf: [{ type: string }, { type: integer }] }
|
||||
result: { type: object }
|
||||
error: { type: object }
|
||||
"202": { description: A notification. No body. }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/.well-known/caldav:
|
||||
get:
|
||||
tags: [integrations]
|
||||
@@ -1899,6 +2023,13 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
address: { type: string, example: todo+abc123@example.dk }
|
||||
configured:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether a mail server is set up at all. The address is real
|
||||
either way, but with no mail server nothing on the internet
|
||||
routes to it — and an interface that does not say so is
|
||||
promising something that will not happen.
|
||||
Gmail:
|
||||
description: OK
|
||||
content:
|
||||
@@ -1948,6 +2079,10 @@ components:
|
||||
- rate_limited
|
||||
- internal_error
|
||||
- payload_too_large
|
||||
- gmail_not_configured
|
||||
- ai_not_configured
|
||||
- totp_not_enabled
|
||||
- inbox_protected
|
||||
error: { type: string }
|
||||
fields:
|
||||
type: object
|
||||
@@ -2004,7 +2139,12 @@ components:
|
||||
TaskInput:
|
||||
type: object
|
||||
properties:
|
||||
project_id: { type: string, description: "Omitted on create, the task goes to the Inbox." }
|
||||
project_id:
|
||||
type: string
|
||||
description: |
|
||||
Omitted on create, the task goes to the Inbox. On update it moves the
|
||||
task — which needs editor rights on the destination.
|
||||
|
||||
section_id: { type: string }
|
||||
parent_id: { type: string }
|
||||
content: { type: string }
|
||||
@@ -2020,6 +2160,19 @@ components:
|
||||
items: { type: string }
|
||||
sort_order: { type: number }
|
||||
|
||||
QuickAddResult:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Task"
|
||||
- type: object
|
||||
properties:
|
||||
unknown_project:
|
||||
type: string
|
||||
description: |
|
||||
Present when the text named a `#project` that does not exist. The
|
||||
task is still created, in the Inbox — a thought is not thrown away
|
||||
over a typo — but the caller is told, because the alternative is
|
||||
that the name vanishes from the title and nothing says why.
|
||||
|
||||
ParsedTask:
|
||||
type: object
|
||||
description: What the quick-add parser made of a line.
|
||||
|
||||
+46
-16
@@ -3,33 +3,53 @@
|
||||
verdande ships with a Rune manifest for
|
||||
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
|
||||
|
||||
## Before you start: the registry is private
|
||||
|
||||
The image lives at `ghcr.io/kristianwind/verdande` and the package is **private**,
|
||||
so an anonymous pull fails with a message that reads as though the image does not
|
||||
exist at all:
|
||||
## If the image will not pull
|
||||
|
||||
```
|
||||
create container: Error response from daemon:
|
||||
No such image: ghcr.io/kristianwind/verdande:latest
|
||||
```
|
||||
|
||||
That is a permission error wearing the wrong hat. The Docker daemon **on the
|
||||
Yggdrasil host** has to be logged in once:
|
||||
This message is a liar. It does not mean the tag is missing from the registry —
|
||||
it means the image is not on the host, and the panel's attempt to fetch it failed
|
||||
without saying so.
|
||||
|
||||
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
|
||||
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
|
||||
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
||||
looks locally — then reports the image as missing.
|
||||
|
||||
!!! warning "`docker login` on the host does not help"
|
||||
`docker login` is client-side. The daemon stores no credentials; the CLI
|
||||
sends them itself with each request. Yggdrasil is a different client and
|
||||
sends none, so logging in changes nothing for the panel.
|
||||
|
||||
**Make the package public.** Under
|
||||
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
|
||||
→ *Change visibility*. The repository stays private; only the built image becomes
|
||||
readable, which is what the manifest and these pages have always assumed.
|
||||
|
||||
Once it is public, press **Start** on the server you already have. There is no
|
||||
need to delete it and make another: the image reference is read from the rune at
|
||||
container-create time, and both Start and Restart recreate the container.
|
||||
|
||||
To keep the package private you have to put the image on the host yourself,
|
||||
before creating the server, and again after every release:
|
||||
|
||||
```bash
|
||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
||||
sudo docker login ghcr.io -u kristianwind
|
||||
sudo docker pull ghcr.io/kristianwind/verdande:latest
|
||||
```
|
||||
|
||||
`GHCR_TOKEN` is a GitHub personal access token with the **`read:packages`** scope
|
||||
and nothing else. The login is stored in `~/.docker/config.json` for whichever
|
||||
user runs the daemon — if Yggdrasil runs as root or through systemd, log in as
|
||||
that user, not as yourself.
|
||||
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
||||
panel's own pull still fails silently; create simply finds the image already
|
||||
there.
|
||||
|
||||
!!! tip "Or make the package public"
|
||||
Under **Packages → verdande → Package settings → Change visibility**. The
|
||||
repository can stay private; only the built image becomes readable. Then no
|
||||
login is needed anywhere and this section stops applying.
|
||||
!!! danger "This hides updates"
|
||||
The panel re-pulls on every recreate, fails quietly, and carries on with the
|
||||
local image. A new release then *looks* like it rolled out and did not. For
|
||||
something you intend to update, public is the right answer and host-pull is
|
||||
the stopgap.
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -40,6 +60,16 @@ that user, not as yourself.
|
||||
3. Fill in the settings below.
|
||||
4. Start it, open the URL, create the first account.
|
||||
|
||||
!!! note "Updating the rune is a manual step"
|
||||
A rune does not update itself when this manifest changes. The panel writes
|
||||
the rune row from a UI action only — neither a restart nor a timer does it —
|
||||
so pick up a new manifest with **Runes → verdande → Update**, or install it
|
||||
again from Browse GitHub.
|
||||
|
||||
The catalogue list is cached in memory for ten minutes, so two attempts in a
|
||||
row can both hand you the old version. Recent panel versions show the list's
|
||||
age with a *fetch again* link.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | What to put |
|
||||
|
||||
@@ -5,15 +5,12 @@ cache, no queue and no second process.
|
||||
|
||||
## Docker
|
||||
|
||||
!!! warning "Log in to the registry first"
|
||||
The package is private, and an anonymous pull fails with `No such image`
|
||||
rather than with a permission error. Once per host:
|
||||
|
||||
```bash
|
||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
||||
```
|
||||
|
||||
`GHCR_TOKEN` is a GitHub token with the `read:packages` scope.
|
||||
!!! note "If the pull is refused"
|
||||
The GHCR package is public, so this needs no credentials. If it is ever set
|
||||
back to private, `docker login ghcr.io` with a token carrying the
|
||||
`read:packages` scope before pulling — and see
|
||||
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
||||
fails differently and much more confusingly under Yggdrasil.
|
||||
|
||||
```bash
|
||||
docker run -d --name verdande \
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The comment above the code list in errors.go says they are kept in one place
|
||||
// "so the frontend's mapping can be checked against it rather than discovered
|
||||
// from whatever a handler happened to write". Nothing checked it, and the
|
||||
// frontend was quietly missing entries — which is not a cosmetic gap: a code
|
||||
// with no message falls through to the server's English log prose, and a code
|
||||
// mapped to the wrong sentence tells the person something untrue. Connecting
|
||||
// Gmail without an OAuth client said "Det er der allerede."
|
||||
|
||||
var (
|
||||
codeConst = regexp.MustCompile(`(?m)^\s*Code\w+\s*=\s*"([a-z_]+)"`)
|
||||
messageKey = regexp.MustCompile(`(?m)^\s*([a-z_]+):\s*$|^\s*([a-z_]+):\s*'`)
|
||||
messagesTop = "const MESSAGES = {"
|
||||
)
|
||||
|
||||
func TestEveryErrorCodeHasAMessageInTheFrontend(t *testing.T) {
|
||||
codes := serverErrorCodes(t)
|
||||
messages := frontendMessageKeys(t)
|
||||
|
||||
var missing []string
|
||||
for _, code := range codes {
|
||||
if !messages[code] {
|
||||
missing = append(missing, code)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
|
||||
for _, code := range missing {
|
||||
t.Errorf("%q is returned by the API and has no message in web/src/lib/api.js — "+
|
||||
"a person would be shown the server's English log prose", code)
|
||||
}
|
||||
}
|
||||
|
||||
func serverErrorCodes(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
source, err := os.ReadFile("errors.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read errors.go: %v", err)
|
||||
}
|
||||
var codes []string
|
||||
for _, m := range codeConst.FindAllStringSubmatch(string(source), -1) {
|
||||
codes = append(codes, m[1])
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
t.Fatal("no codes were read out of errors.go — the scanner and the file disagree")
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// frontendMessageKeys reads the keys of the MESSAGES object. A small scanner
|
||||
// rather than a JS parser, for the same reason the OpenAPI check uses one: it
|
||||
// needs one thing out of a file this test also owns.
|
||||
func frontendMessageKeys(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
source, err := os.ReadFile(filepath.Join("..", "..", "web", "src", "lib", "api.js"))
|
||||
if err != nil {
|
||||
t.Fatalf("read api.js: %v", err)
|
||||
}
|
||||
body := string(source)
|
||||
|
||||
start := strings.Index(body, messagesTop)
|
||||
if start < 0 {
|
||||
t.Fatalf("could not find %q in api.js", messagesTop)
|
||||
}
|
||||
body = body[start+len(messagesTop):]
|
||||
end := strings.Index(body, "\n};")
|
||||
if end < 0 {
|
||||
t.Fatal("the MESSAGES object does not end where this scanner expects")
|
||||
}
|
||||
|
||||
keys := map[string]bool{}
|
||||
for _, line := range strings.Split(body[:end], "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
name, _, ok := strings.Cut(line, ":")
|
||||
if ok {
|
||||
keys[strings.TrimSpace(name)] = true
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("no message keys were read out of api.js")
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// The four that used to be indistinguishable from "that already exists".
|
||||
func TestTheMisleadingConflictsHaveTheirOwnCodes(t *testing.T) {
|
||||
for _, code := range []string{
|
||||
CodeGmailNotConfigured,
|
||||
CodeAINotConfigured,
|
||||
CodeTOTPNotEnabled,
|
||||
CodeInboxProtected,
|
||||
} {
|
||||
if code == CodeConflict {
|
||||
t.Errorf("%q is still plain conflict", code)
|
||||
}
|
||||
}
|
||||
|
||||
messages := frontendMessageKeys(t)
|
||||
for _, code := range []string{"gmail_not_configured", "ai_not_configured",
|
||||
"totp_not_enabled", "inbox_protected"} {
|
||||
if !messages[code] {
|
||||
t.Errorf("%q has no Danish message", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Inbox is the one project that cannot be deleted, and saying "that already
|
||||
// exists" about it helps nobody.
|
||||
func TestDeletingTheInboxSaysWhy(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
list, _ := projects["projects"].([]any)
|
||||
|
||||
var inboxID string
|
||||
for _, raw := range list {
|
||||
p := raw.(map[string]any)
|
||||
if p["is_inbox"] == true {
|
||||
inboxID = p["id"].(string)
|
||||
}
|
||||
}
|
||||
if inboxID == "" {
|
||||
t.Fatal("no inbox was found")
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "DELETE", "/api/v1/projects/"+inboxID, nil)
|
||||
if resp.StatusCode != 409 {
|
||||
t.Fatalf("status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != CodeInboxProtected {
|
||||
t.Errorf("code = %v, want %q", body["code"], CodeInboxProtected)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,17 @@ const (
|
||||
CodeRateLimited = "rate_limited"
|
||||
CodeInternal = "internal_error"
|
||||
CodePayloadTooLarge = "payload_too_large"
|
||||
|
||||
// Four situations that used to answer plain `conflict`, which the frontend
|
||||
// renders as "that already exists". For these that is not merely vague, it is
|
||||
// wrong: an unconfigured Gmail is not a Gmail that is already there, and a
|
||||
// second factor that is off is not one that is on. A code exists so the
|
||||
// interface can say the true thing — the server's own prose is English for a
|
||||
// log, and deciding what a person reads is the frontend's job.
|
||||
CodeGmailNotConfigured = "gmail_not_configured"
|
||||
CodeAINotConfigured = "ai_not_configured"
|
||||
CodeTOTPNotEnabled = "totp_not_enabled"
|
||||
CodeInboxProtected = "inbox_protected"
|
||||
)
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
|
||||
@@ -245,6 +245,7 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, "create label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||
writeJSON(w, http.StatusCreated, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||
}
|
||||
|
||||
@@ -263,6 +264,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "update label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -272,5 +274,6 @@ func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "delete label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.gmailConfig()
|
||||
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"Gmail er ikke sat op på denne server — administratoren skal registrere en OAuth-klient hos Google")
|
||||
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
|
||||
"no Google OAuth client is registered on this server")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ import (
|
||||
|
||||
type mailAddressResponse struct {
|
||||
Address string `json:"address"`
|
||||
// Configured says whether a mail server has been set up at all. Without one
|
||||
// the address is still minted and still valid — the token is real, and the
|
||||
// endpoint that receives mail works — but nothing on the internet routes to
|
||||
// it yet, and an interface that shows the address without saying so is
|
||||
// promising something that will not happen.
|
||||
Configured bool `json:"configured"`
|
||||
}
|
||||
|
||||
// handleGetMailAddress returns the personal address that turns an email into a
|
||||
@@ -34,7 +40,10 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, "mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
Address: s.mailAddress(token),
|
||||
Configured: s.cfg.SMTP.Host != "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -47,7 +56,10 @@ func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request)
|
||||
s.internal(w, "set mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
Address: s.mailAddress(token),
|
||||
Configured: s.cfg.SMTP.Host != "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) mailAddress(token string) string {
|
||||
@@ -55,7 +67,15 @@ func (s *Server) mailAddress(token string) string {
|
||||
if from := s.cfg.SMTP.From; strings.Contains(from, "@") {
|
||||
// The mail server's own domain, which is the one that can actually route
|
||||
// this — the web address may well be a tunnel hostname that receives no mail.
|
||||
domain = from[strings.Index(from, "@")+1:]
|
||||
//
|
||||
// Except when it is the default. VERDANDE_SMTP_FROM falls back to
|
||||
// verdande@localhost, so an instance with no mail server at all was
|
||||
// handing out todo+…@localhost: an address that looks real, cannot
|
||||
// possibly receive anything, and gives no hint which of the two it is.
|
||||
// The public hostname is at least a domain that exists.
|
||||
if d := from[strings.Index(from, "@")+1:]; d != "" && d != "localhost" {
|
||||
domain = d
|
||||
}
|
||||
}
|
||||
return "todo+" + token + "@" + domain
|
||||
}
|
||||
@@ -251,8 +271,8 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
// Not an error the user did anything to cause: the feature is simply off.
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"der er ikke sat en AI-udbyder op under indstillinger")
|
||||
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
||||
"no AI provider is configured")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,8 +319,8 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"der er ikke sat en AI-udbyder op under indstillinger")
|
||||
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
||||
"no AI provider is configured")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -771,3 +771,385 @@ func TestUpdateNoticeIsForAdministratorsOnly(t *testing.T) {
|
||||
t.Error("the current version is hidden from members")
|
||||
}
|
||||
}
|
||||
|
||||
// --- MCP with the key in the URL --------------------------------------------------
|
||||
|
||||
// Claude's custom-connector dialog takes a URL and nothing else — no bearer
|
||||
// token — so this is the only shape that can be configured from it.
|
||||
func TestMCPAcceptsAKeyInTheQuery(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var userID string
|
||||
err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := ts.apiToken(t, userID)
|
||||
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
|
||||
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||
t.Fatalf("the response was not JSON-RPC: %v", err)
|
||||
}
|
||||
result, _ := decoded["result"].(map[string]any)
|
||||
if result == nil || result["protocolVersion"] == nil {
|
||||
t.Errorf("no protocol version was announced: %v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
// The endpoint that broke it: /mcp used not to exist, so it fell through to the
|
||||
// SPA fallback and answered 200 with the app shell. A connector pointed at it
|
||||
// reported success and then failed to parse a page of HTML.
|
||||
func TestMCPWithoutAKeyIsNotTheAppShell(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, url := range []string{ts.URL + "/mcp", ts.URL + "/mcp?key=not-a-verdande-token"} {
|
||||
resp, err := http.Post(url, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("%s: status %d, want 401", url, resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("%s: content-type %q — a connector cannot read that", url, ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A revoked key stops working, like any other.
|
||||
func TestMCPKeyStopsWorkingWhenRevoked(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var userID string
|
||||
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := ts.apiToken(t, userID)
|
||||
|
||||
var id string
|
||||
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM api_tokens LIMIT 1`).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.DeleteAPIToken(t.Context(), userID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a revoked key still works: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The session cookie must NOT work here. This route sits outside the CSRF check,
|
||||
// so a cookie-authenticated POST would be a cross-site request that acts as
|
||||
// whoever is signed in — the exact hole the key-only rule exists to close.
|
||||
func TestMCPWithKeyIgnoresTheSessionCookie(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// ts.client holds the session from bootstrap. No key in the URL.
|
||||
resp, err := ts.client.Post(ts.URL+"/mcp", "application/json",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a signed-in cookie authenticated a POST outside the CSRF check: status %d",
|
||||
resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- mail to task ------------------------------------------------------------------
|
||||
|
||||
// The address used to come out as todo+…@localhost on any instance without a
|
||||
// mail server, because VERDANDE_SMTP_FROM defaults to verdande@localhost and the
|
||||
// domain was taken from it unconditionally. That address looks real, cannot
|
||||
// receive anything, and gives no hint which of the two it is.
|
||||
func TestMailAddressDoesNotClaimToBeLocalhost(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.BaseURL = "https://todo.example.dk"
|
||||
// Left at the default, as an instance with no mail server has it.
|
||||
c.SMTP.From = "verdande@localhost"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||
address, _ := body["address"].(string)
|
||||
|
||||
if strings.HasSuffix(address, "@localhost") {
|
||||
t.Errorf("address = %q — that can never receive mail", address)
|
||||
}
|
||||
if !strings.HasSuffix(address, "@todo.example.dk") {
|
||||
t.Errorf("address = %q, want the public hostname", address)
|
||||
}
|
||||
if body["configured"] != false {
|
||||
t.Error("no SMTP host is set, so configured should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// A real sender address still wins: the mail server's own domain is the one that
|
||||
// can actually route this, and the web address may be a tunnel hostname.
|
||||
func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.BaseURL = "https://tunnel-abc123.example.net"
|
||||
c.SMTP.Host = "mail.firma.dk"
|
||||
c.SMTP.From = "verdande@firma.dk"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||
address, _ := body["address"].(string)
|
||||
|
||||
if !strings.HasSuffix(address, "@firma.dk") {
|
||||
t.Errorf("address = %q, want the sender's domain", address)
|
||||
}
|
||||
if body["configured"] != true {
|
||||
t.Error("an SMTP host is set, so configured should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// A 200 with the app shell is an answer that says "yes, that exists" to anything
|
||||
// that asks. For /.well-known/ that is actively harmful: it is a reserved
|
||||
// namespace, and a client probing it is asking a yes-or-no question.
|
||||
//
|
||||
// Claude asks /.well-known/oauth-authorization-server before connecting to an
|
||||
// MCP server. It read the shell as "there is an authorization server here" and
|
||||
// then failed trying to register with it, reporting a broken sign-in service on
|
||||
// a server that has none and needs none.
|
||||
func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, path := range []string{
|
||||
"/.well-known/oauth-authorization-server",
|
||||
"/.well-known/oauth-protected-resource",
|
||||
"/.well-known/openid-configuration",
|
||||
"/.well-known/anything-at-all",
|
||||
} {
|
||||
resp, err := ts.client.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s: status %d, want 404", path, resp.StatusCode)
|
||||
}
|
||||
if strings.Contains(string(body), "<!doctype html") {
|
||||
t.Errorf("%s: answered with the app shell", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The one well-known path that is real still works — it is a route, not a
|
||||
// fallthrough, so the rule above must not have swallowed it.
|
||||
func TestWellKnownCalDAVStillRedirects(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
resp, err := client.Get(ts.URL + "/.well-known/caldav")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
t.Error("CalDAV discovery was caught by the well-known rule")
|
||||
}
|
||||
}
|
||||
|
||||
// --- what the API accepts, it must actually do -------------------------------------
|
||||
|
||||
// project_id on an update used to be validated and then dropped: the destination
|
||||
// was permission-checked, which reads as "understood", and the task never moved
|
||||
// while the answer said OK. Both the REST path and the MCP tool did it.
|
||||
func TestUpdateMovesTheTaskToAnotherProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "betal moms"})
|
||||
taskID := task["id"].(string)
|
||||
if task["project_id"] == projectID {
|
||||
t.Fatal("the task started in the destination; the test proves nothing")
|
||||
}
|
||||
|
||||
resp, updated := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"project_id": projectID})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch: status %d, body %v", resp.StatusCode, updated)
|
||||
}
|
||||
if updated["project_id"] != projectID {
|
||||
t.Errorf("project_id = %v, want %v — the move was accepted and not made",
|
||||
updated["project_id"], projectID)
|
||||
}
|
||||
|
||||
// And it really is there, not merely reported as there.
|
||||
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if reread["project_id"] != projectID {
|
||||
t.Errorf("after re-reading, project_id = %v", reread["project_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPUpdateMovesTheTaskToAnotherProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
created := ts.callTool(t, "create_task", map[string]any{"text": "betal moms"})
|
||||
taskID := created["id"].(string)
|
||||
|
||||
moved := ts.callTool(t, "update_task", map[string]any{
|
||||
"task_id": taskID, "project_id": projectID,
|
||||
})
|
||||
if moved["project_id"] != projectID {
|
||||
t.Errorf("project_id = %v, want %v", moved["project_id"], projectID)
|
||||
}
|
||||
}
|
||||
|
||||
// A project you cannot write to is refused rather than quietly ignored.
|
||||
func TestUpdateRefusesAProjectYouCannotWriteTo(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min opgave"})
|
||||
taskID := task["id"].(string)
|
||||
mine := task["project_id"]
|
||||
|
||||
resp, _ := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"project_id": theirs["id"]})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if reread["project_id"] != mine {
|
||||
t.Error("the task moved into somebody else's project")
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown #project is still not an error — the thought is kept rather than
|
||||
// thrown away over a typo — but it is no longer silent. It used to vanish from
|
||||
// the title while the task went to the Inbox, with nothing saying why.
|
||||
func TestQuickAddSaysWhenTheProjectDoesNotExist(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Lav plakat #3Dekoration"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status %d, want 201 — the task should still be created", resp.StatusCode)
|
||||
}
|
||||
if body["unknown_project"] != "3Dekoration" {
|
||||
t.Errorf("unknown_project = %v, want %q", body["unknown_project"], "3Dekoration")
|
||||
}
|
||||
if body["content"] != "Lav plakat" {
|
||||
t.Errorf("content = %v", body["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickAddSaysNothingWhenTheProjectExists(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Dekoration"})
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Lav plakat #Dekoration"})
|
||||
if _, present := body["unknown_project"]; present {
|
||||
t.Errorf("reported an unknown project for one that exists: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCreateSaysWhenTheProjectDoesNotExist(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
created := ts.callTool(t, "create_task", map[string]any{"text": "Lav plakat #3Dekoration"})
|
||||
note, _ := created["note"].(string)
|
||||
if !strings.Contains(note, "3Dekoration") {
|
||||
t.Errorf("note = %q, want it to name the project that does not exist", note)
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-tasks were unreachable over MCP: create_task had no parent_id, so a parent
|
||||
// with seven children arrived as eight unrelated tasks.
|
||||
func TestMCPCreateMakesSubtasks(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
parent := ts.callTool(t, "create_task", map[string]any{
|
||||
"content": "Runen v2", "project_id": project["id"],
|
||||
})
|
||||
parentID := parent["id"].(string)
|
||||
|
||||
child := ts.callTool(t, "create_task", map[string]any{
|
||||
"content": "Tegn ikonet", "parent_id": parentID,
|
||||
})
|
||||
if child["parent_id"] != parentID {
|
||||
t.Errorf("parent_id = %v, want %v", child["parent_id"], parentID)
|
||||
}
|
||||
// A sub-task joins its parent's project whatever else was asked for.
|
||||
if child["project_id"] != project["id"] {
|
||||
t.Errorf("project_id = %v, want the parent's %v", child["project_id"], project["id"])
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/tasks?parent_id="+parentID, nil)
|
||||
if tasks, _ := listed["tasks"].([]any); len(tasks) != 1 {
|
||||
t.Errorf("the parent has %d sub-tasks, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
parent := ts.callTool(t, "create_task", map[string]any{"content": "Runen v2"})
|
||||
child := ts.callTool(t, "create_task", map[string]any{
|
||||
"content": "Tegn ikonet", "parent_id": parent["id"],
|
||||
})
|
||||
|
||||
// A tool that refuses reports it inside the result with isError, not as a
|
||||
// JSON-RPC error — the call itself was well formed, the arguments were not.
|
||||
response := ts.rpc(t, "tools/call", map[string]any{
|
||||
"name": "create_task",
|
||||
"arguments": map[string]any{"content": "Endnu dybere", "parent_id": child["id"]},
|
||||
})
|
||||
result, _ := response["result"].(map[string]any)
|
||||
if result == nil || result["isError"] != true {
|
||||
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/mcp"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
@@ -55,7 +58,8 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
@@ -124,6 +128,42 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleMCPWithKey is the same endpoint for a client that can only be given a
|
||||
// URL: `POST /mcp?key=vrd_…`.
|
||||
//
|
||||
// Claude's custom-connector dialog asks for a name, an address and — under
|
||||
// advanced settings — an OAuth client id and secret. There is no field for a
|
||||
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
|
||||
// address is. The calendar feed already carries its credential in the URL for
|
||||
// the same reason, and the request logger records the path without the query, so
|
||||
// the key does not end up in the log.
|
||||
//
|
||||
// The key is the *only* thing accepted here. Reading a session cookie instead
|
||||
// would make this a state-changing POST outside the CSRF check — which is to say
|
||||
// a cross-site request that acts as whoever is signed in.
|
||||
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.URL.Query().Get("key")
|
||||
if key == "" || !auth.IsAPIToken(key) {
|
||||
// 401 rather than 404: unlike a task, the existence of this endpoint is
|
||||
// not a secret — it is written in the documentation.
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"add ?key=<api token> to the URL")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.db.UserByAPIToken(r.Context(), key)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
s.internal(w, "mcp key", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
|
||||
}
|
||||
|
||||
// --- tool implementations ------------------------------------------------------------
|
||||
|
||||
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
@@ -215,6 +255,7 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
Text string `json:"text"`
|
||||
Content string `json:"content"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
DueDate string `json:"due_date"`
|
||||
Priority int `json:"priority"`
|
||||
Description string `json:"description"`
|
||||
@@ -230,6 +271,11 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
task := &store.Task{CreatedBy: userID, Priority: 4}
|
||||
labels := params.Labels
|
||||
|
||||
// Reported below rather than swallowed. "#Dekoration" used to vanish from the
|
||||
// title while the task went to the Inbox, with nothing anywhere saying so —
|
||||
// invisible in a chat, where nobody sees the row that was written.
|
||||
unknownProject := ""
|
||||
|
||||
if text := strings.TrimSpace(params.Text); text != "" {
|
||||
parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||
task.Content = parsed.Content
|
||||
@@ -242,6 +288,8 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
if parsed.Project != "" && params.ProjectID == "" {
|
||||
if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
|
||||
task.ProjectID = id
|
||||
} else {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,6 +315,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
if params.ProjectID != "" {
|
||||
task.ProjectID = params.ProjectID
|
||||
}
|
||||
|
||||
// A sub-task belongs to its parent's project, whatever else was asked for:
|
||||
// the two cannot disagree, and the parent is the more specific instruction.
|
||||
// Without this the tools could not build a tree at all — an import of a
|
||||
// parent with seven children arrived as eight unrelated tasks.
|
||||
if params.ParentID != "" {
|
||||
parent, err := s.db.GetTask(ctx, params.ParentID, userID)
|
||||
if err != nil {
|
||||
return nil, mcp.ArgError("no task with id %q to hang this under", params.ParentID)
|
||||
}
|
||||
if parent.ParentID != "" {
|
||||
return nil, mcp.ArgError("sub-tasks are one level deep; %q is already a sub-task", params.ParentID)
|
||||
}
|
||||
task.ParentID = parent.ID
|
||||
task.ProjectID = parent.ProjectID
|
||||
}
|
||||
|
||||
if task.ProjectID == "" {
|
||||
if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
@@ -281,10 +346,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
}
|
||||
|
||||
s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
|
||||
return map[string]any{
|
||||
|
||||
out := map[string]any{
|
||||
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
||||
"due_date": task.DueDate, "project_id": task.ProjectID,
|
||||
}, nil
|
||||
}
|
||||
if task.ParentID != "" {
|
||||
out["parent_id"] = task.ParentID
|
||||
}
|
||||
if unknownProject != "" {
|
||||
// Phrased for a model to relay: it says what happened and what to do,
|
||||
// so the person is told rather than left with a task in the wrong place.
|
||||
out["note"] = fmt.Sprintf(
|
||||
"There is no project called %q, so this went to the Inbox. "+
|
||||
"Create the project and move it, or say which project you meant.",
|
||||
unknownProject)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
@@ -326,8 +404,11 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
|
||||
if params.Labels != nil {
|
||||
update.SetLabels, update.Labels = true, *params.Labels
|
||||
}
|
||||
// Moved before the field update, and through the same helper the REST handler
|
||||
// uses. This used to check the caller's rights on the destination and then
|
||||
// drop the argument, so the task never moved and the answer still said OK.
|
||||
if params.ProjectID != nil {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, *params.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
if err := s.moveTaskToProject(ctx, params.TaskID, *params.ProjectID, userID); err != nil {
|
||||
return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
|
||||
}
|
||||
}
|
||||
@@ -341,9 +422,13 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
|
||||
}
|
||||
s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
|
||||
|
||||
// project_id is in the answer because it is one of the things that can be
|
||||
// changed here: a caller that asked for a move can see whether it happened
|
||||
// rather than taking "OK" on trust.
|
||||
return map[string]any{
|
||||
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
||||
"due_date": task.DueDate, "labels": task.Labels,
|
||||
"project_id": task.ProjectID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -68,6 +69,39 @@ type projectRequest struct {
|
||||
Archived *bool `json:"archived"`
|
||||
}
|
||||
|
||||
type reorderProjectsRequest struct {
|
||||
// IDs in the order they should appear, top to bottom.
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// handleReorderProjects sets the sidebar order.
|
||||
//
|
||||
// The whole list rather than one move: a person has a handful of projects, and
|
||||
// sending the order you want is both simpler to reason about and impossible to
|
||||
// get into the half-ordered state a sequence of individual moves can produce if
|
||||
// one of them fails.
|
||||
//
|
||||
// Projects the caller does not own are skipped — sort_order is a column on the
|
||||
// project, so a shared one sits where its owner put it.
|
||||
func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
|
||||
var req reorderProjectsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
// A ceiling rather than a guess at what is reasonable: this is a bound on
|
||||
// the transaction, not an opinion about how many projects somebody may have.
|
||||
if len(req.IDs) > 500 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder projects", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
var req projectRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
@@ -99,6 +133,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
|
||||
s.publishToOwner(r, "project.created", toProjectJSON(*p))
|
||||
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
|
||||
}
|
||||
|
||||
@@ -134,6 +169,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "get project", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
|
||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||
}
|
||||
|
||||
@@ -141,16 +177,49 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
if err := s.db.DeleteProject(r.Context(), projectID); err != nil {
|
||||
if strings.Contains(err.Error(), "Inbox cannot be deleted") {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "the Inbox cannot be deleted")
|
||||
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
|
||||
return
|
||||
}
|
||||
s.storeError(w, "delete project", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "project.deleted", nil)
|
||||
s.publishToOwner(r, "project.deleted", map[string]string{"id": projectID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type trashedProjectJSON struct {
|
||||
projectJSON
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
// PurgeAfter is when it stops being recoverable. Sent rather than left for
|
||||
// the frontend to work out, because the retention window is the server's and
|
||||
// a copy of it in the interface is a copy that will one day disagree.
|
||||
PurgeAfter string `json:"purge_after"`
|
||||
TaskCount int `json:"task_count"`
|
||||
}
|
||||
|
||||
// handleListTrashedProjects is what makes restoring reachable. The restore
|
||||
// endpoint has always existed; without this, using it meant knowing the id of
|
||||
// something that had already vanished from the interface.
|
||||
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
|
||||
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list trashed projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]trashedProjectJSON, 0, len(trashed))
|
||||
for _, t := range trashed {
|
||||
out = append(out, trashedProjectJSON{
|
||||
projectJSON: toProjectJSON(t.Project),
|
||||
DeletedAt: t.DeletedAt.Format(time.RFC3339),
|
||||
PurgeAfter: t.DeletedAt.Add(s.cfg.TrashRetention).Format(time.RFC3339),
|
||||
TaskCount: t.TaskCount,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"projects": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
// Permission cannot be checked through ProjectRole here: a trashed project is
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Restoring has been possible since the trash existed. Finding what to restore
|
||||
// has not: the endpoint takes an id, and the id is exactly what you no longer
|
||||
// have once the project has gone from the interface.
|
||||
func TestTheTrashCanBeListedAndRestored(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ferie"})
|
||||
id := created["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "book fly", "project_id": id})
|
||||
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+id, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Gone from the ordinary listing.
|
||||
_, live := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
for _, raw := range live["projects"].([]any) {
|
||||
if raw.(map[string]any)["id"] == id {
|
||||
t.Fatal("a deleted project is still in the project list")
|
||||
}
|
||||
}
|
||||
|
||||
// And findable in the trash, with enough to decide whether to bring it back.
|
||||
_, trash := ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||
list, _ := trash["projects"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("want one project in the trash, got %v", list)
|
||||
}
|
||||
entry := list[0].(map[string]any)
|
||||
if entry["name"] != "Ferie" {
|
||||
t.Errorf("name = %v", entry["name"])
|
||||
}
|
||||
if entry["task_count"] != float64(1) {
|
||||
t.Errorf("task_count = %v, want 1 — the count is what says whether it matters",
|
||||
entry["task_count"])
|
||||
}
|
||||
if entry["purge_after"] == nil || entry["deleted_at"] == nil {
|
||||
t.Errorf("no deadline was reported: %v", entry)
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("restore: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Back, with its task.
|
||||
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+id, nil)
|
||||
if got, _ := tasks["tasks"].([]any); len(got) != 1 {
|
||||
t.Errorf("the task did not come back with the project: %v", got)
|
||||
}
|
||||
_, trash = ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
||||
t.Errorf("still in the trash after restoring: %v", list)
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's deleted project is not in your trash, and you cannot restore it.
|
||||
func TestTheTrashIsPerOwner(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||
id := created["id"].(string)
|
||||
ts.do(t, "DELETE", "/api/v1/projects/"+id, nil)
|
||||
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
_, trash := other.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
||||
t.Errorf("somebody else's deleted project is visible: %v", list)
|
||||
}
|
||||
resp, _ := other.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderProjectsWritesTheGivenOrder(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var ids []string
|
||||
for _, name := range []string{"Alfa", "Bravo", "Charlie"} {
|
||||
_, body := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": name})
|
||||
ids = append(ids, body["id"].(string))
|
||||
}
|
||||
|
||||
// Back to front.
|
||||
reversed := []string{ids[2], ids[1], ids[0]}
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{"ids": reversed})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
projects, _ := list["projects"].([]any)
|
||||
|
||||
var got []string
|
||||
for _, raw := range projects {
|
||||
p := raw.(map[string]any)
|
||||
// The Inbox sorts ahead of everything by its own rule; it is not part of
|
||||
// what was reordered.
|
||||
if p["is_inbox"] == true {
|
||||
continue
|
||||
}
|
||||
got = append(got, p["id"].(string))
|
||||
}
|
||||
if len(got) != 3 || got[0] != reversed[0] || got[1] != reversed[1] || got[2] != reversed[2] {
|
||||
t.Errorf("order = %v, want %v", got, reversed)
|
||||
}
|
||||
}
|
||||
|
||||
// sort_order is a column on the project, not a per-viewer preference, so
|
||||
// reordering must not rearrange a project somebody else owns.
|
||||
func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
||||
theirID := theirs["id"].(string)
|
||||
|
||||
_, before := other.do(t, "GET", "/api/v1/projects", nil)
|
||||
var wanted float64
|
||||
for _, raw := range before["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["id"] == theirID {
|
||||
wanted = p["sort_order"].(float64)
|
||||
}
|
||||
}
|
||||
|
||||
// Ask, as somebody else, to put their project first.
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{
|
||||
"ids": []string{theirID},
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := other.do(t, "GET", "/api/v1/projects", nil)
|
||||
for _, raw := range after["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["id"] == theirID {
|
||||
if p["sort_order"] != wanted {
|
||||
t.Errorf("sort_order changed from %v to %v — somebody else moved it",
|
||||
wanted, p["sort_order"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The member list errored on every project — the ORDER BY named an expression,
|
||||
// which SQLite refuses across a UNION — so the share panel had never shown
|
||||
// anybody, not even the owner.
|
||||
func TestListMembersWorksAtAll(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
members, _ := body["members"].([]any)
|
||||
if len(members) != 1 {
|
||||
t.Fatalf("a project with no invites lists %d members, want its owner", len(members))
|
||||
}
|
||||
if m := members[0].(map[string]any); m["role"] != "owner" {
|
||||
t.Errorf("role = %v, want owner", m["role"])
|
||||
}
|
||||
}
|
||||
|
||||
// And the Inbox, which is the one every task drawer asks about.
|
||||
func TestListMembersWorksForTheInbox(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
var inboxID string
|
||||
for _, raw := range list["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["is_inbox"] == true {
|
||||
inboxID = p["id"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/projects/"+inboxID+"/members", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,19 @@ func (s *Server) publish(projectID, event string, payload any) {
|
||||
s.hub.Publish(projectID, event, payload)
|
||||
}
|
||||
|
||||
// publishToOwner sends a change that belongs to a person rather than to a
|
||||
// project: their projects, their labels, their saved filters.
|
||||
//
|
||||
// Project membership is what the socket subscribes to, and a project that has
|
||||
// just been created has no subscribers — the person is not watching a thing that
|
||||
// did not exist a moment ago. So these go to the user instead, which also
|
||||
// reaches their other devices.
|
||||
func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
|
||||
if user := userFrom(r.Context()); user != nil {
|
||||
s.hub.PublishToUser(user.ID, event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebSocket is the live-sync connection.
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
@@ -90,6 +90,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// authenticated by the token in the recipient address.
|
||||
r.Post("/inbound/mail", s.handleInboundMail)
|
||||
|
||||
// MCP with the token in the query string, for clients that cannot send a
|
||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
|
||||
// it at all. Same reasoning as the calendar feed above: a client that cannot
|
||||
// be told to send a header means the token in the URL is the credential.
|
||||
//
|
||||
// Outside /api/v1 deliberately, because it must not be reachable with a
|
||||
// session cookie. A cookie-authenticated POST with no CSRF check is a
|
||||
// cross-site request waiting to happen; handleMCPWithKey accepts the key and
|
||||
// nothing else.
|
||||
r.Post("/mcp", s.handleMCPWithKey)
|
||||
|
||||
// Google sends the browser here after consent. Outside /api/v1 because it is a
|
||||
// top-level navigation rather than a fetch, but still behind the session — the
|
||||
// tokens it exchanges belong to whoever is signed in.
|
||||
@@ -247,6 +259,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Route("/projects", func(r chi.Router) {
|
||||
r.Get("/", s.handleListProjects)
|
||||
r.Post("/", s.handleCreateProject)
|
||||
// Above /{projectID}, or chi reads "reorder" as an id.
|
||||
r.Post("/reorder", s.handleReorderProjects)
|
||||
|
||||
r.Route("/{projectID}", func(r chi.Router) {
|
||||
// Read access is the floor for the whole subtree; the writes
|
||||
@@ -273,9 +287,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
})
|
||||
|
||||
// Restoring is outside the block above: a trashed project is invisible
|
||||
// to the permission check by design, so the handler resolves ownership
|
||||
// itself.
|
||||
// The trash is outside the block above: a deleted project is invisible
|
||||
// to the permission check by design, so these handlers resolve
|
||||
// ownership themselves.
|
||||
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
@@ -336,7 +351,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
// The version travels with the health check because this is the one endpoint
|
||||
// that answers without a session. "Which version is actually running?" is
|
||||
// otherwise a question you cannot ask from outside — and after a deploy it is
|
||||
// the first question there is.
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": Version})
|
||||
}
|
||||
|
||||
// serveWeb serves the PWA, falling back to index.html so that client-side routes
|
||||
@@ -348,6 +367,21 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// /.well-known/ is reserved by RFC 8615 and is never a route in the app.
|
||||
// Handing back the shell there does not merely waste a response: it tells
|
||||
// whoever asked that the thing they were looking for exists.
|
||||
//
|
||||
// That is not hypothetical. Claude probes
|
||||
// /.well-known/oauth-authorization-server before connecting to an MCP server,
|
||||
// read 200 and a page of HTML as "there is an authorization server here", and
|
||||
// then failed trying to register with it — reporting a broken sign-in service
|
||||
// on a server that has none and needs none. A 404 means "no OAuth here", the
|
||||
// client stops asking, and the key in the URL is used as intended.
|
||||
if strings.HasPrefix(r.URL.Path, "/.well-known/") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if s.web == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -249,6 +251,16 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A change of project is a move, not a field: it has to renumber the task
|
||||
// among its new neighbours. Done before the rest so a request that both moves
|
||||
// and edits ends up entirely applied or entirely refused.
|
||||
if req.ProjectID != nil {
|
||||
if err := s.moveTaskToProject(r.Context(), taskID, *req.ProjectID, user.ID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
u := store.TaskUpdate{
|
||||
Description: req.Description, Priority: req.Priority,
|
||||
SectionID: req.SectionID, ParentID: req.ParentID,
|
||||
@@ -473,11 +485,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
projectID := req.ProjectID
|
||||
// Set when the text named a project that does not exist. The task is still
|
||||
// created — see below — but the caller is told, because the alternative is
|
||||
// what it used to be: "#Dekoration" disappears from the title, the task lands
|
||||
// somewhere else, and nothing anywhere says why.
|
||||
unknownProject := ""
|
||||
if parsed.Project != "" {
|
||||
// An unknown "#name" is not an error: the task still gets created, in the
|
||||
// Inbox, rather than a thought being thrown away over a typo.
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||
projectID = id
|
||||
} else {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
@@ -510,6 +529,16 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
t.Labels = parsed.Labels
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
|
||||
if unknownProject != "" {
|
||||
// Alongside the task rather than instead of it: the task was created, and
|
||||
// this says what could not be honoured while creating it.
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
taskJSON
|
||||
UnknownProject string `json:"unknown_project"`
|
||||
}{toTaskJSON(*t), unknownProject})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -525,6 +554,42 @@ func (s *Server) handleQuickAddPreview(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- helpers --------------------------------------------------------------------
|
||||
|
||||
// moveTaskToProject moves a task to another project, if that is really what was
|
||||
// asked for.
|
||||
//
|
||||
// Shared by the REST handler and the MCP tool, because they had drifted: both
|
||||
// accepted project_id on an update, both checked the caller's rights on the
|
||||
// destination — which reads as "understood" — and then neither passed it on,
|
||||
// because store.TaskUpdate has no such field. The task stayed where it was and
|
||||
// the response said OK. An argument that is validated and then dropped is worse
|
||||
// than one that is refused.
|
||||
//
|
||||
// A move rather than a field assignment: sort_order is per project, so landing
|
||||
// in a new one means being given a position among its tasks.
|
||||
func (s *Server) moveTaskToProject(ctx context.Context, taskID, projectID, userID string) error {
|
||||
if projectID == "" {
|
||||
return nil
|
||||
}
|
||||
current, err := s.db.GetTask(ctx, taskID, userID)
|
||||
if err != nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
if current.ProjectID == projectID {
|
||||
return nil // already there; not worth a write or a renumber
|
||||
}
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, projectID, userID, store.RoleEditor); err != nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
|
||||
// No neighbours: it lands at the start of the destination, which is the only
|
||||
// answer available when the caller did not say where. Section is cleared —
|
||||
// a section belongs to the project being left behind.
|
||||
if _, err := s.db.MoveTask(ctx, taskID, projectID, "", "", ""); err != nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTaskContent(content string, priority *int) map[string]string {
|
||||
fields := map[string]string{}
|
||||
switch {
|
||||
|
||||
@@ -116,7 +116,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
||||
user := userFrom(r.Context())
|
||||
|
||||
if !user.TOTPEnabled {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
writeError(w, http.StatusConflict, CodeTOTPNotEnabled,
|
||||
"two-factor authentication is not switched on")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// A label is how you find a task whose wording you have forgotten. Search
|
||||
// covered the text only, so "regnskab" found nothing even with the task
|
||||
// labelled @regnskab in front of you.
|
||||
func TestSearchFindsTasksByLabel(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "ring til revisoren @regnskab"})
|
||||
ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "køb kaffe"})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
||||
tasks, _ := body["tasks"].([]any)
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("found %d tasks searching for a label, want 1: %v", len(tasks), body)
|
||||
}
|
||||
if got := tasks[0].(map[string]any)["content"]; got != "ring til revisoren" {
|
||||
t.Errorf("found %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The text search still works, and a label match does not swallow it.
|
||||
func TestSearchStillFindsTasksByText(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": "køb grøn maling"})
|
||||
|
||||
// Diacritic folding still applies to the text side.
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=gron", nil)
|
||||
if tasks, _ := body["tasks"].([]any); len(tasks) != 1 {
|
||||
t.Errorf("searching \"gron\" found %d tasks, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's label must not pull their task into your results — the label
|
||||
// join has to stay inside the visibility the outer query already applies.
|
||||
func TestSearchByLabelStaysWithinWhatYouCanSee(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
other.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "deres hemmelighed @regnskab"})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
||||
if tasks, _ := body["tasks"].([]any); len(tasks) != 0 {
|
||||
t.Errorf("found somebody else's task: %v", tasks)
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,55 @@ func (db *DB) DeleteProject(ctx context.Context, projectID string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// TrashedProject is a deleted project, with when it went and when it goes for good.
|
||||
type TrashedProject struct {
|
||||
Project
|
||||
DeletedAt time.Time
|
||||
TaskCount int
|
||||
}
|
||||
|
||||
// ListTrashedProjects returns what the caller owns and has deleted.
|
||||
//
|
||||
// Restoring has been possible since the trash existed, but only if you already
|
||||
// knew the project's id — which is exactly what you no longer have once it has
|
||||
// gone from the interface. A recovery window nobody can reach is not one.
|
||||
//
|
||||
// Owned, not shared: a member of a project somebody else deleted has no business
|
||||
// bringing it back.
|
||||
func (db *DB) ListTrashedProjects(ctx context.Context, userID string) ([]TrashedProject, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||
p.archived, p.sort_order, p.created_at, p.updated_at, p.deleted_at,
|
||||
(SELECT count(*) FROM tasks t
|
||||
WHERE t.project_id = p.id AND t.deleted_at = p.deleted_at) AS task_count
|
||||
FROM projects p
|
||||
WHERE p.owner_id = ? AND p.deleted_at IS NOT NULL
|
||||
ORDER BY p.deleted_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []TrashedProject{}
|
||||
for rows.Next() {
|
||||
var t TrashedProject
|
||||
var created, updated, deleted int64
|
||||
var icon sql.NullString
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &icon, &t.ViewMode, &t.OwnerID,
|
||||
&t.IsInbox, &t.Archived, &t.SortOrder, &created, &updated, &deleted,
|
||||
&t.TaskCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Icon = icon.String
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
t.DeletedAt = time.Unix(deleted, 0).UTC()
|
||||
t.Role = RoleOwner
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RestoreProject brings a project back, along with the tasks that were deleted with
|
||||
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
|
||||
// and separately, and restoring a project should not undo that too.
|
||||
@@ -279,15 +328,27 @@ type Member struct {
|
||||
// project_members row — ownership is the projects table — so they are unioned in
|
||||
// rather than left out of the list of people who can see it.
|
||||
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
|
||||
// The owner first, then everybody else by name.
|
||||
//
|
||||
// The ordering is a column rather than an expression, and the whole compound
|
||||
// select is wrapped: SQLite only accepts ORDER BY terms from the result set
|
||||
// of a UNION, so `ORDER BY role = 'owner' DESC` — which reads perfectly well
|
||||
// — fails at query time with "1st ORDER BY term does not match any column in
|
||||
// the result set". Every call errored, so the share panel had never listed
|
||||
// anybody.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, p.created_at
|
||||
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||
WHERE p.id = ?
|
||||
UNION ALL
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, m.role, m.added_at
|
||||
FROM project_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.project_id = ?
|
||||
ORDER BY role = 'owner' DESC, name`, projectID, projectID)
|
||||
SELECT id, email, name, avatar_color, role, added_at FROM (
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role,
|
||||
p.created_at AS added_at, 0 AS rank
|
||||
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||
WHERE p.id = ?
|
||||
UNION ALL
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, m.role,
|
||||
m.added_at, 1 AS rank
|
||||
FROM project_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.project_id = ?
|
||||
)
|
||||
ORDER BY rank, name`, projectID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -507,3 +568,30 @@ func joinComma(parts []string) string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReorderProjects writes the given order, and only for projects the user owns.
|
||||
//
|
||||
// The whole list at once, rather than the midpoint-between-neighbours dance the
|
||||
// tasks do. A person has a handful of projects, not a thousand, so writing
|
||||
// 0, 1, 2 … is one small transaction that cannot run out of precision — the
|
||||
// failure the task ordering has to respace a section to recover from.
|
||||
//
|
||||
// Scoped to ownership because sort_order lives on the project row rather than
|
||||
// per viewer: a shared project sits where its owner put it, and a member
|
||||
// reordering their sidebar must not rearrange somebody else's. Ids that are not
|
||||
// the caller's are skipped rather than refused, so one shared project in the
|
||||
// list does not fail the whole drag.
|
||||
func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
now := time.Now().Unix()
|
||||
for i, id := range ids {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE projects SET sort_order = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_id = ? AND deleted_at IS NULL`,
|
||||
float64(i), now, id, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
+17
-2
@@ -136,8 +136,23 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
args = append(args, f.DueFrom)
|
||||
}
|
||||
if expr := MatchExpr(f.Search); expr != "" {
|
||||
where = append(where, `t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)`)
|
||||
args = append(args, expr)
|
||||
// Text or a label. A label is how you find a task whose wording you have
|
||||
// forgotten — "that thing about the accounts" is a search for @regnskab —
|
||||
// and leaving them out made the box look like it had lost the task.
|
||||
//
|
||||
// The label side is a plain lowercase match rather than another FTS index:
|
||||
// labels are short, and their names are typed by the person searching for
|
||||
// them, so there is no spelling to be generous about. The diacritic
|
||||
// folding that matters for task text does not earn a second index here.
|
||||
where = append(where, `(
|
||||
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
||||
OR t.id IN (
|
||||
SELECT tl.task_id FROM task_labels tl
|
||||
JOIN labels l ON l.id = tl.label_id
|
||||
WHERE lower(l.name) LIKE ?
|
||||
)
|
||||
)`)
|
||||
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
||||
}
|
||||
if f.FilterSQL != "" {
|
||||
where = append(where, "("+f.FilterSQL+")")
|
||||
|
||||
+9
-1
@@ -11,7 +11,7 @@ gameskill:
|
||||
invites and reminders, then start it and open the URL to create the first
|
||||
admin account.
|
||||
author: "kristianwind"
|
||||
version: 1
|
||||
version: 2
|
||||
icon: "app"
|
||||
|
||||
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
||||
@@ -70,6 +70,14 @@ gameskill:
|
||||
pattern: '^[0-9]+(ns|us|ms|s|m|h)$'
|
||||
hint: "must be a duration like 720h, 30m or 45s"
|
||||
|
||||
# Off by default because it is an outbound request nobody asked for. It sends
|
||||
# no identifier and reads one public URL — GitHub's releases API — but that is
|
||||
# a choice to make rather than a default to inherit.
|
||||
- key: VERDANDE_UPDATE_CHECK
|
||||
name: "Look for new releases (asks GitHub once a day; sends nothing about you)"
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
install:
|
||||
# The image has no shell, so the install step borrows one. It only has to make
|
||||
# the directory layout exist; verdande creates and migrates its own database on
|
||||
|
||||
@@ -82,6 +82,12 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||
for (const href of hrefs) {
|
||||
await sidebar.locator(`a[href="${href}"]`).first().click();
|
||||
|
||||
// Wait for the navigation before looking at the page. Without this the
|
||||
// assertion can be satisfied by the heading of the page you just left —
|
||||
// which is not a hypothetical: a change that removed a project page's
|
||||
// heading entirely passed here on a fast machine and failed on CI.
|
||||
await page.waitForURL(`**${href}`);
|
||||
|
||||
// Something with a heading rendered. A route written outside the routes
|
||||
// tree lands on the SPA shell and renders nothing — which is exactly how
|
||||
// the label route broke, and it answered 200 the whole time.
|
||||
@@ -179,3 +185,62 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
||||
expect(worker.ok()).toBeTruthy();
|
||||
expect(await worker.text()).toContain("addEventListener('push'");
|
||||
});
|
||||
|
||||
/**
|
||||
* A project can be renamed, deleted and brought back.
|
||||
*
|
||||
* The round trip matters more than the three steps: deleting used to be a
|
||||
* one-way door in the interface — the API could restore, but only from an id
|
||||
* that had already disappeared from the screen.
|
||||
*/
|
||||
test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Ferie');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Ferie' })).toBeVisible();
|
||||
|
||||
// The heading is the rename affordance: click it, and it becomes a field.
|
||||
await page.getByRole('button', { name: 'Ferie' }).click();
|
||||
const title = page.getByLabel('Projektets navn');
|
||||
await title.fill('Sommerferie');
|
||||
await title.press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Slet projektet' }).click();
|
||||
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
|
||||
|
||||
// And it is reachable again, which is the half that did not exist.
|
||||
await page.goto('/indstillinger/data');
|
||||
const trash = page.locator('section.panel').filter({ hasText: 'Papirkurv' });
|
||||
await expect(trash.getByText('Sommerferie')).toBeVisible();
|
||||
|
||||
await trash.getByRole('button', { name: 'Hent tilbage' }).click();
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The MCP connector address must not fall through to the app shell.
|
||||
*
|
||||
* It did: `/mcp` was not a route, so the SPA fallback answered 200 with a page
|
||||
* of HTML. A connector pointed at it reported a successful connection and then
|
||||
* failed to parse the response — which looks like a broken client rather than a
|
||||
* missing route. A 401 in JSON is the honest answer.
|
||||
*/
|
||||
test('MCP-adressen svarer JSON, ikke app-skallen', async ({ request }) => {
|
||||
const response = await request.post('/mcp', {
|
||||
data: { jsonrpc: '2.0', id: 1, method: 'initialize' }
|
||||
});
|
||||
|
||||
expect(response.status(), 'uden nøgle skal den afvise, ikke servere siden').toBe(401);
|
||||
expect(response.headers()['content-type']).toContain('application/json');
|
||||
expect((await response.json()).code).toBe('unauthorized');
|
||||
});
|
||||
|
||||
+151
-1
@@ -87,7 +87,157 @@
|
||||
--content-max: 720px;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
/*
|
||||
* The dark palette again, by name.
|
||||
*
|
||||
* :root carries it as the default, and :root only ever matches <html> — so a
|
||||
* swatch in the settings picker asking for data-theme="dark" would otherwise
|
||||
* inherit whatever the page is wearing. Repeated rather than restructured: the
|
||||
* alternative is splitting the token block in two and leaving somebody to work
|
||||
* out which half a new token belongs in.
|
||||
*/
|
||||
[data-theme='dark'] {
|
||||
--ground: #111312;
|
||||
--surface: #171a19;
|
||||
--surface-raised: #1d211f;
|
||||
--surface-sunken: #0d0f0e;
|
||||
--line: #262b29;
|
||||
--line-strong: #343a37;
|
||||
|
||||
--ink: #e8ebe9;
|
||||
--ink-muted: #9aa39e;
|
||||
--ink-faint: #6b736f;
|
||||
|
||||
--accent: #5fbf8f;
|
||||
--accent-hover: #6fcf9f;
|
||||
--accent-sunken: #1c3329;
|
||||
--accent-ink: #0d1411;
|
||||
|
||||
--p1: #cf6b6b;
|
||||
--p2: #d69f5c;
|
||||
--p3: #6b9ecf;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #cf6b6b;
|
||||
--danger-sunken: #33201f;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.2);
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
/*
|
||||
* The other themes.
|
||||
*
|
||||
* Each one redefines colour and nothing else: no spacing, no type, no radii.
|
||||
* That is what keeps them themes rather than five separate designs, and it is
|
||||
* why a component that reaches for a raw hex breaks in four places at once
|
||||
* instead of one.
|
||||
*
|
||||
* Every theme states its full palette rather than inheriting the dark one and
|
||||
* patching it. A partial theme looks finished until somebody adds a token, at
|
||||
* which point four themes silently fall back to a colour from a fifth.
|
||||
*/
|
||||
|
||||
/* Skumring — the same dark, turned warm, with amber where the green was. */
|
||||
[data-theme='dusk'] {
|
||||
--ground: #14110f;
|
||||
--surface: #1b1714;
|
||||
--surface-raised: #221d19;
|
||||
--surface-sunken: #100d0b;
|
||||
--line: #2b2521;
|
||||
--line-strong: #3a322c;
|
||||
|
||||
--ink: #ece6df;
|
||||
--ink-muted: #a3988c;
|
||||
/* Light enough to clear 4.5:1 against the ground. The quiet tier is where
|
||||
dates and counts live, and it is the first thing to disappear. */
|
||||
--ink-faint: #8d8377;
|
||||
|
||||
--accent: #d99a4e;
|
||||
--accent-hover: #e8a95c;
|
||||
--accent-sunken: #33261480;
|
||||
--accent-ink: #16110a;
|
||||
|
||||
--p1: #cf6b6b;
|
||||
--p2: #d69f5c;
|
||||
--p3: #6b9ecf;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #cf6b6b;
|
||||
--danger-sunken: #33201f;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.25);
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* Papir — warm light, closer to paper than to a screen. */
|
||||
[data-theme='paper'] {
|
||||
--ground: #f6f1e7;
|
||||
--surface: #fffcf6;
|
||||
--surface-raised: #fffcf6;
|
||||
--surface-sunken: #ece5d7;
|
||||
--line: #ded5c3;
|
||||
--line-strong: #c8bda6;
|
||||
|
||||
--ink: #241f18;
|
||||
--ink-muted: #665c4c;
|
||||
--ink-faint: #756a55;
|
||||
|
||||
--accent: #8a5a2b;
|
||||
--accent-hover: #74491f;
|
||||
--accent-sunken: #ebdfcb;
|
||||
--accent-ink: #fffcf6;
|
||||
|
||||
--p1: #b0452f;
|
||||
--p2: #96701f;
|
||||
--p3: #3f6b8a;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #b0452f;
|
||||
--danger-sunken: #f3e2dc;
|
||||
|
||||
--shadow: 0 1px 2px rgb(60 40 10 / 0.08);
|
||||
--shadow-lg: 0 8px 32px rgb(60 40 10 / 0.14);
|
||||
}
|
||||
|
||||
/*
|
||||
* Kontrast — for reading in bright sun, or for eyes that the quiet greys of the
|
||||
* other four do not serve. Pure black on white, borders that are actually
|
||||
* visible, and an accent dark enough to pass against white text.
|
||||
*
|
||||
* The muted inks are deliberately much darker than elsewhere: "secondary text"
|
||||
* at 60% grey is the first thing to disappear, and it is where dates live.
|
||||
*/
|
||||
[data-theme='contrast'] {
|
||||
--ground: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #f2f2f2;
|
||||
--surface-sunken: #e8e8e8;
|
||||
--line: #767676;
|
||||
--line-strong: #1a1a1a;
|
||||
|
||||
--ink: #000000;
|
||||
--ink-muted: #2b2b2b;
|
||||
--ink-faint: #4a4a4a;
|
||||
|
||||
--accent: #005c2e;
|
||||
--accent-hover: #00753a;
|
||||
--accent-sunken: #cfe8da;
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
--p1: #a10000;
|
||||
--p2: #7a4a00;
|
||||
--p3: #00417a;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #a10000;
|
||||
--danger-sunken: #f7dcdc;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
[data-theme='light'] {
|
||||
--ground: #faf9f7;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
|
||||
+23
-1
@@ -34,9 +34,29 @@ const MESSAGES = {
|
||||
validation_failed: 'Tjek felterne herunder.',
|
||||
payload_too_large: 'Det er for stort.',
|
||||
internal_error: 'Noget gik galt. Prøv igen.',
|
||||
offline: 'Ingen forbindelse til serveren.'
|
||||
bad_request: 'Anmodningen kunne ikke læses.',
|
||||
// Client-only: a network failure is not an HTTP status, so the server has no
|
||||
// code for it.
|
||||
offline: 'Ingen forbindelse til serveren.',
|
||||
|
||||
// Situations that used to arrive as a plain `conflict` and were therefore
|
||||
// shown as "Det er der allerede." — which was not merely vague but wrong. An
|
||||
// unconfigured Gmail is not a Gmail that is already connected.
|
||||
gmail_not_configured:
|
||||
'Gmail er ikke sat op på denne server. Administratoren skal registrere en OAuth-klient hos Google og sætte VERDANDE_GMAIL_CLIENT_ID og _SECRET.',
|
||||
ai_not_configured: 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.',
|
||||
totp_not_enabled: 'To-faktor er ikke slået til.',
|
||||
inbox_protected: 'Indbakken kan ikke slettes.'
|
||||
};
|
||||
|
||||
/**
|
||||
* What to show a person for a failed request.
|
||||
*
|
||||
* A code with no message here falls back to the server's prose, which is English
|
||||
* and written for a log — not good, but better than swallowing the only
|
||||
* explanation there is. A code that turns up in that fallback is a code this
|
||||
* table is missing.
|
||||
*/
|
||||
export function humanMessage(err) {
|
||||
if (!(err instanceof ApiError)) return MESSAGES.offline;
|
||||
return MESSAGES[err.code] ?? err.message ?? MESSAGES.internal_error;
|
||||
@@ -156,6 +176,8 @@ export const api = {
|
||||
createProject: (data) => post('/projects', data),
|
||||
updateProject: (id, data) => patch(`/projects/${id}`, data),
|
||||
deleteProject: (id) => del(`/projects/${id}`),
|
||||
reorderProjects: (ids) => post('/projects/reorder', { ids }),
|
||||
listTrashedProjects: () => get('/trash/projects'),
|
||||
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
|
||||
|
||||
listSections: (projectId) => get(`/projects/${projectId}/sections`),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, sidebar } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -27,14 +27,90 @@
|
||||
let filters = $state([]);
|
||||
let labels = $state([]);
|
||||
|
||||
// Reads app.labelsChanged so the effect re-runs when a label is created,
|
||||
// renamed or deleted anywhere — including in another tab, or by Claude
|
||||
// through the connector.
|
||||
$effect(() => {
|
||||
app.labelsChanged;
|
||||
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
||||
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
||||
});
|
||||
|
||||
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
|
||||
let own = $derived(app.projects.filter((p) => !p.is_inbox && !p.shared));
|
||||
// Sorted here rather than relying on the order the server sent, so a drag
|
||||
// settles the moment it is dropped instead of after the round trip.
|
||||
let own = $derived(
|
||||
app.projects
|
||||
.filter((p) => !p.is_inbox && !p.shared)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
);
|
||||
let current = $derived($page.url.pathname);
|
||||
|
||||
// --- resizing -------------------------------------------------------------------
|
||||
|
||||
let resizing = $state(false);
|
||||
|
||||
/**
|
||||
* Pointer events rather than mouse events, so a trackpad, a mouse and a stylus
|
||||
* all work. Capturing the pointer means the drag keeps following even when it
|
||||
* leaves the handle, which at 4px wide it does constantly.
|
||||
*/
|
||||
function startResize(event) {
|
||||
event.preventDefault();
|
||||
resizing = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function onResize(event) {
|
||||
if (!resizing) return;
|
||||
sidebar.setWidth(event.clientX);
|
||||
}
|
||||
|
||||
function endResize(event) {
|
||||
if (!resizing) return;
|
||||
resizing = false;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
// --- reordering ---------------------------------------------------------------
|
||||
|
||||
let draggingId = $state(null);
|
||||
let overId = $state(null);
|
||||
let overBelow = $state(false);
|
||||
|
||||
function onDragStart(event, project) {
|
||||
draggingId = project.id;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox refuses to start a drag unless something is set on the transfer.
|
||||
event.dataTransfer.setData('text/plain', project.id);
|
||||
}
|
||||
|
||||
function onDragOver(event, project) {
|
||||
if (!draggingId || draggingId === project.id) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
overId = project.id;
|
||||
overBelow = event.clientY > box.top + box.height / 2;
|
||||
}
|
||||
|
||||
async function onDrop(event, target) {
|
||||
event.preventDefault();
|
||||
const id = draggingId;
|
||||
const below = overBelow;
|
||||
draggingId = null;
|
||||
overId = null;
|
||||
if (!id || id === target.id) return;
|
||||
|
||||
const without = own.filter((p) => p.id !== id);
|
||||
const at = without.findIndex((p) => p.id === target.id);
|
||||
if (at < 0) return;
|
||||
|
||||
const ordered = [...without];
|
||||
ordered.splice(below ? at + 1 : at, 0, own.find((p) => p.id === id));
|
||||
await app.reorderProjects(ordered.map((p) => p.id));
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar" aria-label="Hovedmenu">
|
||||
@@ -90,8 +166,21 @@
|
||||
{#each own as project (project.id)}
|
||||
<a
|
||||
href="/projekt/{project.id}"
|
||||
class="sortable"
|
||||
class:active={current === `/projekt/${project.id}`}
|
||||
class:dragging={draggingId === project.id}
|
||||
class:drop-above={overId === project.id && !overBelow}
|
||||
class:drop-below={overId === project.id && overBelow}
|
||||
onclick={onnavigate}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, project)}
|
||||
ondragend={() => {
|
||||
draggingId = null;
|
||||
overId = null;
|
||||
}}
|
||||
ondragover={(e) => onDragOver(e, project)}
|
||||
ondragleave={() => (overId = null)}
|
||||
ondrop={(e) => onDrop(e, project)}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{project.name}
|
||||
@@ -182,6 +271,29 @@
|
||||
<span class="signout">Log ud</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
||||
A drag handle with no keyboard equivalent is a setting some people simply
|
||||
cannot reach. -->
|
||||
<div
|
||||
class="resize"
|
||||
class:resizing
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Bredde på sidebjælken"
|
||||
tabindex="0"
|
||||
onpointerdown={startResize}
|
||||
onpointermove={onResize}
|
||||
onpointerup={endResize}
|
||||
onpointercancel={endResize}
|
||||
ondblclick={() => sidebar.reset()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowLeft') sidebar.setWidth(sidebar.width - 16);
|
||||
else if (e.key === 'ArrowRight') sidebar.setWidth(sidebar.width + 16);
|
||||
else if (e.key === 'Home') sidebar.reset();
|
||||
else return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
></div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
@@ -196,6 +308,45 @@
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sits on the border, wider than it looks: a 1px target is a target you miss.
|
||||
The visible line stays the border; this only widens what the pointer hits. */
|
||||
.resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 3px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.resize:hover::after,
|
||||
.resize:focus-visible::after,
|
||||
.resize.resizing::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resize:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
/* The drawer has a fixed width and no room to drag against. */
|
||||
.resize {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -280,6 +431,39 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Only your own projects reorder. A shared one sits where its owner put it —
|
||||
sort_order is a column on the project, not a preference per viewer. */
|
||||
a.sortable {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a.sortable.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* A line in the gap rather than a highlighted row: the gap is the target. */
|
||||
a.sortable::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--s2);
|
||||
right: var(--s2);
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
a.sortable.drop-above::before {
|
||||
top: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
a.sortable.drop-below::before {
|
||||
bottom: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
||||
@@ -20,6 +20,23 @@
|
||||
let priority = $state(4);
|
||||
let dueDate = $state('');
|
||||
let labels = $state('');
|
||||
let projectId = $state('');
|
||||
let recurrence = $state('');
|
||||
let assignee = $state('');
|
||||
|
||||
/** Who the task can be given to. Only ever more than one on a shared project. */
|
||||
let members = $state([]);
|
||||
|
||||
// The rules worth a menu entry. Anything more specific is typed into the
|
||||
// title, where the parser understands far more than a list ever could.
|
||||
const REPEATS = [
|
||||
{ rule: '', label: 'Aldrig' },
|
||||
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
|
||||
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
|
||||
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
|
||||
{ rule: 'FREQ=MONTHLY', label: 'Hver måned' },
|
||||
{ rule: 'FREQ=YEARLY', label: 'Hvert år' }
|
||||
];
|
||||
|
||||
let subtasks = $state([]);
|
||||
let comments = $state([]);
|
||||
@@ -46,6 +63,17 @@
|
||||
priority = task.priority;
|
||||
dueDate = task.due_date ?? '';
|
||||
labels = (task.labels ?? []).join(', ');
|
||||
projectId = task.project_id;
|
||||
recurrence = task.recurrence_rule ?? '';
|
||||
assignee = task.assignee_id ?? '';
|
||||
|
||||
members = [];
|
||||
api.listMembers(task.project_id)
|
||||
.then((r) => r.members.length > 1 && (members = r.members))
|
||||
.catch(() => {
|
||||
// A project you are not the owner of may refuse the member list.
|
||||
// Then there is nobody to choose from, which is the same as none.
|
||||
});
|
||||
|
||||
subtasks = [];
|
||||
comments = [];
|
||||
@@ -73,13 +101,83 @@
|
||||
await app.update(task.id, patch);
|
||||
}
|
||||
|
||||
function saveContent() {
|
||||
/**
|
||||
* Saves the title, reading it the way quick add would.
|
||||
*
|
||||
* Typing "i morgen kl. 14" into a title used to leave it as those words. The
|
||||
* same sentence in the quick-add box becomes a date, which made where you
|
||||
* typed it decide what it meant.
|
||||
*
|
||||
* The parse runs on the server — the grammar is Danish and English and lives
|
||||
* there — and is only applied when it actually consumed something. What it
|
||||
* took is said out loud, because a title that edits itself without a word is
|
||||
* worse than one that does nothing.
|
||||
*/
|
||||
async function saveContent() {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || trimmed === task.content) {
|
||||
content = task.content;
|
||||
return;
|
||||
}
|
||||
save({ content: trimmed });
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = await api.quickAddPreview(trimmed);
|
||||
} catch {
|
||||
// Offline, or the parser refused. Saving the words is still right.
|
||||
}
|
||||
|
||||
const patch = { content: trimmed };
|
||||
const took = [];
|
||||
if (parsed && parsed.content && parsed.content !== trimmed) {
|
||||
patch.content = parsed.content;
|
||||
if (parsed.due_date) {
|
||||
patch.due_date = parsed.due_date;
|
||||
took.push(parsed.due_date);
|
||||
}
|
||||
if (parsed.priority && parsed.priority < 4) {
|
||||
patch.priority = parsed.priority;
|
||||
took.push(`P${parsed.priority}`);
|
||||
}
|
||||
if (parsed.recurrence) {
|
||||
patch.recurrence_rule = parsed.recurrence;
|
||||
took.push('gentagelse');
|
||||
}
|
||||
if (parsed.labels?.length) {
|
||||
patch.labels = [...new Set([...(task.labels ?? []), ...parsed.labels])];
|
||||
took.push(parsed.labels.map((l) => `@${l}`).join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
await save(patch);
|
||||
|
||||
// The fields are seeded once per task so a save cannot overwrite what
|
||||
// somebody is typing. That rule has to bend exactly here: this save
|
||||
// changed the very fields on screen, and leaving them showing what was
|
||||
// typed while the toast reports something else is the worst of both.
|
||||
if (took.length) {
|
||||
content = patch.content;
|
||||
if (patch.due_date) dueDate = patch.due_date;
|
||||
if (patch.priority) priority = patch.priority;
|
||||
if (patch.recurrence_rule) recurrence = patch.recurrence_rule;
|
||||
if (patch.labels) labels = patch.labels.join(', ');
|
||||
app.toast(`Læst som ${took.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
if (projectId === task.project_id) return;
|
||||
await save({ project_id: projectId });
|
||||
}
|
||||
|
||||
async function saveRecurrence() {
|
||||
if (recurrence === (task.recurrence_rule ?? '')) return;
|
||||
await save({ recurrence_rule: recurrence });
|
||||
}
|
||||
|
||||
async function saveAssignee() {
|
||||
if (assignee === (task.assignee_id ?? '')) return;
|
||||
await save({ assignee_id: assignee });
|
||||
}
|
||||
|
||||
function saveDescription() {
|
||||
@@ -316,8 +414,42 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if task.recurrence_text}
|
||||
<p class="repeats">Gentages {task.recurrence_text}.</p>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="project">Projekt</label>
|
||||
<select id="project" bind:value={projectId} onchange={saveProject}>
|
||||
{#each app.projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="repeat">Gentages</label>
|
||||
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
||||
{#each REPEATS as option (option.rule)}
|
||||
<option value={option.rule}>{option.label}</option>
|
||||
{/each}
|
||||
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
|
||||
showing it rather than snapping to the nearest preset is the
|
||||
difference between a menu and a menu that quietly edits. -->
|
||||
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
|
||||
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if members.length > 1}
|
||||
<div class="field">
|
||||
<label for="assignee">Ansvarlig</label>
|
||||
<select id="assignee" bind:value={assignee} onchange={saveAssignee}>
|
||||
<option value="">Ingen</option>
|
||||
{#each members as member (member.user_id)}
|
||||
<option value={member.user_id}>{member.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
@@ -567,12 +699,6 @@
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.repeats {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+187
-13
@@ -29,6 +29,16 @@ class AppState {
|
||||
*/
|
||||
detailId = $state(null);
|
||||
|
||||
/**
|
||||
* Bumped whenever a label changes anywhere.
|
||||
*
|
||||
* A counter rather than the labels themselves: the sidebar wants each label's
|
||||
* task count, which no single event carries and which changes whenever a task
|
||||
* gains or loses one. Whoever is showing labels re-reads them; that is one
|
||||
* small request against modelling a count in two places.
|
||||
*/
|
||||
labelsChanged = $state(0);
|
||||
|
||||
#socket = null;
|
||||
#reconnectDelay = 1000;
|
||||
|
||||
@@ -110,6 +120,25 @@ class AppState {
|
||||
case 'task.deleted':
|
||||
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
|
||||
break;
|
||||
|
||||
// Projects and labels belong to a person rather than to a project, so
|
||||
// they arrive on the user's own channel — a project that has just been
|
||||
// created has no subscribers, because nobody was watching a thing that
|
||||
// did not exist a moment ago.
|
||||
case 'project.created':
|
||||
case 'project.updated':
|
||||
this.upsertProject(task);
|
||||
break;
|
||||
case 'project.deleted':
|
||||
this.projects = this.projects.filter((p) => p.id !== task?.id);
|
||||
break;
|
||||
|
||||
// The payload is not always the label — a rename says only that
|
||||
// something changed — and the sidebar wants counts anyway, which no
|
||||
// single event carries. Asking is cheaper than modelling it.
|
||||
case 'label.changed':
|
||||
this.labelsChanged++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +165,13 @@ class AppState {
|
||||
* copy is not worth a moment's thought next to the class of bug it removes.
|
||||
*/
|
||||
replace(id, next) {
|
||||
// A task can invent a label just by mentioning one — "@regnskab" in quick
|
||||
// add creates it — and no label event is sent for that, because it happened
|
||||
// inside a task write. Comparing the two lists is what keeps the sidebar
|
||||
// honest without re-reading the labels on every completed checkbox.
|
||||
const before = this.tasks.find((t) => t.id === id)?.labels ?? [];
|
||||
if ((next?.labels ?? []).join() !== before.join()) this.labelsChanged++;
|
||||
|
||||
this.tasks = this.tasks.map((t) => (t.id === id ? next : t));
|
||||
}
|
||||
|
||||
@@ -151,14 +187,33 @@ class AppState {
|
||||
*/
|
||||
upsert(task) {
|
||||
if (!task?.id) return;
|
||||
if (this.tasks.some((t) => t.id === task.id)) this.replace(task.id, task);
|
||||
else this.tasks = [...this.tasks, task];
|
||||
if (this.tasks.some((t) => t.id === task.id)) {
|
||||
this.replace(task.id, task);
|
||||
return;
|
||||
}
|
||||
if (task.labels?.length) this.labelsChanged++;
|
||||
this.tasks = [...this.tasks, task];
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return this.tasks.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a project, or replaces it if the list already has it.
|
||||
*
|
||||
* The same guard the tasks need, and for the same reason: a project arrives
|
||||
* twice, once as the response to the request that created it and once over
|
||||
* the socket. Appending both puts two rows with one id into a keyed `{#each}`,
|
||||
* which throws and stops the sidebar rendering.
|
||||
*/
|
||||
upsertProject(project) {
|
||||
if (!project?.id) return;
|
||||
this.projects = this.projects.some((p) => p.id === project.id)
|
||||
? this.projects.map((p) => (p.id === project.id ? project : p))
|
||||
: [...this.projects, project];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks a task off. The row is struck through and gone before the request
|
||||
* leaves — which is the single most-repeated action in the app, and the one
|
||||
@@ -233,7 +288,7 @@ class AppState {
|
||||
async createProject(name) {
|
||||
try {
|
||||
const project = await api.createProject({ name });
|
||||
this.projects = [...this.projects, project];
|
||||
this.upsertProject(project);
|
||||
return project;
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
@@ -246,6 +301,29 @@ class AppState {
|
||||
this.projects = projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the projects in the given order.
|
||||
*
|
||||
* The whole list rather than one moved item: it is a handful of rows, and
|
||||
* sending the order you want cannot land in the half-applied state a sequence
|
||||
* of individual moves can. `sort_order` is set here as well as on the server,
|
||||
* so the sidebar settles immediately rather than after a round trip.
|
||||
*/
|
||||
async reorderProjects(ids) {
|
||||
const previous = this.projects;
|
||||
const rank = new Map(ids.map((id, i) => [id, i]));
|
||||
|
||||
this.projects = this.projects.map((p) =>
|
||||
rank.has(p.id) ? { ...p, sort_order: rank.get(p.id) } : p
|
||||
);
|
||||
try {
|
||||
await api.reorderProjects(ids);
|
||||
} catch (e) {
|
||||
this.projects = previous;
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- toasts -------------------------------------------------------------------
|
||||
|
||||
toast(message) {
|
||||
@@ -263,21 +341,117 @@ class AppState {
|
||||
|
||||
export const app = new AppState();
|
||||
|
||||
/** Theme, kept in localStorage and applied to the document element. */
|
||||
export const theme = {
|
||||
get current() {
|
||||
if (typeof document === 'undefined') return 'dark';
|
||||
return document.documentElement.dataset.theme ?? 'dark';
|
||||
},
|
||||
/**
|
||||
* How wide the sidebar is, and whether it is showing at all.
|
||||
*
|
||||
* Kept in localStorage rather than on the account: it is a property of the
|
||||
* screen you are sitting at, not of you. The same person on a laptop and on a
|
||||
* wide monitor wants different answers, and syncing it would make one of those
|
||||
* two wrong every time they switch.
|
||||
*/
|
||||
const SIDEBAR_MIN = 200;
|
||||
const SIDEBAR_MAX = 480;
|
||||
const SIDEBAR_DEFAULT = 268;
|
||||
|
||||
function storedNumber(key, fallback) {
|
||||
if (typeof localStorage === 'undefined') return fallback;
|
||||
const raw = Number(localStorage.getItem(key));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
||||
}
|
||||
|
||||
class SidebarLayout {
|
||||
width = $state(storedNumber('verdande:sidebar-width', SIDEBAR_DEFAULT));
|
||||
collapsed = $state(
|
||||
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:sidebar-collapsed') === '1'
|
||||
);
|
||||
|
||||
setWidth(px) {
|
||||
// Clamped here rather than in the drag handler, so every caller gets the
|
||||
// same rule and a stored value from an older build cannot make the
|
||||
// sidebar unusably narrow.
|
||||
this.width = Math.round(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, px)));
|
||||
this.#save('verdande:sidebar-width', String(this.width));
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const next = this.current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
this.collapsed = !this.collapsed;
|
||||
this.#save('verdande:sidebar-collapsed', this.collapsed ? '1' : '0');
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.setWidth(SIDEBAR_DEFAULT);
|
||||
}
|
||||
|
||||
#save(key, value) {
|
||||
try {
|
||||
localStorage.setItem('verdande:theme', next);
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const sidebar = new SidebarLayout();
|
||||
|
||||
/**
|
||||
* The themes on offer.
|
||||
*
|
||||
* `dark` is the tokens on bare :root; the rest are [data-theme] blocks in
|
||||
* app.css. The list lives here so the picker and the stylesheet cannot drift —
|
||||
* an entry with no CSS behind it renders as the default and looks like a bug in
|
||||
* the picker rather than a missing block.
|
||||
*/
|
||||
export const THEMES = [
|
||||
{ id: 'dark', name: 'Nordlys', note: 'Mørk med grøn accent.', dark: true },
|
||||
{ id: 'dusk', name: 'Skumring', note: 'Mørk og varm, med rav.', dark: true },
|
||||
{ id: 'light', name: 'Dagslys', note: 'Lys og køligt neutral.', dark: false },
|
||||
{ id: 'paper', name: 'Papir', note: 'Lys og varm, som papir.', dark: false },
|
||||
{ id: 'contrast', name: 'Kontrast', note: 'Sort på hvidt, til skarpt lys.', dark: false }
|
||||
];
|
||||
|
||||
/** Theme, kept in localStorage and applied to the document element. */
|
||||
class Theme {
|
||||
/**
|
||||
* Mirrors the attribute on <html>, which app.html sets before first paint —
|
||||
* that is the only way to avoid a white flash on load, and it means the
|
||||
* document, not this object, is where the truth lives.
|
||||
*/
|
||||
current = $state(
|
||||
typeof document === 'undefined' ? 'dark' : (document.documentElement.dataset.theme ?? 'dark')
|
||||
);
|
||||
|
||||
set(id) {
|
||||
if (!THEMES.some((t) => t.id === id)) return;
|
||||
this.current = id;
|
||||
document.documentElement.dataset.theme = id;
|
||||
|
||||
// The browser paints its own chrome from this — the address bar on a
|
||||
// phone, the frame of an installed PWA. Left alone it stays the colour of
|
||||
// whichever theme shipped in the markup.
|
||||
const ground = getComputedStyle(document.documentElement).getPropertyValue('--ground').trim();
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', ground);
|
||||
|
||||
try {
|
||||
localStorage.setItem('verdande:theme', id);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The topbar button, which flips between light and dark rather than walking
|
||||
* all five. Somebody who has chosen Skumring wants the light one to be Papir,
|
||||
* not to be marched back to the default — so it moves to the other side of
|
||||
* the list and stays warm or stays cool.
|
||||
*/
|
||||
toggle() {
|
||||
const now = THEMES.find((t) => t.id === this.current) ?? THEMES[0];
|
||||
const opposite = THEMES.filter((t) => t.dark !== now.dark);
|
||||
const index = THEMES.filter((t) => t.dark === now.dark).indexOf(now);
|
||||
this.set((opposite[index] ?? opposite[0]).id);
|
||||
}
|
||||
}
|
||||
|
||||
export const theme = new Theme();
|
||||
|
||||
export { ApiError, humanMessage };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { app, theme } from '$lib/stores.svelte.js';
|
||||
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
@@ -66,11 +66,20 @@
|
||||
{:else if !app.user}
|
||||
<SignIn />
|
||||
{:else}
|
||||
<div class="shell" class:sidebar-open={sidebarOpen}>
|
||||
<div
|
||||
class="shell"
|
||||
class:sidebar-open={sidebarOpen}
|
||||
class:sidebar-collapsed={sidebar.collapsed}
|
||||
style="--sidebar-width: {sidebar.width}px"
|
||||
>
|
||||
<Sidebar onnavigate={() => (sidebarOpen = false)} />
|
||||
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<!-- Two controls, not one: on a phone the sidebar is a drawer that
|
||||
opens over the page, on a desktop it is a column that folds away.
|
||||
Same words, different actions, so each is shown only where it
|
||||
applies. -->
|
||||
<button
|
||||
class="menu"
|
||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||
@@ -82,6 +91,19 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="fold"
|
||||
onclick={() => sidebar.toggle()}
|
||||
aria-label={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
|
||||
aria-pressed={sidebar.collapsed}
|
||||
title={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M9 4v16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="search" onclick={() => (paletteOpen = true)}>
|
||||
<span>Søg</span>
|
||||
<kbd>⌘K</kbd>
|
||||
@@ -173,7 +195,32 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.fold {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
transition: color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.fold:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Folded away rather than merely narrow: width 0 with the border gone, so
|
||||
nothing of it is left to catch the eye. */
|
||||
.shell.sidebar-collapsed :global(.sidebar) {
|
||||
width: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu svg,
|
||||
.fold svg,
|
||||
.theme svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -277,6 +324,11 @@
|
||||
display: grid;
|
||||
}
|
||||
|
||||
/* The drawer is the whole answer here; folding a drawer means nothing. */
|
||||
.fold {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrim {
|
||||
display: block;
|
||||
}
|
||||
@@ -292,5 +344,15 @@
|
||||
.shell.sidebar-open :global(.sidebar) {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* A collapsed desktop sidebar must not follow you to a phone, where the
|
||||
drawer would then open to nothing. */
|
||||
.shell.sidebar-collapsed :global(.sidebar) {
|
||||
width: var(--sidebar-width);
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
padding-right: var(--s3);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
/** Konto: who you are, your password, and the second factor. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
||||
|
||||
// --- profile ---------------------------------------------------------------
|
||||
|
||||
@@ -204,6 +204,39 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Udseende</h2>
|
||||
<p class="hint">
|
||||
Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved —
|
||||
en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="themes">
|
||||
{#each THEMES as option (option.id)}
|
||||
<button
|
||||
class="theme-card"
|
||||
class:chosen={theme.current === option.id}
|
||||
onclick={() => theme.set(option.id)}
|
||||
aria-pressed={theme.current === option.id}
|
||||
>
|
||||
<!-- data-theme goes on the swatch and not on the card: it redefines
|
||||
--ink too, and a card wearing a dark theme's ink on a light page
|
||||
has a name nobody can read. The swatch shows the colours; the
|
||||
label stays in the page's. -->
|
||||
<span class="swatch" data-theme={option.id} aria-hidden="true">
|
||||
<span class="swatch-line"></span>
|
||||
<span class="swatch-line short"></span>
|
||||
<span class="swatch-dot"></span>
|
||||
</span>
|
||||
<span class="theme-name">{option.name}</span>
|
||||
<span class="theme-note">{option.note}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Adgangskode</h2>
|
||||
@@ -353,6 +386,82 @@
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.themes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--s1);
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.theme-card.chosen {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
/* Painted in the card's own theme rather than the page's — that is the whole
|
||||
point of putting data-theme on the button. */
|
||||
.swatch {
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--line);
|
||||
padding: var(--s2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.swatch-line {
|
||||
height: 5px;
|
||||
width: 70%;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--ink-muted);
|
||||
}
|
||||
|
||||
.swatch-line.short {
|
||||
width: 45%;
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
.swatch-dot {
|
||||
position: absolute;
|
||||
right: var(--s2);
|
||||
top: var(--s2);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.theme-note {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.secret {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
|
||||
@@ -101,6 +101,36 @@
|
||||
}
|
||||
|
||||
let exportProject = $state('');
|
||||
|
||||
// --- the trash -------------------------------------------------------------
|
||||
|
||||
let trashed = $state([]);
|
||||
let loadingTrash = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.listTrashedProjects()
|
||||
.then((r) => (trashed = r.projects))
|
||||
.catch((e) => app.toast(humanMessage(e)))
|
||||
.finally(() => (loadingTrash = false));
|
||||
});
|
||||
|
||||
async function restore(project) {
|
||||
const previous = trashed;
|
||||
trashed = trashed.filter((p) => p.id !== project.id);
|
||||
try {
|
||||
await api.restoreProject(project.id);
|
||||
await app.refreshProjects();
|
||||
} catch (e) {
|
||||
trashed = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole days left, rounded down — "0 dage tilbage" is the truthful last day. */
|
||||
function daysLeft(iso) {
|
||||
return Math.max(0, Math.floor((new Date(iso) - Date.now()) / 86400000));
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
@@ -146,6 +176,38 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Papirkurv</h2>
|
||||
<p class="hint">
|
||||
Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som
|
||||
de var — bortset fra opgaver, du havde slettet hver for sig først; dem var
|
||||
der en grund til.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loadingTrash}
|
||||
<p class="empty">…</p>
|
||||
{:else if trashed.length === 0}
|
||||
<p class="empty">Papirkurven er tom.</p>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each trashed as project (project.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="title">{project.name}</span>
|
||||
<span class="meta">
|
||||
{project.task_count} opgave{project.task_count === 1 ? '' : 'r'} ·
|
||||
{daysLeft(project.purge_after)} dage tilbage
|
||||
</span>
|
||||
</div>
|
||||
<button class="use" onclick={() => restore(project)}>Hent tilbage</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Eksport</h2>
|
||||
|
||||
@@ -96,10 +96,18 @@
|
||||
|
||||
let feedURL = $state('');
|
||||
let mailAddress = $state('');
|
||||
/** Null until loaded, so the warning does not flash before the answer is in. */
|
||||
let mailConfigured = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
||||
api.getMailAddress().then((r) => (mailAddress = r.address)).catch(() => {});
|
||||
api
|
||||
.getMailAddress()
|
||||
.then((r) => {
|
||||
mailAddress = r.address;
|
||||
mailConfigured = r.configured;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
async function rotateFeed() {
|
||||
@@ -114,7 +122,9 @@
|
||||
async function rotateMail() {
|
||||
if (!confirm('Ny adresse? Mail til den gamle bliver afvist.')) return;
|
||||
try {
|
||||
mailAddress = (await api.rotateMailAddress()).address;
|
||||
const r = await api.rotateMailAddress();
|
||||
mailAddress = r.address;
|
||||
mailConfigured = r.configured;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
@@ -218,6 +228,16 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if mailConfigured === false}
|
||||
<p class="callback" data-tone="bad">
|
||||
Der er ingen mailserver sat op på denne server, så adressen herunder kan
|
||||
ikke modtage noget endnu. Sæt <span class="mono">VERDANDE_SMTP_HOST</span> og
|
||||
de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til
|
||||
<span class="mono">todo+*</span> videre til
|
||||
<span class="mono">/inbound/mail</span>.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="mail">Din adresse</label>
|
||||
<input id="mail" class="mono" value={mailAddress} readonly />
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
/** The plaintext of the token just minted. Held only in this variable. */
|
||||
let fresh = $state(null);
|
||||
let copied = $state(false);
|
||||
let connectorCopied = $state(false);
|
||||
|
||||
/** What Claude's connector dialog needs, which is a URL and nothing else. */
|
||||
let connectorURL = $derived(fresh ? `${location.origin}/mcp?key=${fresh.token}` : '');
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
@@ -59,6 +63,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function copyConnector() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(connectorURL);
|
||||
connectorCopied = true;
|
||||
} catch {
|
||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(token) {
|
||||
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
|
||||
return;
|
||||
@@ -100,6 +113,22 @@
|
||||
{#if copied}<span class="saved">Kopieret.</span>{/if}
|
||||
<button class="secondary" onclick={() => (fresh = null)}>Færdig</button>
|
||||
</div>
|
||||
|
||||
<!-- Shown here and nowhere else, because it contains the token, and the
|
||||
token exists only in this moment. Claude's connector dialog takes a
|
||||
URL and nothing else — there is no field for a header — so this is
|
||||
the form it has to be in. -->
|
||||
<div class="also">
|
||||
<p class="hint">
|
||||
Skal du bruge den som <a href="/indstillinger/integrationer">MCP-forbindelse</a>
|
||||
i Claude? Så er det denne adresse, dialogen skal have:
|
||||
</p>
|
||||
<p class="mono value">{connectorURL}</p>
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={copyConnector}>Kopiér adressen</button>
|
||||
{#if connectorCopied}<span class="saved">Kopieret.</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -194,6 +223,18 @@
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.also {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.also a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.what {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
/** One project: its sections, its tasks, and who it is shared with. */
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
@@ -65,6 +66,96 @@
|
||||
let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id));
|
||||
let unsectioned = $derived(open.filter((t) => !t.section_id));
|
||||
|
||||
let editing = $state(false);
|
||||
|
||||
/** Renames on blur. A no-op when nothing changed, so tabbing through is free. */
|
||||
async function rename(input) {
|
||||
const name = input.value.trim();
|
||||
editing = false;
|
||||
if (!name || name === project.name) return;
|
||||
|
||||
try {
|
||||
project = await api.updateProject(project.id, { name });
|
||||
// The sidebar reads its own copy, so it has to be told.
|
||||
await app.refreshProjects();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const days = 30;
|
||||
const confirmed = confirm(
|
||||
`Slet "${project.name}" med alle dets opgaver?\n\n` +
|
||||
`Det havner i papirkurven og kan hentes tilbage under ` +
|
||||
`Indstillinger → Data og skabeloner i ${days} dage.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await api.deleteProject(project.id);
|
||||
await app.refreshProjects();
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- sections -------------------------------------------------------------------
|
||||
|
||||
let addingSection = $state(false);
|
||||
let renamingSection = $state(null);
|
||||
let sectionName = $state('');
|
||||
|
||||
async function addSection(event) {
|
||||
event.preventDefault();
|
||||
const name = sectionName.trim();
|
||||
if (!name) return;
|
||||
addingSection = false;
|
||||
sectionName = '';
|
||||
try {
|
||||
sections = [...sections, await api.createSection(project.id, name)];
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSection(event, section) {
|
||||
event.preventDefault();
|
||||
const name = sectionName.trim();
|
||||
renamingSection = null;
|
||||
if (!name || name === section.name) return;
|
||||
|
||||
const previous = sections;
|
||||
sections = sections.map((s) => (s.id === section.id ? { ...s, name } : s));
|
||||
try {
|
||||
await api.updateSection(section.id, { name });
|
||||
} catch (e) {
|
||||
sections = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSection(section) {
|
||||
const count = open.filter((t) => t.section_id === section.id).length;
|
||||
const warning = count
|
||||
? `Slet sektionen "${section.name}"? De ${count} opgaver bliver, men mister deres sektion.`
|
||||
: `Slet sektionen "${section.name}"?`;
|
||||
if (!confirm(warning)) return;
|
||||
|
||||
const previous = sections;
|
||||
sections = sections.filter((s) => s.id !== section.id);
|
||||
try {
|
||||
await api.deleteSection(section.id);
|
||||
// The tasks are not deleted with it; they come back unsectioned, and the
|
||||
// list has to be re-read to show them in the right place.
|
||||
await app.loadTasks({ project_id: project.id });
|
||||
} catch (e) {
|
||||
sections = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function invite(event) {
|
||||
event.preventDefault();
|
||||
inviteError = '';
|
||||
@@ -87,7 +178,38 @@
|
||||
<div class="view">
|
||||
{#if project}
|
||||
<header>
|
||||
<h1>{project.name}</h1>
|
||||
<!-- A heading first, a field second. Swapping the h1 out for an input
|
||||
outright cost the page its heading — which the sidebar smoke test
|
||||
caught, and which is a real loss: every view here has one, and it is
|
||||
what a screen reader announces on arrival. So the heading stays and
|
||||
becomes editable on click. -->
|
||||
{#if editing}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
class="title"
|
||||
autofocus
|
||||
value={project.name}
|
||||
aria-label="Projektets navn"
|
||||
onblur={(e) => rename(e.currentTarget)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') {
|
||||
e.currentTarget.value = project.name;
|
||||
editing = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<h1 class:renameable={isOwner}>
|
||||
{#if isOwner}
|
||||
<button onclick={() => (editing = true)} title="Klik for at omdøbe">
|
||||
{project.name}
|
||||
</button>
|
||||
{:else}
|
||||
{project.name}
|
||||
{/if}
|
||||
</h1>
|
||||
{/if}
|
||||
<div class="views" role="group" aria-label="Visning">
|
||||
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
||||
<button
|
||||
@@ -103,6 +225,14 @@
|
||||
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button class="remove" onclick={remove} aria-label="Slet projektet">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if showShare}
|
||||
@@ -174,7 +304,34 @@
|
||||
{#each sections as section (section.id)}
|
||||
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
||||
<section>
|
||||
<h2>{section.name}</h2>
|
||||
<div class="section-head">
|
||||
{#if renamingSection === section.id}
|
||||
<form onsubmit={(e) => renameSection(e, section)}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={sectionName}
|
||||
autofocus
|
||||
aria-label="Sektionens navn"
|
||||
onblur={() => (renamingSection = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (renamingSection = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<h2>{section.name}</h2>
|
||||
{#if canEdit}
|
||||
<button
|
||||
class="section-action"
|
||||
onclick={() => {
|
||||
renamingSection = section.id;
|
||||
sectionName = section.name;
|
||||
}}>Omdøb</button
|
||||
>
|
||||
<button class="section-action remove" onclick={() => removeSection(section)}>
|
||||
Slet
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<TaskList {tasks} projectId={project.id} sectionId={section.id} {canEdit} />
|
||||
{#if !tasks.length}
|
||||
<p class="empty">Tom</p>
|
||||
@@ -182,7 +339,35 @@
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if !open.length}
|
||||
{#if canEdit}
|
||||
<section class="add-section">
|
||||
{#if addingSection}
|
||||
<form onsubmit={addSection}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={sectionName}
|
||||
autofocus
|
||||
placeholder="Sektionens navn"
|
||||
aria-label="Ny sektion"
|
||||
onblur={() => !sectionName.trim() && (addingSection = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingSection = false)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add"
|
||||
onclick={() => {
|
||||
addingSection = true;
|
||||
sectionName = '';
|
||||
}}
|
||||
>
|
||||
+ Tilføj sektion
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if !open.length && !sections.length}
|
||||
<p class="clear">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
Ingenting her endnu.
|
||||
@@ -225,6 +410,72 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The heading's button carries no button-ness: it is the heading, and the only
|
||||
hint that it does anything is the underline on hover. */
|
||||
h1.renameable button {
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
h1.renameable button:hover {
|
||||
border-bottom-color: var(--line-strong);
|
||||
}
|
||||
|
||||
/* Matches the heading it stands in for, so the swap does not move the page. */
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.015em;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--s1) var(--s2);
|
||||
margin-left: calc(var(--s2) * -1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.title:focus {
|
||||
background: var(--surface);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.remove {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
transition:
|
||||
color var(--fast) var(--ease),
|
||||
background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.views {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
@@ -407,6 +658,79 @@
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Hidden until the section is hovered or something in it has focus: a heading
|
||||
with two buttons permanently beside it stops reading as a heading. */
|
||||
.section-action {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
padding: 0 var(--s1);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.section-head:hover .section-action,
|
||||
.section-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section-action:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.section-action.remove:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.section-head form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-head input,
|
||||
.add-section input {
|
||||
width: 100%;
|
||||
padding: var(--s1) var(--s2);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.section-head input:focus,
|
||||
.add-section input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.add-section {
|
||||
margin-top: var(--s5);
|
||||
}
|
||||
|
||||
.add-section .add {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--s2);
|
||||
transition: color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.add-section .add:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.readonly {
|
||||
margin: 0;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// vite.config.js
|
||||
import { sveltekit } from "file:///Users/kw/Documents/Code/ToDoApp/web/node_modules/@sveltejs/kit/src/exports/vite/index.js";
|
||||
var vite_config_default = {
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
// In development the app runs on 5173 and the API on 8080. Proxying rather
|
||||
// than pointing fetch at another origin keeps the session cookie
|
||||
// first-party, which is what production looks like — otherwise every
|
||||
// cookie and CSRF behaviour would differ between dev and the real thing.
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: false,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWJcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9rdy9Eb2N1bWVudHMvQ29kZS9Ub0RvQXBwL3dlYi92aXRlLmNvbmZpZy5qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWIvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHBsdWdpbnM6IFtzdmVsdGVraXQoKV0sXG5cdHNlcnZlcjoge1xuXHRcdHBvcnQ6IDUxNzMsXG5cdFx0Ly8gSW4gZGV2ZWxvcG1lbnQgdGhlIGFwcCBydW5zIG9uIDUxNzMgYW5kIHRoZSBBUEkgb24gODA4MC4gUHJveHlpbmcgcmF0aGVyXG5cdFx0Ly8gdGhhbiBwb2ludGluZyBmZXRjaCBhdCBhbm90aGVyIG9yaWdpbiBrZWVwcyB0aGUgc2Vzc2lvbiBjb29raWVcblx0XHQvLyBmaXJzdC1wYXJ0eSwgd2hpY2ggaXMgd2hhdCBwcm9kdWN0aW9uIGxvb2tzIGxpa2UgXHUyMDE0IG90aGVyd2lzZSBldmVyeVxuXHRcdC8vIGNvb2tpZSBhbmQgQ1NSRiBiZWhhdmlvdXIgd291bGQgZGlmZmVyIGJldHdlZW4gZGV2IGFuZCB0aGUgcmVhbCB0aGluZy5cblx0XHRwcm94eToge1xuXHRcdFx0Jy9hcGknOiB7XG5cdFx0XHRcdHRhcmdldDogJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MCcsXG5cdFx0XHRcdGNoYW5nZU9yaWdpbjogZmFsc2UsXG5cdFx0XHRcdHdzOiB0cnVlXG5cdFx0XHR9XG5cdFx0fVxuXHR9XG59O1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE4UixTQUFTLGlCQUFpQjtBQUV4VCxJQUFPLHNCQUFRO0FBQUEsRUFDZCxTQUFTLENBQUMsVUFBVSxDQUFDO0FBQUEsRUFDckIsUUFBUTtBQUFBLElBQ1AsTUFBTTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUEsSUFLTixPQUFPO0FBQUEsTUFDTixRQUFRO0FBQUEsUUFDUCxRQUFRO0FBQUEsUUFDUixjQUFjO0FBQUEsUUFDZCxJQUFJO0FBQUEsTUFDTDtBQUFBLElBQ0Q7QUFBQSxFQUNEO0FBQ0Q7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||
Reference in New Issue
Block a user