From 5e670119db67c24bcaa0d27c7e69474a906de58d Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 18:36:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(notes):=20titlen=20er=20f=C3=B8rste=20linj?= =?UTF-8?q?e,=20og=20#=20foresl=C3=A5r=20projektet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Titlen var udledt én gang og derefter ladt i fred, så den gik i stå i det øjeblik nogen skrev begyndelsen om — en note med overskriften "Ny note" stod i listen som "fdgfgfgh", hvilket var, hvad dens første linje havde været nogle rettelser tidligere. Den udledes nu ved hver gemning, og ingen kan sætte den ved siden af teksten: kolonnen er en kopi af kroppen, ikke et felt derudover. Feltet er væk fra både HTTP-fladen og MCP-værktøjerne, for et felt, serveren ignorerer, er et løfte, den ikke kan holde. Skriver man #Kaf, foreslås #Kaffebaren. Ordet læses fra den tekstknude, markøren står i, og ikke fra hele dokumentet — en note kan nævne tyve projekter, og kun det under markøren er ved at blive skrevet. Listen ejer piletasterne og retur, mens den er åben, som enhver anden fuldførelse. Og under feltet står nu, hvad teksten forstår: # projekt, [[en anden note]], ⌘B, ⌘U. Samme idé som linjen under hurtig tilføjelse — en note er det andet sted i programmet, hvor det, man skriver, bliver læst for betydning. Co-Authored-By: Claude Opus 5 --- docs/openapi.yaml | 5 +- internal/httpapi/mcp_handlers.go | 12 +- internal/httpapi/notes_handlers.go | 9 +- internal/store/notes.go | 11 +- internal/store/notes_test.go | 48 +++++- web/e2e/smoke.spec.js | 48 ++++++ web/src/lib/components/NoteEditor.svelte | 177 ++++++++++++++++++++++- web/src/lib/locales/da.js | 2 + web/src/lib/locales/en.js | 2 + 9 files changed, 286 insertions(+), 28 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a0a806d..4caeb57 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3308,7 +3308,7 @@ components: properties: id: { type: string } project_id: { type: string } - title: { type: string, description: "Taken from the first line when not given." } + title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." } body: { type: string, description: Markdown } pinned: { type: boolean } created_at: { type: string, format: date-time } @@ -3326,8 +3326,7 @@ components: type: object properties: project_id: { type: string, description: "Empty files it under no project." } - title: { type: string } - body: { type: string } + body: { type: string, description: "Markdown. Its first line becomes the title." } pinned: { type: boolean } Mailbox: diff --git a/internal/httpapi/mcp_handlers.go b/internal/httpapi/mcp_handlers.go index ba0056c..c682355 100644 --- a/internal/httpapi/mcp_handlers.go +++ b/internal/httpapi/mcp_handlers.go @@ -530,8 +530,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) { "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."), + "body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."), "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."), }), @@ -545,8 +544,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) { "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."), + "body": mcp.Str("The whole new text, as Markdown. The title follows its first line."), "pinned": mcp.Bool("Pin it or unpin it."), }), }, s.mcpUpdateNote) @@ -604,7 +602,6 @@ func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) b 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"` } @@ -612,7 +609,6 @@ func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.Raw n := &store.Note{ CreatedBy: userID, - Title: params.Title, Body: params.Body, Pinned: params.Pinned, } @@ -638,7 +634,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw var params struct { NoteID string `json:"note_id"` Body *string `json:"body"` - Title *string `json:"title"` Pinned *bool `json:"pinned"` } _ = json.Unmarshal(args, ¶ms) @@ -659,9 +654,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw if params.Body != nil { n.Body = *params.Body } - if params.Title != nil { - n.Title = *params.Title - } if params.Pinned != nil { n.Pinned = *params.Pinned } diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index 98d7565..ddf1bed 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -97,9 +97,10 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, n) } +// No title. It is the note's first line and is derived on every save — a field +// here would be a promise this server cannot keep. type noteBody struct { ProjectID *string `json:"project_id"` - Title *string `json:"title"` Body *string `json:"body"` Pinned *bool `json:"pinned"` } @@ -119,9 +120,6 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) { } n.ProjectID = *body.ProjectID } - if body.Title != nil { - n.Title = *body.Title - } if body.Body != nil { n.Body = *body.Body } @@ -164,9 +162,6 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) { } n.ProjectID = *body.ProjectID } - if body.Title != nil { - n.Title = *body.Title - } if body.Body != nil { n.Body = *body.Body } diff --git a/internal/store/notes.go b/internal/store/notes.go index af5d792..de20bd2 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -62,11 +62,12 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error { } n.UpdatedAt = now - // A note with no title takes its first line, the way Apple Notes does. A list - // of untitled notes is a list nobody can read. - if strings.TrimSpace(n.Title) == "" { - n.Title = firstLine(n.Body) - } + // The title is always the first line, the way Apple Notes does it. Not "when it + // is empty": derived once and then left alone, it goes stale the moment somebody + // rewrites the opening of a note, and the list ends up calling a note by a name + // that is nowhere in it. The column is a cache of the body, not a field beside + // it, and every caller gets the same answer because none of them can set it. + n.Title = firstLine(n.Body) return db.Tx(ctx, func(tx *sql.Tx) error { var projectID any diff --git a/internal/store/notes_test.go b/internal/store/notes_test.go index 8986f1e..510930e 100644 --- a/internal/store/notes_test.go +++ b/internal/store/notes_test.go @@ -118,10 +118,10 @@ func TestNotesAreFoundInEitherSpelling(t *testing.T) { db, userID := sealedStore(t) ctx := context.Background() + // The title is the first line now, so that is where it goes. if err := db.SaveNote(ctx, &Note{ CreatedBy: userID, - Title: "Grønt regnskab", - Body: "Målt på årsbasis for Århus.", + Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.", }); err != nil { t.Fatal(err) } @@ -245,3 +245,47 @@ func TestTheProjectCountIsWhatIsLeft(t *testing.T) { t.Errorf("after deleting one, the count is %d, want 1", n) } } + +// The title follows the first line, always — not only the first time. +// +// Derived once and then left alone, it goes stale the moment somebody rewrites +// the opening, and the list ends up calling a note by a name that is nowhere in +// it. That is exactly what happened: a note headed "Ny note" was listed as +// "fdgfgfgh", which was what its first line had been some edits ago. +func TestTheTitleFollowsTheFirstLine(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + + n := &Note{CreatedBy: userID, Body: "Første udgave\n\nnoget tekst"} + if err := db.SaveNote(ctx, n); err != nil { + t.Fatal(err) + } + if n.Title != "Første udgave" { + t.Fatalf("title started as %q", n.Title) + } + + n.Body = "# Ny note\n\nDette er mit nye noteprogram" + if err := db.SaveNote(ctx, n); err != nil { + t.Fatal(err) + } + if n.Title != "Ny note" { + t.Errorf("after rewriting the first line the title is %q", n.Title) + } + + back, err := db.Note(ctx, n.ID) + if err != nil { + t.Fatal(err) + } + if back.Title != "Ny note" { + t.Errorf("the stored title is %q", back.Title) + } + + // And a title cannot be set beside the text, because then the two can disagree. + n.Title = "noget andet" + if err := db.SaveNote(ctx, n); err != nil { + t.Fatal(err) + } + if n.Title != "Ny note" { + t.Errorf("a title set by hand survived as %q", n.Title) + } +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 20601be..22893a9 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -2129,3 +2129,51 @@ test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ pa expect(trouble).toEqual([]); }); + +test('# foreslår et projekt, og titlen følger den første linje', 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('Forslagsprojektet'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + await expect(sidebar.getByRole('link', { name: 'Forslagsprojektet' })).toBeVisible(); + + await sidebar.getByRole('link', { name: 'Noter', exact: true }).click(); + await page.getByRole('button', { name: 'Ny note' }).click(); + const ed = page.getByRole('textbox', { name: 'Notens tekst' }); + await ed.click(); + + await page.keyboard.type('Første udkast'); + await page.keyboard.press('Enter'); + + // Half a tag is enough. The name is long and nobody should have to spell it. + await page.keyboard.type('Handler om #Forslags'); + const option = page.getByRole('option', { name: /Forslagsprojektet/ }).first(); + await expect(option).toBeVisible(); + + // Return takes the highlighted one — the list owns the key while it is open. + await page.keyboard.press('Enter'); + await expect(ed).toContainText('#Forslagsprojektet'); + await expect(option).toBeHidden(); + + // And the syntax is spelled out under the field, where it is being typed. + await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible(); + + // The title is the first line. It used to be derived once and then left alone, + // so rewriting the opening left the list calling the note by an old name. + await page.keyboard.press('Enter'); + await ed.blur(); + await expect(page.getByRole('button', { name: /Første udkast/ })).toBeVisible(); + + // Rewrite the first line; the list has to follow. + await ed.click(); + await page.keyboard.press('ControlOrMeta+Home'); + for (let i = 0; i < 13; i++) await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.type('Andet udkast'); + await ed.blur(); + await expect(page.getByRole('button', { name: /Andet udkast/ })).toBeVisible(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index f082112..fd32b95 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -15,6 +15,8 @@ */ import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js'; import { t } from '$lib/i18n.svelte.js'; + import { app } from '$lib/stores.svelte.js'; + import { colorVar } from '$lib/colors.js'; let { note, onchange, onsave } = $props(); @@ -40,6 +42,13 @@ active = {}; }); + const SYNTAX = [ + { mark: '#', what: 'notes.syntaxProject' }, + { mark: '[[', what: 'notes.syntaxNote' }, + { mark: '⌘B', what: 'notes.bold' }, + { mark: '⌘U', what: 'notes.underline' } + ]; + const STYLES = [ { key: 'title', tag: 'h1', label: 'notes.styleTitle' }, { key: 'heading', tag: 'h2', label: 'notes.styleHeading' }, @@ -96,6 +105,62 @@ function changed() { if (!editor) return; onchange?.(htmlToMarkdown(editor)); + readSuggestions(); + } + + // --- suggesting a project ------------------------------------------------------ + + let suggestions = $state([]); + let chosen = $state(0); + + /** + * The word being typed after a #, if that is what is happening. + * + * Read from the text node the caret is in rather than from the whole document: + * a note can mention twenty projects, and only the one under the cursor is being + * written. + */ + function partialTag() { + const selection = window.getSelection(); + const node = selection?.anchorNode; + if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null; + + const before = node.textContent.slice(0, selection.anchorOffset); + const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before); + if (!match) return null; + return { node, start: selection.anchorOffset - match[1].length, term: match[1] }; + } + + function readSuggestions() { + const partial = partialTag(); + if (!partial) { + suggestions = []; + return; + } + const term = partial.term.toLowerCase(); + suggestions = app.projects + .filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term)) + .slice(0, 6); + chosen = 0; + } + + /** Replaces the half-typed tag with the whole name. */ + function accept(project) { + const partial = partialTag(); + if (!partial) return; + + const range = document.createRange(); + range.setStart(partial.node, partial.start); + range.setEnd(partial.node, partial.start + partial.term.length); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + // A trailing space, because the next thing typed is a word and not more of + // the tag — and without it the suggestions come straight back. + document.execCommand('insertText', false, project.name + ' '); + suggestions = []; + onchange?.(htmlToMarkdown(editor)); } /** The block the caret is standing in. */ @@ -135,6 +200,31 @@ } function onkeydown(event) { + // The suggestion list owns the arrows and return while it is open, the way + // every other completion does. Escape closes it without choosing. + if (suggestions.length) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + chosen = (chosen + 1) % suggestions.length; + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + chosen = (chosen - 1 + suggestions.length) % suggestions.length; + return; + } + if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault(); + accept(suggestions[chosen]); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + suggestions = []; + return; + } + } + if (event.key === 'Enter' && !event.shiftKey) { // After the split, not before it. setTimeout(() => { @@ -231,11 +321,48 @@ spellcheck="true" oninput={changed} onblur={onsave} - onkeyup={readState} + onkeyup={() => { + readState(); + readSuggestions(); + }} onmouseup={readState} {onkeydown} {onpaste} > + + {#if suggestions.length} + + + {/if} + + +

+ {#each SYNTAX as item} + {item.mark} {t(item.what)} + {/each} +