Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c60bdaf9 | ||
|
|
5e670119db |
+2
-3
@@ -3308,7 +3308,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
project_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 }
|
body: { type: string, description: Markdown }
|
||||||
pinned: { type: boolean }
|
pinned: { type: boolean }
|
||||||
created_at: { type: string, format: date-time }
|
created_at: { type: string, format: date-time }
|
||||||
@@ -3326,8 +3326,7 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
project_id: { type: string, description: "Empty files it under no project." }
|
project_id: { type: string, description: "Empty files it under no project." }
|
||||||
title: { type: string }
|
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||||
body: { type: string }
|
|
||||||
pinned: { type: boolean }
|
pinned: { type: boolean }
|
||||||
|
|
||||||
Mailbox:
|
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 — " +
|
"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.",
|
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"body": mcp.Str("The note as Markdown. #Projekt and [[note]] become links."),
|
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #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."),
|
"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."),
|
"pinned": mcp.Bool("Keep it at the top of the list."),
|
||||||
}),
|
}),
|
||||||
@@ -545,8 +544,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
|||||||
"removed.",
|
"removed.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"note_id": mcp.Str("Which note."),
|
"note_id": mcp.Str("Which note."),
|
||||||
"body": mcp.Str("The whole new text, as Markdown."),
|
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
|
||||||
"title": mcp.Str("A new title."),
|
|
||||||
"pinned": mcp.Bool("Pin it or unpin it."),
|
"pinned": mcp.Bool("Pin it or unpin it."),
|
||||||
}),
|
}),
|
||||||
}, s.mcpUpdateNote)
|
}, 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) {
|
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||||
var params struct {
|
var params struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Title string `json:"title"`
|
|
||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
@@ -612,7 +609,6 @@ func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.Raw
|
|||||||
|
|
||||||
n := &store.Note{
|
n := &store.Note{
|
||||||
CreatedBy: userID,
|
CreatedBy: userID,
|
||||||
Title: params.Title,
|
|
||||||
Body: params.Body,
|
Body: params.Body,
|
||||||
Pinned: params.Pinned,
|
Pinned: params.Pinned,
|
||||||
}
|
}
|
||||||
@@ -638,7 +634,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw
|
|||||||
var params struct {
|
var params struct {
|
||||||
NoteID string `json:"note_id"`
|
NoteID string `json:"note_id"`
|
||||||
Body *string `json:"body"`
|
Body *string `json:"body"`
|
||||||
Title *string `json:"title"`
|
|
||||||
Pinned *bool `json:"pinned"`
|
Pinned *bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(args, ¶ms)
|
_ = json.Unmarshal(args, ¶ms)
|
||||||
@@ -659,9 +654,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw
|
|||||||
if params.Body != nil {
|
if params.Body != nil {
|
||||||
n.Body = *params.Body
|
n.Body = *params.Body
|
||||||
}
|
}
|
||||||
if params.Title != nil {
|
|
||||||
n.Title = *params.Title
|
|
||||||
}
|
|
||||||
if params.Pinned != nil {
|
if params.Pinned != nil {
|
||||||
n.Pinned = *params.Pinned
|
n.Pinned = *params.Pinned
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,9 +97,10 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, n)
|
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 {
|
type noteBody struct {
|
||||||
ProjectID *string `json:"project_id"`
|
ProjectID *string `json:"project_id"`
|
||||||
Title *string `json:"title"`
|
|
||||||
Body *string `json:"body"`
|
Body *string `json:"body"`
|
||||||
Pinned *bool `json:"pinned"`
|
Pinned *bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
@@ -119,9 +120,6 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
n.ProjectID = *body.ProjectID
|
n.ProjectID = *body.ProjectID
|
||||||
}
|
}
|
||||||
if body.Title != nil {
|
|
||||||
n.Title = *body.Title
|
|
||||||
}
|
|
||||||
if body.Body != nil {
|
if body.Body != nil {
|
||||||
n.Body = *body.Body
|
n.Body = *body.Body
|
||||||
}
|
}
|
||||||
@@ -164,9 +162,6 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
n.ProjectID = *body.ProjectID
|
n.ProjectID = *body.ProjectID
|
||||||
}
|
}
|
||||||
if body.Title != nil {
|
|
||||||
n.Title = *body.Title
|
|
||||||
}
|
|
||||||
if body.Body != nil {
|
if body.Body != nil {
|
||||||
n.Body = *body.Body
|
n.Body = *body.Body
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- #garageristeriet and #GarageRisteriet are the same project.
|
||||||
|
--
|
||||||
|
-- They were not, because the key was stored exactly as typed and looked up the
|
||||||
|
-- same way — so a note that named a project in lower case simply did not appear
|
||||||
|
-- on it, silently and with nothing to suggest why.
|
||||||
|
--
|
||||||
|
-- Folded on the way in from now on; this brings the rows already written into
|
||||||
|
-- line. Only projects: a [[note title]] is a title, and two notes called "Møde"
|
||||||
|
-- and "møde" are two notes.
|
||||||
|
--
|
||||||
|
-- The primary key is (note_id, kind, target_id), so folding can collide with a row
|
||||||
|
-- that is already there — a note that wrote both spellings. INSERT OR IGNORE into
|
||||||
|
-- a fresh set and swap, which keeps one of each rather than failing on the pair.
|
||||||
|
CREATE TABLE note_links_folded (
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('task', 'note', 'project')),
|
||||||
|
target_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (note_id, kind, target_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO note_links_folded (note_id, kind, target_id)
|
||||||
|
SELECT note_id, kind,
|
||||||
|
CASE WHEN kind = 'project' THEN lower(target_id) ELSE target_id END
|
||||||
|
FROM note_links;
|
||||||
|
|
||||||
|
DROP TABLE note_links;
|
||||||
|
ALTER TABLE note_links_folded RENAME TO note_links;
|
||||||
|
|
||||||
|
CREATE INDEX note_links_backwards ON note_links (kind, target_id);
|
||||||
+17
-6
@@ -62,11 +62,12 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
|||||||
}
|
}
|
||||||
n.UpdatedAt = now
|
n.UpdatedAt = now
|
||||||
|
|
||||||
// A note with no title takes its first line, the way Apple Notes does. A list
|
// The title is always the first line, the way Apple Notes does it. Not "when it
|
||||||
// of untitled notes is a list nobody can read.
|
// is empty": derived once and then left alone, it goes stale the moment somebody
|
||||||
if strings.TrimSpace(n.Title) == "" {
|
// rewrites the opening of a note, and the list ends up calling a note by a name
|
||||||
n.Title = firstLine(n.Body)
|
// 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 {
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
var projectID any
|
var projectID any
|
||||||
@@ -127,7 +128,14 @@ func LinksIn(body string) []NoteLink {
|
|||||||
add("task", strings.ToLower(m[1]))
|
add("task", strings.ToLower(m[1]))
|
||||||
}
|
}
|
||||||
for _, m := range projectRef.FindAllStringSubmatch(body, -1) {
|
for _, m := range projectRef.FindAllStringSubmatch(body, -1) {
|
||||||
add("project", m[1])
|
// Folded, because #garageristeriet and #GarageRisteriet are the same project
|
||||||
|
// to everybody except a database. The key is stored folded and looked up
|
||||||
|
// folded, so the comparison stays an exact match and keeps using its index —
|
||||||
|
// a comparison that lowered the column instead would read the whole table.
|
||||||
|
//
|
||||||
|
// The project's real spelling is not lost: it is on the project, which is
|
||||||
|
// what the interface shows. This column is a key, not a label.
|
||||||
|
add("project", strings.ToLower(m[1]))
|
||||||
}
|
}
|
||||||
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
|
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
|
||||||
add("note", strings.TrimSpace(m[1]))
|
add("note", strings.TrimSpace(m[1]))
|
||||||
@@ -209,6 +217,9 @@ func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
|||||||
// The reason note_links exists. Without it this would mean reading every note's
|
// The reason note_links exists. Without it this would mean reading every note's
|
||||||
// prose, and a panel that slow is one nobody leaves open.
|
// prose, and a panel that slow is one nobody leaves open.
|
||||||
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
|
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
|
||||||
|
if kind == "project" {
|
||||||
|
targetID = strings.ToLower(targetID)
|
||||||
|
}
|
||||||
return db.notesWhere(ctx, `
|
return db.notesWhere(ctx, `
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
|
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ func TestATagInANoteIsTheSameTagAsInATask(t *testing.T) {
|
|||||||
if len(projects) != 2 {
|
if len(projects) != 2 {
|
||||||
t.Fatalf("read %v out of the note, want the two projects", n.Links)
|
t.Fatalf("read %v out of the note, want the two projects", n.Links)
|
||||||
}
|
}
|
||||||
if projects[0] != "Firma" || projects[1] != "Regnskab" {
|
// Folded: the key is a key, not a label. #firma and #Firma are the same project
|
||||||
|
// to everybody except a database, and the project's own spelling is on the
|
||||||
|
// project, which is what the interface shows.
|
||||||
|
if projects[0] != "firma" || projects[1] != "regnskab" {
|
||||||
t.Errorf("got %v", projects)
|
t.Errorf("got %v", projects)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,10 +121,10 @@ func TestNotesAreFoundInEitherSpelling(t *testing.T) {
|
|||||||
db, userID := sealedStore(t)
|
db, userID := sealedStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// The title is the first line now, so that is where it goes.
|
||||||
if err := db.SaveNote(ctx, &Note{
|
if err := db.SaveNote(ctx, &Note{
|
||||||
CreatedBy: userID,
|
CreatedBy: userID,
|
||||||
Title: "Grønt regnskab",
|
Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.",
|
||||||
Body: "Målt på årsbasis for Århus.",
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -173,7 +176,9 @@ func TestWhatIsReadOutOfABody(t *testing.T) {
|
|||||||
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
|
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
|
||||||
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
|
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
|
||||||
{"the same tag twice is one link", "#Firma og #Firma igen",
|
{"the same tag twice is one link", "#Firma og #Firma igen",
|
||||||
[]NoteLink{{"project", "Firma"}}},
|
[]NoteLink{{"project", "firma"}}},
|
||||||
|
{"two spellings are one tag", "#Firma og #firma",
|
||||||
|
[]NoteLink{{"project", "firma"}}},
|
||||||
{"a heading is not a tag", "# Overskrift", nil},
|
{"a heading is not a tag", "# Overskrift", nil},
|
||||||
} {
|
} {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
@@ -245,3 +250,86 @@ func TestTheProjectCountIsWhatIsLeft(t *testing.T) {
|
|||||||
t.Errorf("after deleting one, the count is %d, want 1", n)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note that names a project in the other case must still turn up on it.
|
||||||
|
//
|
||||||
|
// It did not, and nothing said why: the key was stored exactly as typed and
|
||||||
|
// looked up the same way, so #garageristeriet and #GarageRisteriet were two
|
||||||
|
// different things that looked identical to a person.
|
||||||
|
func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := db.SaveNote(ctx, &Note{
|
||||||
|
CreatedBy: userID,
|
||||||
|
Body: "Bønnerne kommer fredag #garageristeriet",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, spelling := range []string{"GarageRisteriet", "garageristeriet", "GARAGERISTERIET"} {
|
||||||
|
found, err := db.NotesLinking(ctx, "project", spelling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Errorf("looking up %q found %d notes, want 1", spelling, len(found))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note title is a title, not a key: two notes called "Møde" and "møde" are
|
||||||
|
// two notes, and folding them together would merge things somebody kept apart.
|
||||||
|
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: "se [[Møde]]"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if found, _ := db.NotesLinking(ctx, "note", "møde"); len(found) != 0 {
|
||||||
|
t.Error("a note title was folded")
|
||||||
|
}
|
||||||
|
if found, _ := db.NotesLinking(ctx, "note", "Møde"); len(found) != 1 {
|
||||||
|
t.Error("a note title did not match itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2129,3 +2129,85 @@ test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ pa
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('et tag finder sit projekt uanset store og små bogstaver', 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('StoreBogstaver');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'StoreBogstaver' })).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();
|
||||||
|
|
||||||
|
// Written in lower case, the way somebody types when they are not thinking
|
||||||
|
// about it. It used to mean the note simply never appeared on the project, with
|
||||||
|
// nothing anywhere to say why.
|
||||||
|
await page.keyboard.type('Aftale om levering');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('Handler om #storebogstaver');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await ed.blur();
|
||||||
|
|
||||||
|
// The panel shows the project's own spelling, not the folded key: a label
|
||||||
|
// nobody wrote and that is nowhere in the note would be worse than none.
|
||||||
|
await expect(page.locator('.link', { hasText: '#StoreBogstaver' })).toBeVisible();
|
||||||
|
|
||||||
|
await sidebar.getByRole('link', { name: 'StoreBogstaver' }).click();
|
||||||
|
await expect(page.locator('.notes').getByRole('link', { name: /Aftale om levering/ })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
*/
|
*/
|
||||||
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
||||||
import { t } from '$lib/i18n.svelte.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();
|
let { note, onchange, onsave } = $props();
|
||||||
|
|
||||||
@@ -40,6 +42,13 @@
|
|||||||
active = {};
|
active = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SYNTAX = [
|
||||||
|
{ mark: '#', what: 'notes.syntaxProject' },
|
||||||
|
{ mark: '[[', what: 'notes.syntaxNote' },
|
||||||
|
{ mark: '⌘B', what: 'notes.bold' },
|
||||||
|
{ mark: '⌘U', what: 'notes.underline' }
|
||||||
|
];
|
||||||
|
|
||||||
const STYLES = [
|
const STYLES = [
|
||||||
{ key: 'title', tag: 'h1', label: 'notes.styleTitle' },
|
{ key: 'title', tag: 'h1', label: 'notes.styleTitle' },
|
||||||
{ key: 'heading', tag: 'h2', label: 'notes.styleHeading' },
|
{ key: 'heading', tag: 'h2', label: 'notes.styleHeading' },
|
||||||
@@ -96,6 +105,62 @@
|
|||||||
function changed() {
|
function changed() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
onchange?.(htmlToMarkdown(editor));
|
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. */
|
/** The block the caret is standing in. */
|
||||||
@@ -135,6 +200,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onkeydown(event) {
|
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) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
// After the split, not before it.
|
// After the split, not before it.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -231,11 +321,48 @@
|
|||||||
spellcheck="true"
|
spellcheck="true"
|
||||||
oninput={changed}
|
oninput={changed}
|
||||||
onblur={onsave}
|
onblur={onsave}
|
||||||
onkeyup={readState}
|
onkeyup={() => {
|
||||||
|
readState();
|
||||||
|
readSuggestions();
|
||||||
|
}}
|
||||||
onmouseup={readState}
|
onmouseup={readState}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
{onpaste}
|
{onpaste}
|
||||||
></div>
|
></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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -345,6 +472,54 @@
|
|||||||
color: var(--ink-muted);
|
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 {
|
.page {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 50vh;
|
min-height: 50vh;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export const da = {
|
|||||||
'notes.styleNumbered': 'Nummereret liste',
|
'notes.styleNumbered': 'Nummereret liste',
|
||||||
'notes.favourite': 'Gør til favorit',
|
'notes.favourite': 'Gør til favorit',
|
||||||
'notes.unfavourite': 'Fjern som favorit',
|
'notes.unfavourite': 'Fjern som favorit',
|
||||||
|
'notes.syntaxProject': 'projekt',
|
||||||
|
'notes.syntaxNote': 'en anden note',
|
||||||
'notes.title': 'Noter',
|
'notes.title': 'Noter',
|
||||||
'notes.new': 'Ny note',
|
'notes.new': 'Ny note',
|
||||||
'notes.search': 'Søg i noter',
|
'notes.search': 'Søg i noter',
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const en = {
|
|||||||
'notes.styleNumbered': 'Numbered list',
|
'notes.styleNumbered': 'Numbered list',
|
||||||
'notes.favourite': 'Make a favourite',
|
'notes.favourite': 'Make a favourite',
|
||||||
'notes.unfavourite': 'Remove from favourites',
|
'notes.unfavourite': 'Remove from favourites',
|
||||||
|
'notes.syntaxProject': 'project',
|
||||||
|
'notes.syntaxNote': 'another note',
|
||||||
'notes.title': 'Notes',
|
'notes.title': 'Notes',
|
||||||
'notes.new': 'New note',
|
'notes.new': 'New note',
|
||||||
'notes.search': 'Search notes',
|
'notes.search': 'Search notes',
|
||||||
|
|||||||
@@ -160,6 +160,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a link is called on screen.
|
||||||
|
*
|
||||||
|
* A project tag is stored folded, because it is a key — but showing the folded
|
||||||
|
* key would mean a note that says #garageristeriet is labelled with a spelling
|
||||||
|
* that appears nowhere, neither in the note nor on the project. The project's
|
||||||
|
* own name is the right answer, and the fold is what makes it findable.
|
||||||
|
*/
|
||||||
|
function label(link) {
|
||||||
|
if (link.kind !== 'project') return link.target_id;
|
||||||
|
const project = app.projects.find(
|
||||||
|
(p) => p.name.toLowerCase() === link.target_id.toLowerCase()
|
||||||
|
);
|
||||||
|
return '#' + (project?.name ?? link.target_id);
|
||||||
|
}
|
||||||
|
|
||||||
// What the note points at, for the panel under the editor.
|
// What the note points at, for the panel under the editor.
|
||||||
let links = $derived(selected?.links ?? []);
|
let links = $derived(selected?.links ?? []);
|
||||||
|
|
||||||
@@ -229,7 +245,7 @@
|
|||||||
{#if links.length}
|
{#if links.length}
|
||||||
<span class="links">
|
<span class="links">
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
<span class="link">{link.kind === 'project' ? '#' : ''}{link.target_id}</span>
|
<span class="link">{label(link)}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user