Merge pull request #97 from kristianwind/porten-daekker-eksporten
fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene
This commit is contained in:
@@ -97,7 +97,13 @@ jobs:
|
|||||||
# -race is the whole point of running tests in CI for a server with a
|
# -race is the whole point of running tests in CI for a server with a
|
||||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test -race -count=1 ./...
|
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||||
|
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||||
|
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||||
|
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||||
|
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||||
|
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||||
|
run: go test -race -count=1 -timeout 20m ./...
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: CGO_ENABLED=0 go build ./...
|
run: CGO_ENABLED=0 go build ./...
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ jobs:
|
|||||||
# -race is the whole point of running tests in CI for a server with a
|
# -race is the whole point of running tests in CI for a server with a
|
||||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test -race -count=1 ./...
|
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||||
|
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||||
|
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||||
|
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||||
|
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||||
|
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||||
|
run: go test -race -count=1 -timeout 20m ./...
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: CGO_ENABLED=0 go build ./...
|
run: CGO_ENABLED=0 go build ./...
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ wood. Verdande is what is becoming; urd is what has become and was kept.
|
|||||||
Not a fork, because the two share everything that is hard — accounts, sharing,
|
Not a fork, because the two share everything that is hard — accounts, sharing,
|
||||||
passkeys, the realtime socket, search, backups, migrations — and notes are about a
|
passkeys, the realtime socket, search, backups, migrations — and notes are about a
|
||||||
twentieth of the Go here. A test walks both routers and fails in either direction,
|
twentieth of the Go here. A test walks both routers and fails in either direction,
|
||||||
so "the task routes are not there" is measured rather than promised. See
|
so "the task routes are not there" is measured rather than promised — 63 routes and
|
||||||
|
five of nine MCP tools. See
|
||||||
[Two editions](docs/editions.md).
|
[Two editions](docs/editions.md).
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|||||||
+35
-6
@@ -6,7 +6,7 @@ One program, built once, with two faces.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| What it is | Tasks, projects and notes | Notes |
|
| What it is | Tasks, projects and notes | Notes |
|
||||||
| `VERDANDE_EDITION` | `full` (the default) | `notes` |
|
| `VERDANDE_EDITION` | `full` (the default) | `notes` |
|
||||||
| Routes served | 200 | 171 |
|
| Routes served | 200 | 137 |
|
||||||
| Mark | ᚹ | ᚢ |
|
| Mark | ᚹ | ᚢ |
|
||||||
|
|
||||||
Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
|
Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
|
||||||
@@ -24,7 +24,7 @@ needed applying twice, by hand, forever.
|
|||||||
|
|
||||||
So the split is a configuration value read in one place, and the thing that keeps
|
So the split is a configuration value read in one place, and the thing that keeps
|
||||||
it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes`
|
it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes`
|
||||||
walks both routers, compares them against a written-down list of the 29 withheld
|
walks both routers, compares them against a written-down list of the 63 withheld
|
||||||
routes, and fails in **both** directions — a route that appears in the notes
|
routes, and fails in **both** directions — a route that appears in the notes
|
||||||
edition and a route that stops being withheld are both a failure.
|
edition and a route that stops being withheld are both a failure.
|
||||||
|
|
||||||
@@ -39,16 +39,45 @@ with no error anywhere and no way to tell from the outside.
|
|||||||
|
|
||||||
## What urd does not have
|
## What urd does not have
|
||||||
|
|
||||||
The 29 withheld routes are tasks, sub-tasks, labels, saved filters, reminders, the
|
The 63 withheld routes:
|
||||||
upcoming and delegated views, and the task half of search and export. Everything
|
|
||||||
else is the same program:
|
| | what |
|
||||||
|
|---|---|
|
||||||
|
| 29 | tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views |
|
||||||
|
| 8 | the CalDAV server — every route in it reads or writes a VTODO |
|
||||||
|
| 1 | `/.well-known/caldav`, which is a claim that a CalDAV server is here |
|
||||||
|
| 1 | the ICS calendar feed, plus 2 for the token it is reached with |
|
||||||
|
| 4 | the task exports: `tasks.ics`, `projects.zip`, and a project's `.csv` and `.ics` |
|
||||||
|
| 2 | the Todoist and CSV imports, which create tasks |
|
||||||
|
| 4 | project templates — a template is a project plus sections plus tasks |
|
||||||
|
| 5 | sections, which group tasks: `section_id` is a column on `tasks` and not on `notes` |
|
||||||
|
| 6 | the AI task features: split, tidy the inbox, and the day's plan |
|
||||||
|
| 1 | the inbox hook, which pushes a line of text in as a task |
|
||||||
|
|
||||||
|
Plus five of the nine MCP tools — `search_tasks`, `create_task`, `update_task`,
|
||||||
|
`complete_task` and `add_comment`. The endpoint is still there; the tool list is
|
||||||
|
shorter. A tool a model can call is a surface, and one that answers "there are no
|
||||||
|
tasks" is worse than one that does not exist: the model tries it, gets an empty
|
||||||
|
answer, and tells the person there is nothing to do.
|
||||||
|
|
||||||
|
Everything else is the same program:
|
||||||
|
|
||||||
- accounts, invitations, passkeys and two-factor
|
- accounts, invitations, passkeys and two-factor
|
||||||
- projects — which in urd are what a note belongs to
|
- projects — which in urd are what a note belongs to
|
||||||
- per-note sharing with other people, independent of projects
|
- per-note sharing with other people, independent of projects
|
||||||
- search, over notes
|
- search, over notes
|
||||||
- export, of notes
|
- export, of notes
|
||||||
- the mail, calendar and MCP surfaces, minus their task routes
|
- the mail, calendar and MCP surfaces, minus their task routes and task tools
|
||||||
|
- AI settings, `ask`, and the note actions
|
||||||
|
|
||||||
|
!!! warning "Still being decided"
|
||||||
|
Four connectors are reachable in urd today and have not been settled either
|
||||||
|
way, because each has a plausible notes reading and withholding them would be
|
||||||
|
deciding what this product is rather than closing a hole: the mail-in address,
|
||||||
|
IMAP mailboxes, Gmail, and Google Calendar. They currently file what they
|
||||||
|
receive as **tasks**, so in urd they connect and then have nowhere to put
|
||||||
|
anything. The same applies to one handler rather than a route:
|
||||||
|
`POST /api/v1/ai/notes/{noteID}/actions/apply` creates tasks from a note.
|
||||||
|
|
||||||
## The name
|
## The name
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -36,28 +37,69 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
|||||||
// Every route the full edition has and the notes edition does not. Written as
|
// Every route the full edition has and the notes edition does not. Written as
|
||||||
// the whole list rather than a prefix rule: a prefix is a description of what
|
// the whole list rather than a prefix rule: a prefix is a description of what
|
||||||
// somebody meant, and this has to be a description of what is there.
|
// somebody meant, and this has to be a description of what is there.
|
||||||
|
//
|
||||||
|
// And note what this list could NOT catch, because it is the shape of mistake
|
||||||
|
// that outlived the first version of this test. The list went green at 29
|
||||||
|
// routes for two merges while CalDAV, the ICS feed, the inbox hook, four task
|
||||||
|
// exports, both task imports, the project templates, the sections and six AI
|
||||||
|
// task endpoints were all still served by the notes edition — because the test
|
||||||
|
// compares the router against THIS LIST, and the list was written from the
|
||||||
|
// routes somebody had thought of. It is a drift check, and it cannot be an
|
||||||
|
// is-it-complete check.
|
||||||
|
//
|
||||||
|
// So when a route is added that one edition should not have, the question to
|
||||||
|
// ask is not "does the walk still pass" but "can this endpoint do anything at
|
||||||
|
// all without the half it is being withheld from". Three of the ones above were
|
||||||
|
// settled by reading rather than by the name: a template turned out to create
|
||||||
|
// tasks (CreateProjectFromTemplate), `section_id` turned out to exist on
|
||||||
|
// `tasks` and not on `notes`, and add_comment turned out to hang off a task.
|
||||||
withheld := map[string]bool{}
|
withheld := map[string]bool{}
|
||||||
for _, r := range []string{
|
for _, r := range []string{
|
||||||
"DELETE /api/v1/filters/{filterID}",
|
"DELETE /api/v1/filters/{filterID}",
|
||||||
"DELETE /api/v1/labels/{labelID}",
|
"DELETE /api/v1/labels/{labelID}",
|
||||||
"DELETE /api/v1/reminders/{reminderID}",
|
"DELETE /api/v1/reminders/{reminderID}",
|
||||||
|
"DELETE /api/v1/sections/{sectionID}",
|
||||||
"DELETE /api/v1/tasks/{taskID}",
|
"DELETE /api/v1/tasks/{taskID}",
|
||||||
|
"DELETE /api/v1/templates/{templateID}",
|
||||||
|
"DELETE /caldav/{userID}/{projectID}/{taskFile}",
|
||||||
|
"GET /.well-known/caldav",
|
||||||
|
"GET /api/v1/ai/plan",
|
||||||
"GET /api/v1/delegated",
|
"GET /api/v1/delegated",
|
||||||
|
"GET /api/v1/export/projects.zip",
|
||||||
|
"GET /api/v1/export/projects/{projectID}.csv",
|
||||||
|
"GET /api/v1/export/projects/{projectID}.ics",
|
||||||
|
"GET /api/v1/export/tasks.ics",
|
||||||
|
"GET /api/v1/feed",
|
||||||
"GET /api/v1/filters",
|
"GET /api/v1/filters",
|
||||||
"GET /api/v1/filters/preview",
|
"GET /api/v1/filters/preview",
|
||||||
"GET /api/v1/filters/{filterID}/tasks",
|
"GET /api/v1/filters/{filterID}/tasks",
|
||||||
"GET /api/v1/labels",
|
"GET /api/v1/labels",
|
||||||
|
"GET /api/v1/projects/{projectID}/sections",
|
||||||
"GET /api/v1/tasks",
|
"GET /api/v1/tasks",
|
||||||
"GET /api/v1/tasks/quick-add/preview",
|
"GET /api/v1/tasks/quick-add/preview",
|
||||||
"GET /api/v1/tasks/{taskID}",
|
"GET /api/v1/tasks/{taskID}",
|
||||||
"GET /api/v1/tasks/{taskID}/comments",
|
"GET /api/v1/tasks/{taskID}/comments",
|
||||||
"GET /api/v1/tasks/{taskID}/reminders",
|
"GET /api/v1/tasks/{taskID}/reminders",
|
||||||
|
"GET /api/v1/templates",
|
||||||
"GET /api/v1/upcoming",
|
"GET /api/v1/upcoming",
|
||||||
|
"GET /caldav/{userID}/{projectID}/{taskFile}",
|
||||||
|
"GET /ics/{token}",
|
||||||
|
"OPTIONS /caldav/*",
|
||||||
"PATCH /api/v1/filters/{filterID}",
|
"PATCH /api/v1/filters/{filterID}",
|
||||||
"PATCH /api/v1/labels/{labelID}",
|
"PATCH /api/v1/labels/{labelID}",
|
||||||
|
"PATCH /api/v1/sections/{sectionID}",
|
||||||
"PATCH /api/v1/tasks/{taskID}",
|
"PATCH /api/v1/tasks/{taskID}",
|
||||||
|
"POST /api/v1/ai/inbox/tidy",
|
||||||
|
"POST /api/v1/ai/inbox/tidy/apply",
|
||||||
|
"POST /api/v1/ai/plan/now",
|
||||||
|
"POST /api/v1/ai/tasks/{taskID}/split",
|
||||||
|
"POST /api/v1/feed/rotate",
|
||||||
"POST /api/v1/filters",
|
"POST /api/v1/filters",
|
||||||
|
"POST /api/v1/import/csv",
|
||||||
|
"POST /api/v1/import/todoist",
|
||||||
"POST /api/v1/labels",
|
"POST /api/v1/labels",
|
||||||
|
"POST /api/v1/projects/{projectID}/sections",
|
||||||
|
"POST /api/v1/projects/{projectID}/sections/reorder",
|
||||||
"POST /api/v1/tasks",
|
"POST /api/v1/tasks",
|
||||||
"POST /api/v1/tasks/quick-add",
|
"POST /api/v1/tasks/quick-add",
|
||||||
"POST /api/v1/tasks/{taskID}/attachments",
|
"POST /api/v1/tasks/{taskID}/attachments",
|
||||||
@@ -67,6 +109,15 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
|||||||
"POST /api/v1/tasks/{taskID}/reminders",
|
"POST /api/v1/tasks/{taskID}/reminders",
|
||||||
"POST /api/v1/tasks/{taskID}/reopen",
|
"POST /api/v1/tasks/{taskID}/reopen",
|
||||||
"POST /api/v1/tasks/{taskID}/snooze",
|
"POST /api/v1/tasks/{taskID}/snooze",
|
||||||
|
"POST /api/v1/templates",
|
||||||
|
"POST /api/v1/templates/{templateID}/use",
|
||||||
|
"POST /inbound/hook/{token}",
|
||||||
|
"PROPFIND /caldav",
|
||||||
|
"PROPFIND /caldav/{userID}",
|
||||||
|
"PROPFIND /caldav/{userID}/{projectID}",
|
||||||
|
"PUT /api/v1/ai/plan",
|
||||||
|
"PUT /caldav/{userID}/{projectID}/{taskFile}",
|
||||||
|
"REPORT /caldav/{userID}/{projectID}",
|
||||||
} {
|
} {
|
||||||
withheld[r] = true
|
withheld[r] = true
|
||||||
}
|
}
|
||||||
@@ -115,10 +166,25 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
|||||||
for _, must := range []string{
|
for _, must := range []string{
|
||||||
"GET /api/v1/notes",
|
"GET /api/v1/notes",
|
||||||
"POST /api/v1/notes",
|
"POST /api/v1/notes",
|
||||||
|
"PATCH /api/v1/notes/{noteID}",
|
||||||
|
"DELETE /api/v1/notes/{noteID}",
|
||||||
|
// Deling er hele grunden til at det er et selvstændigt produkt frem for en
|
||||||
|
// enkeltbrugers notesblok, så den halvdel skal navngives her.
|
||||||
|
"POST /api/v1/notes/{noteID}/shares",
|
||||||
|
"GET /api/v1/notes/{noteID}/shares",
|
||||||
|
"POST /api/v1/notes/{noteID}/attachments",
|
||||||
|
"POST /api/v1/notes/import",
|
||||||
"GET /api/v1/export/notes.zip",
|
"GET /api/v1/export/notes.zip",
|
||||||
|
"GET /api/v1/export/account",
|
||||||
|
"POST /api/v1/ai/notes/{noteID}/actions",
|
||||||
"GET /api/v1/projects", // a notebook, in this edition
|
"GET /api/v1/projects", // a notebook, in this edition
|
||||||
"GET /api/v1/search",
|
"GET /api/v1/search",
|
||||||
"GET /api/v1/auth/me",
|
"GET /api/v1/auth/me",
|
||||||
|
// 34 ruter blev lukket i den anden omgang af denne port. Den her linje er
|
||||||
|
// den, der afgør, at det var en port: MCP-endepunktet er stadig der, og det
|
||||||
|
// er værktøjslisten bagved, der er kortere.
|
||||||
|
"POST /api/v1/mcp",
|
||||||
|
"POST /mcp",
|
||||||
} {
|
} {
|
||||||
if !notes[must] {
|
if !notes[must] {
|
||||||
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
||||||
@@ -228,3 +294,82 @@ func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNotesEditionOffersNoTaskToolsOverMCP.
|
||||||
|
//
|
||||||
|
// The same hole as the routes, through a different door — and it was still open
|
||||||
|
// after the first gate. An MCP server advertises its tools, so a model connected
|
||||||
|
// to a notes instance would be told it can create_task, call it, get something
|
||||||
|
// back, and tell the person their task was filed.
|
||||||
|
//
|
||||||
|
// Asked through tools/list rather than by reading the server's own slice: that is
|
||||||
|
// what a client actually sees, and the slice is unexported for the same reason.
|
||||||
|
func TestNotesEditionOffersNoTaskToolsOverMCP(t *testing.T) {
|
||||||
|
taskTools := []string{"search_tasks", "create_task", "update_task", "complete_task", "add_comment"}
|
||||||
|
noteTools := []string{"search_notes", "create_note", "update_note"}
|
||||||
|
|
||||||
|
for _, edition := range []string{config.EditionFull, config.EditionNotes} {
|
||||||
|
t.Run(edition, func(t *testing.T) {
|
||||||
|
names := mcpToolNames(t, edition)
|
||||||
|
|
||||||
|
// Both editions: the shared tool, and the notes half.
|
||||||
|
for _, want := range append([]string{"list_projects"}, noteTools...) {
|
||||||
|
if !names[want] {
|
||||||
|
t.Errorf("%s edition does not offer %s", edition, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, task := range taskTools {
|
||||||
|
switch edition {
|
||||||
|
case config.EditionFull:
|
||||||
|
if !names[task] {
|
||||||
|
t.Errorf("the full edition lost %s — that is a deletion, not a gate", task)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if names[task] {
|
||||||
|
t.Errorf("the notes edition offers %s over MCP", task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("%s: %d tools", edition, len(names))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpToolNames asks one edition's MCP server for its tool list.
|
||||||
|
func mcpToolNames(t *testing.T, edition string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
cfg := &config.Config{BaseURL: "http://localhost", DataDir: t.TempDir(), SessionTTL: time.Hour, Edition: edition}
|
||||||
|
srv := New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||||
|
|
||||||
|
resp := srv.mcp.Handle(context.Background(), "nobody", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("tools/list got no response")
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Result struct {
|
||||||
|
Tools []struct{ Name string } `json:"tools"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
t.Fatalf("tools/list: %v — %s", err, raw)
|
||||||
|
}
|
||||||
|
if len(parsed.Result.Tools) == 0 {
|
||||||
|
t.Fatalf("tools/list returned no tools at all: %s", raw)
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, tool := range parsed.Result.Tools {
|
||||||
|
names[tool.Name] = true
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import (
|
|||||||
// `search_tasks` and `list_projects` is answering "which of these gets me closer",
|
// `search_tasks` and `list_projects` is answering "which of these gets me closer",
|
||||||
// and a description that only names the endpoint gives it nothing to decide with.
|
// and a description that only names the endpoint gives it nothing to decide with.
|
||||||
func (s *Server) buildMCP() *mcp.Server {
|
func (s *Server) buildMCP() *mcp.Server {
|
||||||
m := mcp.NewServer("verdande", "1")
|
// Udgavens eget navn: det er sådan, instansen står i klientens
|
||||||
|
// forbindelsesliste.
|
||||||
|
m := mcp.NewServer(s.cfg.ProductSlug(), "1")
|
||||||
|
|
||||||
m.Register(mcp.Tool{
|
m.Register(mcp.Tool{
|
||||||
Name: "list_projects",
|
Name: "list_projects",
|
||||||
@@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server {
|
|||||||
}),
|
}),
|
||||||
}, s.mcpListProjects)
|
}, s.mcpListProjects)
|
||||||
|
|
||||||
m.Register(mcp.Tool{
|
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
|
||||||
Name: "search_tasks",
|
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
|
||||||
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
|
||||||
"This is the tool for any question about what the user has to do — " +
|
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
|
||||||
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
// lave. add_comment er med her: en kommentar hænger på en opgave.
|
||||||
"The query accepts the same filter language as the app: \"today & p1\", " +
|
if !s.cfg.NotesOnly() {
|
||||||
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
m.Register(mcp.Tool{
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
Name: "search_tasks",
|
||||||
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
||||||
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
"This is the tool for any question about what the user has to do — " +
|
||||||
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
||||||
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
"The query accepts the same filter language as the app: \"today & p1\", " +
|
||||||
}),
|
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
||||||
}, s.mcpSearchTasks)
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
|
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
||||||
|
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
||||||
|
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
||||||
|
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
||||||
|
}),
|
||||||
|
}, s.mcpSearchTasks)
|
||||||
|
|
||||||
m.Register(mcp.Tool{
|
m.Register(mcp.Tool{
|
||||||
Name: "create_task",
|
Name: "create_task",
|
||||||
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
||||||
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
||||||
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
||||||
"and English. Use the explicit fields only when the user was explicit.",
|
"and English. Use the explicit fields only when the user was explicit.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
"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`."),
|
"content": mcp.Str("The task title, if not using `text`."),
|
||||||
"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."),
|
"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."),
|
"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."),
|
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||||
"description": mcp.Str("Longer notes on the task."),
|
"description": mcp.Str("Longer notes on the task."),
|
||||||
"labels": mcp.StrArray("Label names."),
|
"labels": mcp.StrArray("Label names."),
|
||||||
}),
|
}),
|
||||||
}, s.mcpCreateTask)
|
}, s.mcpCreateTask)
|
||||||
|
|
||||||
m.Register(mcp.Tool{
|
m.Register(mcp.Tool{
|
||||||
Name: "update_task",
|
Name: "update_task",
|
||||||
Description: "Change an existing task: its title, description, priority, due date or " +
|
Description: "Change an existing task: its title, description, priority, due date or " +
|
||||||
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
||||||
"to clear it, pass an empty string.",
|
"to clear it, pass an empty string.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"task_id": mcp.Str("The task to change."),
|
"task_id": mcp.Str("The task to change."),
|
||||||
"content": mcp.Str("A new title."),
|
"content": mcp.Str("A new title."),
|
||||||
"description": mcp.Str("New notes."),
|
"description": mcp.Str("New notes."),
|
||||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||||
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
||||||
"project_id": mcp.Str("Move the task to another project."),
|
"project_id": mcp.Str("Move the task to another project."),
|
||||||
"labels": mcp.StrArray("Replace the task's labels."),
|
"labels": mcp.StrArray("Replace the task's labels."),
|
||||||
}, "task_id"),
|
}, "task_id"),
|
||||||
}, s.mcpUpdateTask)
|
}, s.mcpUpdateTask)
|
||||||
|
|
||||||
m.Register(mcp.Tool{
|
m.Register(mcp.Tool{
|
||||||
Name: "complete_task",
|
Name: "complete_task",
|
||||||
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
||||||
"instead of closing, and the result says so.",
|
"instead of closing, and the result says so.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"task_id": mcp.Str("The task to complete."),
|
"task_id": mcp.Str("The task to complete."),
|
||||||
}, "task_id"),
|
}, "task_id"),
|
||||||
}, s.mcpCompleteTask)
|
}, s.mcpCompleteTask)
|
||||||
|
|
||||||
m.Register(mcp.Tool{
|
m.Register(mcp.Tool{
|
||||||
Name: "add_comment",
|
Name: "add_comment",
|
||||||
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"task_id": mcp.Str("The task to comment on."),
|
"task_id": mcp.Str("The task to comment on."),
|
||||||
"body": mcp.Str("What to say."),
|
"body": mcp.Str("What to say."),
|
||||||
}, "task_id", "body"),
|
}, "task_id", "body"),
|
||||||
}, s.mcpAddComment)
|
}, s.mcpAddComment)
|
||||||
|
}
|
||||||
|
|
||||||
s.registerNoteTools(m)
|
s.registerNoteTools(m)
|
||||||
return m
|
return m
|
||||||
|
|||||||
+100
-39
@@ -159,13 +159,31 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
// Docker both need this to answer before anyone has logged in.
|
// Docker both need this to answer before anyone has logged in.
|
||||||
r.Get("/healthz", s.handleHealth)
|
r.Get("/healthz", s.handleHealth)
|
||||||
|
|
||||||
|
// The same question as inside /api/v1, asked again here because four of the
|
||||||
|
// task-only surfaces live outside it: the calendar feed, CalDAV, the inbox
|
||||||
|
// hook, and the well-known path CalDAV clients discover through. They are out
|
||||||
|
// here because their callers cannot hold a session, not because they are any
|
||||||
|
// less about tasks — and the first gate missed all four for exactly that
|
||||||
|
// reason. It read as complete because the /tasks tree was covered.
|
||||||
|
full := !s.cfg.NotesOnly()
|
||||||
|
|
||||||
// The calendar feed is outside /api/v1 and outside the session: a calendar
|
// The calendar feed is outside /api/v1 and outside the session: a calendar
|
||||||
// client cannot log in, so the token in the path is the whole credential.
|
// client cannot log in, so the token in the path is the whole credential.
|
||||||
r.Get("/ics/{token}", s.handleICSFeed)
|
//
|
||||||
|
// A feed of VTODOs, so there is nothing for it to answer in the notes edition.
|
||||||
|
if full {
|
||||||
|
r.Get("/ics/{token}", s.handleICSFeed)
|
||||||
|
|
||||||
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
||||||
// against the root and authenticate with Basic rather than a session.
|
// against the root and authenticate with Basic rather than a session.
|
||||||
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
//
|
||||||
|
// Withheld rather than left to answer an empty collection: the well-known
|
||||||
|
// path is a claim that a CalDAV server is here, and a client that believes
|
||||||
|
// it goes on to PROPFIND, authenticate, and sync nothing — reporting a
|
||||||
|
// broken calendar server on an instance that has none and needs none. Same
|
||||||
|
// reasoning as the /.well-known/ 404 in serveWeb.
|
||||||
|
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
||||||
|
}
|
||||||
|
|
||||||
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||||
// authenticated by the token in the recipient address.
|
// authenticated by the token in the recipient address.
|
||||||
@@ -190,8 +208,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
//
|
//
|
||||||
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
|
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
|
||||||
// for den indgående post, og omvendt.
|
// for den indgående post, og omvendt.
|
||||||
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
//
|
||||||
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
// Opgaver, efter sin egen beskrivelse ovenfor — den skubber en linje tekst ind
|
||||||
|
// i indbakken — så den findes ikke i noter-udgaven.
|
||||||
|
if full {
|
||||||
|
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
||||||
|
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
||||||
|
}
|
||||||
|
|
||||||
// MCP with the token in the query string, for clients that cannot send a
|
// 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
|
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||||
@@ -218,7 +241,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
// Gmail connection with calendar tokens.
|
// Gmail connection with calendar tokens.
|
||||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
||||||
})
|
})
|
||||||
r.Route("/caldav", s.caldavRoutes)
|
// Hele CalDAV-træet: otte ruter, der alle læser eller skriver en VTODO.
|
||||||
|
if full {
|
||||||
|
r.Route("/caldav", s.caldavRoutes)
|
||||||
|
}
|
||||||
|
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Use(s.requireCSRF)
|
r.Use(s.requireCSRF)
|
||||||
@@ -346,7 +372,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Get("/feed", s.handleGetFeed)
|
// Tokenet til ICS-feedet ovenfor. Et token til et feed, der ikke
|
||||||
|
// findes, er en indstillingsside, der tilbyder en adresse, intet
|
||||||
|
// svarer på.
|
||||||
|
if full {
|
||||||
|
r.Get("/feed", s.handleGetFeed)
|
||||||
|
}
|
||||||
|
|
||||||
r.Route("/notifications", func(r chi.Router) {
|
r.Route("/notifications", func(r chi.Router) {
|
||||||
r.Get("/", s.handleListNotifications)
|
r.Get("/", s.handleListNotifications)
|
||||||
@@ -372,7 +403,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Patch("/", s.handleUpdateComment)
|
r.Patch("/", s.handleUpdateComment)
|
||||||
r.Delete("/", s.handleDeleteComment)
|
r.Delete("/", s.handleDeleteComment)
|
||||||
})
|
})
|
||||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
if full {
|
||||||
|
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||||
|
}
|
||||||
|
|
||||||
r.Get("/mail-address", s.handleGetMailAddress)
|
r.Get("/mail-address", s.handleGetMailAddress)
|
||||||
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
||||||
@@ -393,20 +426,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/settings", s.handleGetAISettings)
|
r.Get("/settings", s.handleGetAISettings)
|
||||||
r.Put("/settings", s.handleSetAISettings)
|
r.Put("/settings", s.handleSetAISettings)
|
||||||
r.Post("/summary", s.handleAISummary)
|
r.Post("/summary", s.handleAISummary)
|
||||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
if full {
|
||||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||||
// der sker, når nogen siger ja til én af dem.
|
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
// der sker, når nogen siger ja til én af dem.
|
||||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||||
|
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||||
|
}
|
||||||
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
|
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
|
||||||
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
||||||
// Dagens plan: indstillingen, og en knap til at se den nu. En
|
// Dagens plan: indstillingen, og en knap til at se den nu. En
|
||||||
// indstilling, man skal vente til i morgen for at se virkningen af,
|
// indstilling, man skal vente til i morgen for at se virkningen af,
|
||||||
// er en indstilling, ingen tør slå til.
|
// er en indstilling, ingen tør slå til.
|
||||||
r.Get("/plan", s.handleGetPlanSettings)
|
if full {
|
||||||
r.Put("/plan", s.handleSetPlanSettings)
|
r.Get("/plan", s.handleGetPlanSettings)
|
||||||
r.Post("/plan/now", s.handlePlanNow)
|
r.Put("/plan", s.handleSetPlanSettings)
|
||||||
|
r.Post("/plan/now", s.handlePlanNow)
|
||||||
|
}
|
||||||
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
|
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
|
||||||
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
|
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
|
||||||
// det er bygget på.
|
// det er bygget på.
|
||||||
@@ -491,24 +528,38 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/import", func(r chi.Router) {
|
// Begge retninger laver eller læser opgaver: Todoist-importen laver
|
||||||
r.Post("/todoist", s.handleImportTodoist)
|
// dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem
|
||||||
r.Post("/csv", s.handleImportCSV)
|
// ud. De blev alle betjent i noter-udgaven indtil nu — de svarede tomt
|
||||||
})
|
// frem for at fejle, hvilket er den stille version af samme fejl.
|
||||||
|
if full {
|
||||||
|
r.Route("/import", func(r chi.Router) {
|
||||||
|
r.Post("/todoist", s.handleImportTodoist)
|
||||||
|
r.Post("/csv", s.handleImportCSV)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
r.Get("/export/account", s.handleExportAccount)
|
r.Get("/export/account", s.handleExportAccount)
|
||||||
r.Get("/export/notes.zip", s.handleExportNotes)
|
r.Get("/export/notes.zip", s.handleExportNotes)
|
||||||
r.Get("/export/projects.zip", s.handleExportAllCSV)
|
if full {
|
||||||
r.Get("/export/tasks.ics", s.handleExportAllICS)
|
r.Get("/export/projects.zip", s.handleExportAllCSV)
|
||||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
r.Get("/export/tasks.ics", s.handleExportAllICS)
|
||||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||||
|
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||||
|
}
|
||||||
|
|
||||||
r.Route("/templates", func(r chi.Router) {
|
// En skabelon er et projekt plus sektioner plus opgaver — læst i
|
||||||
r.Get("/", s.handleListTemplates)
|
// CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som
|
||||||
r.Post("/", s.handleSaveTemplate)
|
// kun siger "projekt". Uden opgaver er den et tomt projekt med
|
||||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
// overskrifter i.
|
||||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
if full {
|
||||||
})
|
r.Route("/templates", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleListTemplates)
|
||||||
|
r.Post("/", s.handleSaveTemplate)
|
||||||
|
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||||
|
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if full {
|
if full {
|
||||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||||
@@ -539,7 +590,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Use(s.requireProject(store.RoleViewer))
|
r.Use(s.requireProject(store.RoleViewer))
|
||||||
|
|
||||||
r.Get("/", s.handleGetProject)
|
r.Get("/", s.handleGetProject)
|
||||||
r.Get("/sections", s.handleListSections)
|
// Sektioner grupperer opgaver inde i et projekt, og det er
|
||||||
|
// afgjort af skemaet frem for af navnet: `section_id` står på
|
||||||
|
// `tasks` og findes ikke på `notes`. En sektion i en notesbog
|
||||||
|
// kan ikke indeholde noget.
|
||||||
|
if full {
|
||||||
|
r.Get("/sections", s.handleListSections)
|
||||||
|
}
|
||||||
r.Get("/members", s.handleListMembers)
|
r.Get("/members", s.handleListMembers)
|
||||||
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
||||||
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
||||||
@@ -548,8 +605,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireProject(store.RoleEditor))
|
r.Use(s.requireProject(store.RoleEditor))
|
||||||
r.Post("/sections", s.handleCreateSection)
|
if full {
|
||||||
r.Post("/sections/reorder", s.handleReorderSections)
|
r.Post("/sections", s.handleCreateSection)
|
||||||
|
r.Post("/sections/reorder", s.handleReorderSections)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -624,10 +683,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/trash/projects", s.handleListTrashedProjects)
|
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||||
|
|
||||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
if full {
|
||||||
r.Patch("/", s.handleUpdateSection)
|
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||||
r.Delete("/", s.handleDeleteSection)
|
r.Patch("/", s.handleUpdateSection)
|
||||||
})
|
r.Delete("/", s.handleDeleteSection)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if full {
|
if full {
|
||||||
r.Route("/tasks", func(r chi.Router) {
|
r.Route("/tasks", func(r chi.Router) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { watchForTrouble } from './trouble.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Noter-udgaven, kørt som sit eget program.
|
* Noter-udgaven, kørt som sit eget program.
|
||||||
@@ -179,3 +180,69 @@ test('navnet står også i teksterne, ikke kun i mærket', async ({ page }) => {
|
|||||||
await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hver side, der KAN nås i noter-udgaven, må ikke kalde noget, der er lukket.
|
||||||
|
*
|
||||||
|
* Det er den halvdel, en port let glemmer, og den fejler stille: lukkes en rute,
|
||||||
|
* fladen stadig henter, svarer serveren 404, komponenten fanger det, og siden
|
||||||
|
* tegner uden den sektion — pænt, tomt, og uden at nogen får at vide, at noget
|
||||||
|
* manglede. Der er ingen fejlskærm at se.
|
||||||
|
*
|
||||||
|
* Målt på svarene frem for på det tegnede: en manglende sektion ser ud som en
|
||||||
|
* sektion, der ikke var noget i.
|
||||||
|
*
|
||||||
|
* Skrevet før ruterne blev lukket, og den fangede SYV — fire af dem fra den første
|
||||||
|
* port, som havde været i drift i to merges: sidebjælken hentede /filters og
|
||||||
|
* /labels ved hver navigation, notifikationssiden hentede /ai/plan, og
|
||||||
|
* notesbogens egen side hentede /tasks. Den sidste var ikke støj: kaldene samles i
|
||||||
|
* et Promise.all, så 404'eren kastede og siden viste `status = 'failed'` frem for
|
||||||
|
* notesbogens noter.
|
||||||
|
*
|
||||||
|
* Og den fangede én mere, som rettelsen selv lavede: da opgavekaldet holdt op med
|
||||||
|
* at fejle, kom en uendelig effekt-løkke frem, som 404'eren havde holdt låg på
|
||||||
|
* (effect_update_depth_exceeded). Derfor læser den konsollen og ikke kun svarene.
|
||||||
|
*
|
||||||
|
* Hvad den IKKE dækker, sagt her frem for underforstået: elleve sider plus ét
|
||||||
|
* projekt. En gruppe (/gruppe/{id}) og en åben note er ikke med, fordi begge
|
||||||
|
* kræver, at prøven først laver en — og skuffer og dialoger, der åbnes ved et
|
||||||
|
* klik, er slet ikke besøgt. En grøn her betyder "ingen af de tolv adresser",
|
||||||
|
* ikke "ingen flade".
|
||||||
|
*/
|
||||||
|
test('ingen side kalder en rute, udgaven ikke har', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
// Projektet skal med som en rigtig adresse: /projekt/{id} er en notesbog her,
|
||||||
|
// og den er den eneste side med en id i stien, der kan nås.
|
||||||
|
await page.goto('/noter');
|
||||||
|
const projekter = await (await page.request.get('/api/v1/projects')).json();
|
||||||
|
const id = (projekter.projects ?? projekter)[0]?.id;
|
||||||
|
expect(id, 'der skal findes mindst ét projekt at åbne').toBeTruthy();
|
||||||
|
|
||||||
|
const sider = [
|
||||||
|
'/noter',
|
||||||
|
'/genveje',
|
||||||
|
'/indstillinger',
|
||||||
|
'/indstillinger/ai',
|
||||||
|
'/indstillinger/brugere',
|
||||||
|
'/indstillinger/data',
|
||||||
|
'/indstillinger/fejl',
|
||||||
|
'/indstillinger/historik',
|
||||||
|
'/indstillinger/integrationer',
|
||||||
|
'/indstillinger/notifikationer',
|
||||||
|
'/indstillinger/tokens',
|
||||||
|
`/projekt/${id}`
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const side of sider) {
|
||||||
|
await page.goto(side);
|
||||||
|
// Ventet på at fladen er tegnet, så de kald siden laver ved opstart er
|
||||||
|
// nået at komme afsted. Uden den her går løkken videre, før svarene er
|
||||||
|
// kommet, og prøven måler ingenting.
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tallet med på det grønne: elleve sider og et projekt, frem for "ingen fejl".
|
||||||
|
expect(trouble, `besøgte ${sider.length} sider`).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
+1
-34
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { USER } from './user.js';
|
import { USER } from './user.js';
|
||||||
|
import { watchForTrouble } from './trouble.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The four flows a broken build must not survive: add a task, tick it off, reach
|
* The four flows a broken build must not survive: add a task, tick it off, reach
|
||||||
@@ -11,14 +12,6 @@ import { USER } from './user.js';
|
|||||||
* precisely the bug this suite exists to catch.
|
* precisely the bug this suite exists to catch.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects anything that went wrong quietly: a thrown exception, a console error,
|
|
||||||
* or an API call that came back a failure.
|
|
||||||
*
|
|
||||||
* Failed responses are recorded with their URL and status. "A console error
|
|
||||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
|
||||||
* 500" is.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Opens the project header's overflow menu and clicks one of its items.
|
* Opens the project header's overflow menu and clicks one of its items.
|
||||||
*
|
*
|
||||||
@@ -93,32 +86,6 @@ async function nyNote(side) {
|
|||||||
return felt;
|
return felt;
|
||||||
}
|
}
|
||||||
|
|
||||||
function watchForTrouble(page) {
|
|
||||||
const trouble = [];
|
|
||||||
page.on('console', (message) => {
|
|
||||||
// The browser logs its own line for every failed fetch. It carries no URL,
|
|
||||||
// so it is noise next to the response listener below.
|
|
||||||
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
|
|
||||||
trouble.push(message.text());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
page.on('pageerror', (error) => trouble.push(String(error)));
|
|
||||||
page.on('response', (response) => {
|
|
||||||
const url = response.url();
|
|
||||||
if (!url.includes('/api/') || response.ok()) return;
|
|
||||||
|
|
||||||
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
|
||||||
// which is the correct answer on the sign-in page and after signing out.
|
|
||||||
// Every other non-ok response is worth failing over — that is what this
|
|
||||||
// watcher is for.
|
|
||||||
const path = new URL(url).pathname;
|
|
||||||
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
|
||||||
|
|
||||||
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
|
||||||
});
|
|
||||||
return trouble;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kommendes liste kan se længere frem end en uge.
|
* Kommendes liste kan se længere frem end en uge.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Collects anything that went wrong quietly: a thrown exception, a console error,
|
||||||
|
* or an API call that came back a failure.
|
||||||
|
*
|
||||||
|
* Failed responses are recorded with their URL and status. "A console error
|
||||||
|
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||||
|
* 500" is.
|
||||||
|
*
|
||||||
|
* Udskilt fra smoke.spec.js, så udgave-suiten kan bruge den samme — og den
|
||||||
|
* fortjente flytningen med det samme: lukker man en rute, fladen stadig kalder,
|
||||||
|
* står der en 404 på en side, der ser helt rigtig ud, og det her er det eneste,
|
||||||
|
* der ser den.
|
||||||
|
*/
|
||||||
|
export function watchForTrouble(page) {
|
||||||
|
const trouble = [];
|
||||||
|
page.on('console', (message) => {
|
||||||
|
// The browser logs its own line for every failed fetch. It carries no URL,
|
||||||
|
// so it is noise next to the response listener below.
|
||||||
|
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
|
||||||
|
trouble.push(message.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on('pageerror', (error) => trouble.push(String(error)));
|
||||||
|
page.on('response', (response) => {
|
||||||
|
const url = response.url();
|
||||||
|
if (!url.includes('/api/') || response.ok()) return;
|
||||||
|
|
||||||
|
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
||||||
|
// which is the correct answer on the sign-in page and after signing out.
|
||||||
|
// Every other non-ok response is worth failing over — that is what this
|
||||||
|
// watcher is for.
|
||||||
|
const path = new URL(url).pathname;
|
||||||
|
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
||||||
|
|
||||||
|
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
||||||
|
});
|
||||||
|
return trouble;
|
||||||
|
}
|
||||||
@@ -73,6 +73,11 @@
|
|||||||
// through the connector.
|
// through the connector.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
app.labelsChanged;
|
app.labelsChanged;
|
||||||
|
// Ikke i noter-udgaven: begge ruter findes slet ikke der, så de to kald var
|
||||||
|
// to 404'ere ved hver navigation — opslugt af .catch() og derfor usynlige.
|
||||||
|
// Fundet af vandringen i edition.spec.js, efter at porten havde været i
|
||||||
|
// drift i to merges.
|
||||||
|
if (app.notesOnly) return;
|
||||||
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
||||||
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -343,6 +343,21 @@ class AppState {
|
|||||||
* `completed: 'exclude'`.
|
* `completed: 'exclude'`.
|
||||||
*/
|
*/
|
||||||
async loadTasks(params) {
|
async loadTasks(params) {
|
||||||
|
// Noter-udgaven har ingen opgaveruter, så den her ville kaste en 404 — og
|
||||||
|
// kalderne samler den i et Promise.all, hvor én fejl tager hele siden med.
|
||||||
|
// Vagten står HER frem for ved de tre kaldesteder, fordi butikken er det
|
||||||
|
// ene sted, der kender udgaven: en vagt pr. kalder er tre steder at glemme
|
||||||
|
// den fjerde, og det var præcis sådan den her slap igennem.
|
||||||
|
if (this.notesOnly) {
|
||||||
|
// Skrevet kun hvis der ER noget at rydde. En ubetinget tilskrivning
|
||||||
|
// looper: projektsiden har en effekt, der læser app.tasks og kalder
|
||||||
|
// herind, så et skriv ved hvert kald er en uendelig runde —
|
||||||
|
// effect_update_depth_exceeded, ikke en 404. Før vagten kastede kaldet
|
||||||
|
// på 404'eren og nåede aldrig at skrive, så løkken var skjult bag en
|
||||||
|
// fejl.
|
||||||
|
if (this.tasks.length) this.tasks = [];
|
||||||
|
return this.tasks;
|
||||||
|
}
|
||||||
const { tasks } = await api.listTasks({
|
const { tasks } = await api.listTasks({
|
||||||
completed: completedView.shown ? 'include' : undefined,
|
completed: completedView.shown ? 'include' : undefined,
|
||||||
...params
|
...params
|
||||||
|
|||||||
@@ -141,6 +141,13 @@
|
|||||||
let startDate = $state('');
|
let startDate = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Skabeloner er projekt plus sektioner plus opgaver, så ruten findes ikke i
|
||||||
|
// noter-udgaven — og den her fangede fejlen i en toast, altså en synlig
|
||||||
|
// fejlbesked på indstillingssiden frem for en tom liste.
|
||||||
|
if (app.notesOnly) {
|
||||||
|
loadingTemplates = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
api
|
api
|
||||||
.listTemplates()
|
.listTemplates()
|
||||||
.then((r) => (templates = r.templates))
|
.then((r) => (templates = r.templates))
|
||||||
|
|||||||
@@ -296,7 +296,11 @@
|
|||||||
let mailConfigured = $state(null);
|
let mailConfigured = $state(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
// Feedet er en kalender af opgaver, så adressen findes ikke i
|
||||||
|
// noter-udgaven.
|
||||||
|
if (!app.notesOnly) {
|
||||||
|
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
||||||
|
}
|
||||||
api
|
api
|
||||||
.getMailAddress()
|
.getMailAddress()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
|
|||||||
@@ -92,6 +92,8 @@
|
|||||||
let planBusy = $state(false);
|
let planBusy = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Dagens plan er dagens opgaver.
|
||||||
|
if (app.notesOnly) return;
|
||||||
api
|
api
|
||||||
.planSettings()
|
.planSettings()
|
||||||
.then((p) => (plan = p))
|
.then((p) => (plan = p))
|
||||||
|
|||||||
@@ -88,9 +88,14 @@
|
|||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
status = 'loading';
|
status = 'loading';
|
||||||
try {
|
try {
|
||||||
|
// Sektioner findes ikke i noter-udgaven, hvor et projekt er en notesbog
|
||||||
|
// — og det er ikke bare støj: Promise.all kaster på den første, der
|
||||||
|
// fejler, så en 404 herfra satte status = 'failed', og siden viste en
|
||||||
|
// fejlskærm frem for notesbogens noter. `loadTasks` vogter sig selv i
|
||||||
|
// butikken, fordi den har tre kaldesteder her.
|
||||||
const [p, s] = await Promise.all([
|
const [p, s] = await Promise.all([
|
||||||
api.getProject(projectId),
|
api.getProject(projectId),
|
||||||
api.listSections(projectId),
|
app.notesOnly ? Promise.resolve({ sections: [] }) : api.listSections(projectId),
|
||||||
app.loadTasks({ project_id: projectId })
|
app.loadTasks({ project_id: projectId })
|
||||||
]);
|
]);
|
||||||
project = p;
|
project = p;
|
||||||
|
|||||||
Reference in New Issue
Block a user