fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene

Prøven, der skal holde udgaverne adskilt, stod grøn gennem to merges, mens
34 opgaveruter blev betjent af noter-udgaven. Den fejlede ikke: den
sammenligner routeren med en nedskreven liste, og listen var skrevet ud
fra de ruter, nogen havde tænkt på. Den er en DRIFT-kontrol, og den kan
ikke være en fuldstændighedskontrol. Det står nu i prøven selv.

Lukket: CalDAV-træet (8 ruter, alle læser eller skriver en VTODO),
.well-known/caldav (en påstand om at der er en CalDAV-server), ICS-feedet
og de to ruter til dets token, de fire opgave-eksporter, Todoist- og
CSV-importen, skabelonerne, sektionerne, de seks AI-opgaveruter og
indbakke-krogen. Plus fem af ni MCP-værktøjer — endepunktet er der
stadig, værktøjslisten er kortere.

Tre blev afgjort ved at læse frem for ved navnet: en skabelon laver
opgaver (CreateProjectFromTemplate), `section_id` står på `tasks` og
findes ikke på `notes`, og add_comment hænger på en opgave.

Men den dyre halvdel var fladen. En vandring gennem hver side, der kan
nås i noter-udgaven, med konsollen og svarene aflæst, fandt SYV brud —
fire af dem fra den første port:

  sidebjælken      GET /filters  404 ved hver navigation, ædt af .catch()
  sidebjælken      GET /labels   404 ved hver navigation, ædt af .catch()
  notifikationer   GET /ai/plan  404, stille
  notesbogens side GET /tasks    kastede → status = 'failed'
  notesbogens side GET /sections kastede → samme fejlskærm
  indstillinger    GET /templates  toast med fejlbesked på skærmen
  integrationer    GET /feed     404, stille

Den fjerde er den, der har kostet noget: /projekt/{id} er notesbogens
side, og kaldene samles i et Promise.all, så én 404 tog hele siden med.
Den har vist en fejlskærm frem for noter siden porten blev sat. Ingen
prøve åbnede en notesbog i den udgave, og .catch(() => {}) sørgede for,
at resten ikke sagde noget. "Fladen følger udgaven" var sandt om menuen
og falsk om hentningerne.

Vagten for opgaver står i butikken frem for ved kaldestederne: der var
tre, og den fjerde blev glemt — hvilket er præcis sådan, den slap
igennem.

Og rettelsen lavede selv en fejl, som vandringen fangede: da opgavekaldet
holdt op med at fejle, kom en uendelig effekt-løkke frem, som 404'eren
havde holdt låg på. En ubetinget tilskrivning til app.tasks får
projektsidens effekt til at kalde tilbage. 404'eren var låget på en
løkke, ikke bare støj.

Også: -timeout 20m i begge CI-workflows. internal/httpapi tog 664s her og
498s i CI på #95, mod Go's standardgrænse på 600 — og når den krydses, er
beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, som
læser som en deadlock i koden frem for som en suite, der blev for stor.

watchForTrouble udskilt til web/e2e/trouble.js, så udgave-suiten kan bruge
den samme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-07 02:03:40 +02:00
co-authored by Claude Opus 5
parent c6c7979d1a
commit 8c3aa33f13
16 changed files with 514 additions and 147 deletions
+145
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"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
// 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.
//
// 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{}
for _, r := range []string{
"DELETE /api/v1/filters/{filterID}",
"DELETE /api/v1/labels/{labelID}",
"DELETE /api/v1/reminders/{reminderID}",
"DELETE /api/v1/sections/{sectionID}",
"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/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/preview",
"GET /api/v1/filters/{filterID}/tasks",
"GET /api/v1/labels",
"GET /api/v1/projects/{projectID}/sections",
"GET /api/v1/tasks",
"GET /api/v1/tasks/quick-add/preview",
"GET /api/v1/tasks/{taskID}",
"GET /api/v1/tasks/{taskID}/comments",
"GET /api/v1/tasks/{taskID}/reminders",
"GET /api/v1/templates",
"GET /api/v1/upcoming",
"GET /caldav/{userID}/{projectID}/{taskFile}",
"GET /ics/{token}",
"OPTIONS /caldav/*",
"PATCH /api/v1/filters/{filterID}",
"PATCH /api/v1/labels/{labelID}",
"PATCH /api/v1/sections/{sectionID}",
"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/import/csv",
"POST /api/v1/import/todoist",
"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/quick-add",
"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}/reopen",
"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
}
@@ -115,10 +166,25 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
for _, must := range []string{
"GET /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/account",
"POST /api/v1/ai/notes/{noteID}/actions",
"GET /api/v1/projects", // a notebook, in this edition
"GET /api/v1/search",
"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] {
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
}
+72 -63
View File
@@ -23,7 +23,9 @@ import (
// `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.
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{
Name: "list_projects",
@@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server {
}),
}, s.mcpListProjects)
m.Register(mcp.Tool{
Name: "search_tasks",
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
"This is the tool for any question about what the user has to do — " +
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
"The query accepts the same filter language as the app: \"today & p1\", " +
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
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)
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
// lave. add_comment er med her: en kommentar hænger på en opgave.
if !s.cfg.NotesOnly() {
m.Register(mcp.Tool{
Name: "search_tasks",
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
"This is the tool for any question about what the user has to do — " +
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
"The query accepts the same filter language as the app: \"today & p1\", " +
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
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{
Name: "create_task",
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 " +
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
"and English. Use the explicit fields only when the user was explicit.",
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. 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."),
"labels": mcp.StrArray("Label names."),
}),
}, s.mcpCreateTask)
m.Register(mcp.Tool{
Name: "create_task",
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 " +
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
"and English. Use the explicit fields only when the user was explicit.",
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. 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."),
"labels": mcp.StrArray("Label names."),
}),
}, s.mcpCreateTask)
m.Register(mcp.Tool{
Name: "update_task",
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; " +
"to clear it, pass an empty string.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to change."),
"content": mcp.Str("A new title."),
"description": mcp.Str("New notes."),
"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."),
"project_id": mcp.Str("Move the task to another project."),
"labels": mcp.StrArray("Replace the task's labels."),
}, "task_id"),
}, s.mcpUpdateTask)
m.Register(mcp.Tool{
Name: "update_task",
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; " +
"to clear it, pass an empty string.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to change."),
"content": mcp.Str("A new title."),
"description": mcp.Str("New notes."),
"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."),
"project_id": mcp.Str("Move the task to another project."),
"labels": mcp.StrArray("Replace the task's labels."),
}, "task_id"),
}, s.mcpUpdateTask)
m.Register(mcp.Tool{
Name: "complete_task",
Description: "Mark a task done. A repeating task advances to its next occurrence " +
"instead of closing, and the result says so.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to complete."),
}, "task_id"),
}, s.mcpCompleteTask)
m.Register(mcp.Tool{
Name: "complete_task",
Description: "Mark a task done. A repeating task advances to its next occurrence " +
"instead of closing, and the result says so.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to complete."),
}, "task_id"),
}, s.mcpCompleteTask)
m.Register(mcp.Tool{
Name: "add_comment",
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to comment on."),
"body": mcp.Str("What to say."),
}, "task_id", "body"),
}, s.mcpAddComment)
m.Register(mcp.Tool{
Name: "add_comment",
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to comment on."),
"body": mcp.Str("What to say."),
}, "task_id", "body"),
}, s.mcpAddComment)
}
s.registerNoteTools(m)
return m
+100 -39
View File
@@ -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.
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
// 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
// against the root and authenticate with Basic rather than a session.
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
// CalDAV, outside /api/v1 because clients do well-known-path discovery
// against the root and authenticate with Basic rather than a session.
//
// 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
// 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
// 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
// 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.
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.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.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.Delete("/", s.handleDeleteComment)
})
r.Post("/feed/rotate", s.handleRotateFeed)
if full {
r.Post("/feed/rotate", s.handleRotateFeed)
}
r.Get("/mail-address", s.handleGetMailAddress)
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.Put("/settings", s.handleSetAISettings)
r.Post("/summary", s.handleAISummary)
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
r.Post("/inbox/tidy", s.handleAITidyInbox)
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
if full {
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
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/apply", s.handleAICreateFromNote)
// Dagens plan: indstillingen, og en knap til at se den nu. En
// indstilling, man skal vente til i morgen for at se virkningen af,
// er en indstilling, ingen tør slå til.
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
if full {
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
}
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
// 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.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
// Begge retninger laver eller læser opgaver: Todoist-importen laver
// dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem
// 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/notes.zip", s.handleExportNotes)
r.Get("/export/projects.zip", s.handleExportAllCSV)
r.Get("/export/tasks.ics", s.handleExportAllICS)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
if full {
r.Get("/export/projects.zip", s.handleExportAllCSV)
r.Get("/export/tasks.ics", s.handleExportAllICS)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
}
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)
})
// En skabelon er et projekt plus sektioner plus opgaver — læst i
// CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som
// kun siger "projekt". Uden opgaver er den et tomt projekt med
// overskrifter i.
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 {
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.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)
// 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.
@@ -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.Use(s.requireProject(store.RoleEditor))
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
if full {
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
}
})
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.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
r.Route("/sections/{sectionID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateSection)
r.Delete("/", s.handleDeleteSection)
})
if full {
r.Route("/sections/{sectionID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateSection)
r.Delete("/", s.handleDeleteSection)
})
}
if full {
r.Route("/tasks", func(r chi.Router) {