Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c60bdaf9 |
@@ -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);
|
||||||
+11
-1
@@ -128,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]))
|
||||||
@@ -210,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
@@ -289,3 +294,42 @@ func TestTheTitleFollowsTheFirstLine(t *testing.T) {
|
|||||||
t.Errorf("a title set by hand survived as %q", n.Title)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2177,3 +2177,37 @@ test('# foreslår et projekt, og titlen følger den første linje', async ({ pag
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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