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]))
|
||||
}
|
||||
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) {
|
||||
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
|
||||
// prose, and a panel that slow is one nobody leaves open.
|
||||
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
|
||||
if kind == "project" {
|
||||
targetID = strings.ToLower(targetID)
|
||||
}
|
||||
return db.notesWhere(ctx, `
|
||||
WHERE deleted_at IS NULL
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 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",
|
||||
[]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},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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([]);
|
||||
});
|
||||
|
||||
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.
|
||||
let links = $derived(selected?.links ?? []);
|
||||
|
||||
@@ -229,7 +245,7 @@
|
||||
{#if links.length}
|
||||
<span class="links">
|
||||
{#each links as link}
|
||||
<span class="link">{link.kind === 'project' ? '#' : ''}{link.target_id}</span>
|
||||
<span class="link">{label(link)}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user