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} + +
+ {#each SYNTAX as item} + {item.mark} {t(item.what)} + {/each} +