feat(notes): titlen er første linje, og # foreslår projektet
Release / Build and publish image (push) Failing after 9m59s
Release / Build and publish image (push) Failing after 9m59s
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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3223989a9d
commit
5e670119db
+2
-3
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
></div>
|
||||
|
||||
{#if suggestions.length}
|
||||
<!-- Anchored to the editor rather than to the caret. Following the caret means
|
||||
measuring it, and a list that jumps a few pixels as you type is worse than
|
||||
one that sits still. -->
|
||||
<ul class="suggestions" role="listbox">
|
||||
{#each suggestions as project, i (project.id)}
|
||||
<li>
|
||||
<button
|
||||
class:on={i === chosen}
|
||||
role="option"
|
||||
aria-selected={i === chosen}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(project);
|
||||
}}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{project.name}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- What the text understands, said where it is being typed. The same idea as
|
||||
the line under the quick-add box: a note is the other place in this program
|
||||
where what you type is read for meaning. -->
|
||||
<p class="legend">
|
||||
{#each SYNTAX as item}
|
||||
<span><kbd>{item.mark}</kbd> {t(item.what)}</span>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -345,6 +472,54 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s1);
|
||||
min-width: 200px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.suggestions button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--s1) var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.suggestions button.on {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s1) var(--s3);
|
||||
padding-top: var(--s2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.legend kbd {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page {
|
||||
flex: 1;
|
||||
min-height: 50vh;
|
||||
|
||||
@@ -36,6 +36,8 @@ export const da = {
|
||||
'notes.styleNumbered': 'Nummereret liste',
|
||||
'notes.favourite': 'Gør til favorit',
|
||||
'notes.unfavourite': 'Fjern som favorit',
|
||||
'notes.syntaxProject': 'projekt',
|
||||
'notes.syntaxNote': 'en anden note',
|
||||
'notes.title': 'Noter',
|
||||
'notes.new': 'Ny note',
|
||||
'notes.search': 'Søg i noter',
|
||||
|
||||
@@ -32,6 +32,8 @@ export const en = {
|
||||
'notes.styleNumbered': 'Numbered list',
|
||||
'notes.favourite': 'Make a favourite',
|
||||
'notes.unfavourite': 'Remove from favourites',
|
||||
'notes.syntaxProject': 'project',
|
||||
'notes.syntaxNote': 'another note',
|
||||
'notes.title': 'Notes',
|
||||
'notes.new': 'New note',
|
||||
'notes.search': 'Search notes',
|
||||
|
||||
Reference in New Issue
Block a user