From 5f04fe56bcb3fa424e6fd115e0ffbd89b5913df5 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 13:33:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(notes):=20note-v=C3=A6rkt=C3=B8jer=20i=20M?= =?UTF-8?q?CP,=20og=20sidebj=C3=A6lken=20ryddet=20op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude kan nu skrive og søge i noter gennem MCP. Det er også svaret på Apple Noter: der findes ingen eksport, der er værd at bruge, men en model, der kører på maskinen, kan læse Notes.app og skrive det, den finder, ind herigennem — én note ad gangen, med teksten som Markdown. En flytning, ingen skal bygge en parser til. Værktøjet melder tilbage, hvilke links noten fik. En note, hvis #tag var stavet forkert, ser ellers præcis ud som en, hvor det ramte, og i en chat ser ingen den række, der blev skrevet. Noter står nu for sig selv med en streg over. De er ikke en femte måde at se de samme opgaver på, hvilket er hvad alt ovenfor er — de er den anden halvdel af programmet, og i den række læste de som endnu et filter. Færdige er ude af menuen og ligger under Indstillinger → Data ved siden af papirkurven. Argumentet for at have den fremme var, at man ikke ved, hvilken visning man lukkede noget i — det er sandt, og det er også et sjældent ærinde ved siden af fire punkter, der læses hver dag. Den er der stadig, med en beskrivelse af hvad den er til. Co-Authored-By: Claude Opus 5 --- internal/httpapi/mcp_handlers.go | 174 ++++++++++++++++++ web/e2e/smoke.spec.js | 5 +- web/src/lib/components/Sidebar.svelte | 45 +++-- web/src/lib/locales/da.js | 2 + web/src/lib/locales/en.js | 2 + .../routes/indstillinger/data/+page.svelte | 13 ++ web/src/routes/noter/+page.svelte | 3 + 7 files changed, 225 insertions(+), 19 deletions(-) diff --git a/internal/httpapi/mcp_handlers.go b/internal/httpapi/mcp_handlers.go index 003959c..ba0056c 100644 --- a/internal/httpapi/mcp_handlers.go +++ b/internal/httpapi/mcp_handlers.go @@ -101,6 +101,7 @@ func (s *Server) buildMCP() *mcp.Server { }, "task_id", "body"), }, s.mcpAddComment) + s.registerNoteTools(m) return m } @@ -496,3 +497,176 @@ func clamp(value, fallback, max int) int { } return value } + +// --- notes --------------------------------------------------------------------- + +// registerNoteTools adds the note half of the MCP surface. +// +// Separate from the task tools for one reason worth stating: this is how a note +// gets in from somewhere else. Apple Notes has no export worth the name, but a +// model running on the machine can read Notes.app and write what it finds through +// these — one note at a time, with the text as Markdown. That is a migration +// nobody has to build a parser for. +func (s *Server) registerNoteTools(m *mcp.Server) { + m.Register(mcp.Tool{ + Name: "search_notes", + Description: "Find notes by text. Matches title and body, and is generous about " + + "Danish spelling — searching \"gron\" finds \"grøn\", and the other way round. " + + "With no query it returns the most recent notes. This is the tool for " + + "\"what did I write about the tax return\" or \"find my note on the kitchen\".", + InputSchema: mcp.Schema(map[string]any{ + "text": mcp.Str("Words to look for in the title and body."), + "limit": mcp.Int("How many to return. Defaults to 25, maximum 100."), + }), + }, s.mcpSearchNotes) + + m.Register(mcp.Tool{ + Name: "create_note", + Description: "Write a note. The body is Markdown and is stored as typed — it is " + + "also exactly what an export produces, so nothing is converted and nothing " + + "is lost. Two things in the text are read out of it and become links: " + + "`#Projekt` files the note against that project, so it appears on the " + + "project's page without anybody filing it there, and `[[Another note]]` " + + "links to a note by its title. Use this to bring notes in from elsewhere — " + + "Apple Notes, OneNote, a folder of Markdown — one call per note.", + InputSchema: mcp.Schema(map[string]any{ + "body": mcp.Str("The note as Markdown. #Projekt and [[note]] become links."), + "title": mcp.Str("A title. Left out, the first line of the body becomes it."), + "project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."), + "pinned": mcp.Bool("Keep it at the top of the list."), + }), + }, s.mcpCreateNote) + + m.Register(mcp.Tool{ + Name: "update_note", + Description: "Change a note. Only the fields given are changed. Sending `body` " + + "replaces the whole text, so read the note first if you mean to add to it — " + + "and the links are read again from the new text, so a #tag removed is a link " + + "removed.", + InputSchema: mcp.Schema(map[string]any{ + "note_id": mcp.Str("Which note."), + "body": mcp.Str("The whole new text, as Markdown."), + "title": mcp.Str("A new title."), + "pinned": mcp.Bool("Pin it or unpin it."), + }), + }, s.mcpUpdateNote) +} + +func (s *Server) mcpSearchNotes(ctx context.Context, userID string, args json.RawMessage) (any, error) { + var params struct { + Text string `json:"text"` + Limit int `json:"limit"` + } + _ = json.Unmarshal(args, ¶ms) + + limit := params.Limit + if limit <= 0 || limit > 100 { + limit = 25 + } + + var notes []store.Note + var err error + if strings.TrimSpace(params.Text) == "" { + notes, err = s.db.LooseNotes(ctx, userID) + } else { + notes, err = s.db.SearchNotes(ctx, params.Text, limit) + } + if err != nil { + return nil, err + } + + // The same filter the HTTP surface applies, for the same reason: a search must + // not become a way to read what you cannot open. + out := make([]map[string]any, 0, len(notes)) + for _, n := range notes { + if !s.mcpMayRead(ctx, &n, userID) { + continue + } + out = append(out, map[string]any{ + "id": n.ID, "title": n.Title, "body": n.Body, + "project_id": n.ProjectID, "updated_at": n.UpdatedAt, + }) + if len(out) >= limit { + break + } + } + return map[string]any{"notes": out, "count": len(out)}, nil +} + +func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) bool { + if n.ProjectID == "" { + return n.CreatedBy == userID + } + _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleViewer) + return err == nil +} + +func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) { + var params struct { + Body string `json:"body"` + Title string `json:"title"` + ProjectID string `json:"project_id"` + Pinned bool `json:"pinned"` + } + _ = json.Unmarshal(args, ¶ms) + + n := &store.Note{ + CreatedBy: userID, + Title: params.Title, + Body: params.Body, + Pinned: params.Pinned, + } + if params.ProjectID != "" { + if _, err := store.RequireProjectRole(ctx, s.db, params.ProjectID, userID, store.RoleEditor); err != nil { + return nil, err + } + n.ProjectID = params.ProjectID + } + if err := s.db.SaveNote(ctx, n); err != nil { + return nil, err + } + + // The links are reported back rather than left silent. A note whose #tag was + // misspelt looks identical to one whose tag landed, and in a chat nobody sees + // the row that was written. + return map[string]any{ + "id": n.ID, "title": n.Title, "links": n.Links, + }, nil +} + +func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) { + var params struct { + NoteID string `json:"note_id"` + Body *string `json:"body"` + Title *string `json:"title"` + Pinned *bool `json:"pinned"` + } + _ = json.Unmarshal(args, ¶ms) + + n, err := s.db.Note(ctx, params.NoteID) + if err != nil { + return nil, err + } + if n == nil || !s.mcpMayRead(ctx, n, userID) { + return nil, fmt.Errorf("no such note") + } + if n.ProjectID != "" { + if _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleEditor); err != nil { + return nil, err + } + } + + if params.Body != nil { + n.Body = *params.Body + } + if params.Title != nil { + n.Title = *params.Title + } + if params.Pinned != nil { + n.Pinned = *params.Pinned + } + if err := s.db.SaveNote(ctx, n); err != nil { + return nil, err + } + return map[string]any{"id": n.ID, "title": n.Title, "links": n.Links}, nil +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 6dba626..1e3dd18 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1464,7 +1464,10 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => { await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden(); const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); - await sidebar.getByRole('link', { name: 'Færdige' }).click(); + // Færdige left the sidebar: it is a rare errand next to four entries read every + // day, and it now sits with the other "where did that go" answers. + await page.goto('/indstillinger/data'); + await page.getByRole('link', { name: 'Åbn færdige opgaver' }).click(); await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible(); const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' }); diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index fb3c99f..7a88928 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -369,29 +369,27 @@ {t('nav.delegated')} {/if} - - - - {t('done.title')} - + {#if app.inbox} {@render projectRow(app.inbox, false)} - - - - - - {t('notes.title')} - - {/if} + + + +
+
+

{t('done.title')}

+

{t('data.doneHint')}

+
+ +
+

{t('data.trash')}

diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index c389122..3283293 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -168,6 +168,8 @@