diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 00450e3..6dba626 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -1900,3 +1900,54 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature
expect(trouble).toEqual([]);
});
+
+test('en note kan skrives, findes igen, og peger på det den nævner', 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('Notetesteriet');
+ await sidebar.getByLabel('Projektnavn').press('Enter');
+ await expect(sidebar.getByRole('link', { name: 'Notetesteriet' })).toBeVisible();
+
+ await sidebar.getByRole('link', { name: 'Noter' }).click();
+ await expect(page.getByRole('heading', { name: 'Noter' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Ny note' }).click();
+ const body = page.getByLabel('Notens tekst');
+ await body.fill('Møde om #Notetesteriet\n\nHan vil gerne have kaffe hver uge.');
+ // Saved on the way out, so there is no Gem-knap to forget.
+ await body.blur();
+
+ // The title comes from the first line, the way Apple Notes does it. Without it
+ // a list of notes is a list of "Uden titel".
+ await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
+
+ // And the #tag was read out of the text, unasked. This is the whole point: it
+ // is the same tag a task carries, so the note can be found from the project.
+ // Scoped to the panel under the editor: the same text is also in the textarea
+ // and in the list preview, which is three of the same thing and none of them
+ // the one being asserted.
+ await expect(page.locator('.link', { hasText: '#Notetesteriet' })).toBeVisible();
+
+ // Findable by a word from the body, and in the other Danish spelling too.
+ await page.getByLabel('Søg i noter').fill('møde');
+ await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
+ await page.getByLabel('Søg i noter').fill('mode');
+ await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
+
+ // It survives a reload, which is the only proof that it was ever written down.
+ await page.reload();
+ await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
+
+ // And the payoff: the note turns up in the project it named, without anybody
+ // filing it there. This is what a #tag being the same tag actually buys.
+ await sidebar.getByRole('link', { name: 'Notetesteriet' }).click();
+ await expect(page.getByRole('heading', { name: 'Notetesteriet' })).toBeVisible();
+ await expect(
+ page.locator('.notes').getByRole('link', { name: /Møde om/ })
+ ).toBeVisible();
+
+ expect(trouble).toEqual([]);
+});
diff --git a/web/src/lib/api.js b/web/src/lib/api.js
index ab41c91..8846b96 100644
--- a/web/src/lib/api.js
+++ b/web/src/lib/api.js
@@ -360,6 +360,18 @@ export const api = {
authorizeGmail: () => post('/gmail/authorize'),
syncGmail: () => post('/gmail/sync'),
+ // Notes. Listing and searching are one endpoint because they answer the same
+ // question with different words.
+ notes: (params = {}) => {
+ const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
+ return get('/notes' + (q ? `?${q}` : ''));
+ },
+ note: (id) => get(`/notes/${id}`),
+ createNote: (body) => post('/notes', body),
+ updateNote: (id, body) => patch(`/notes/${id}`, body),
+ deleteNote: (id) => del(`/notes/${id}`),
+ notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
+
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
// there is no instance-wide registration behind these the way Gmail has one.
mailboxes: () => get('/mailboxes'),
diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte
index 0b576e3..fb3c99f 100644
--- a/web/src/lib/components/Sidebar.svelte
+++ b/web/src/lib/components/Sidebar.svelte
@@ -379,6 +379,16 @@
{#if app.inbox}
{@render projectRow(app.inbox, false)}
+
+
+
+
+
+ {t('notes.title')}
+
+
{/if}
diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js
index 47b9cce..793a090 100644
--- a/web/src/lib/locales/da.js
+++ b/web/src/lib/locales/da.js
@@ -16,6 +16,19 @@
*/
export const da = {
// --- the shell ---------------------------------------------------------------
+ 'notes.title': 'Noter',
+ 'notes.new': 'Ny note',
+ 'notes.search': 'Søg i noter',
+ 'notes.body': 'Notens tekst',
+ 'notes.placeholder': 'Skriv. #projekt og [[en anden note]] bliver til henvisninger.',
+ 'notes.untitled': 'Uden titel',
+ 'notes.noneYet': 'Ingen noter endnu.',
+ 'notes.noneFound': 'Ingen noter matcher.',
+ 'notes.pickOne': 'Vælg en note, eller lav en ny.',
+ 'notes.saving': 'Gemmer …',
+ 'notes.saved': 'Gemt',
+ 'notes.delete': 'Slet',
+ 'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.',
'nav.today': 'I dag',
'nav.upcoming': 'Kommende',
'nav.delegated': 'Venter på andre',
diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js
index 69dc5d2..7fb9526 100644
--- a/web/src/lib/locales/en.js
+++ b/web/src/lib/locales/en.js
@@ -12,6 +12,19 @@
*/
export const en = {
// --- the shell ---------------------------------------------------------------
+ 'notes.title': 'Notes',
+ 'notes.new': 'New note',
+ 'notes.search': 'Search notes',
+ 'notes.body': "The note's text",
+ 'notes.placeholder': 'Write. #project and [[another note]] become references.',
+ 'notes.untitled': 'Untitled',
+ 'notes.noneYet': 'No notes yet.',
+ 'notes.noneFound': 'No notes match.',
+ 'notes.pickOne': 'Pick a note, or write a new one.',
+ 'notes.saving': 'Saving …',
+ 'notes.saved': 'Saved',
+ 'notes.delete': 'Delete',
+ 'notes.deleteNote': 'Delete "{name}"? It can be brought back from the trash.',
'nav.today': 'Today',
'nav.upcoming': 'Upcoming',
'nav.delegated': 'Waiting on others',
diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte
new file mode 100644
index 0000000..c389122
--- /dev/null
+++ b/web/src/routes/noter/+page.svelte
@@ -0,0 +1,326 @@
+
+
+
{t('notes.pickOne')}
+ {/if} +{t('project.readOnly')}
{/if} + + {#if notes.length} +